From 685ff91d7446b5e53f08438c81eb2a817a5dcd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Sat, 2 May 2026 17:49:19 +0400 Subject: [PATCH] Extract snapshot functions into snapshot.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/cookiebanner/service.go | 153 ------------------------------ pkg/cookiebanner/snapshot.go | 176 +++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 153 deletions(-) create mode 100644 pkg/cookiebanner/snapshot.go diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 76207a0f9..01a5eadc1 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -15,14 +15,12 @@ package cookiebanner import ( - "bytes" "context" "encoding/json" "errors" "fmt" "maps" "net/url" - "reflect" "slices" "strings" "time" @@ -353,157 +351,6 @@ func CanonicalizeOrigin(raw string) string { return u.Scheme + "://" + host } -// snapshotsEqual reports whether two version snapshots are visitor-identical. -// buildSnapshot already normalises empty slices and nil maps, so reflect.DeepEqual -// is sufficient and is the single chokepoint we'd extend if we ever wanted to -// ignore particular fields. -func snapshotsEqual(a, b coredata.CookieBannerVersionSnapshot) bool { - return reflect.DeepEqual(a, b) -} - -// snapshotCategoryKindOrder returns a stable weight per Kind so the snapshot -// keeps the visitor-facing layout invariants (NECESSARY first, UNCATEGORISED -// last) without depending on the admin-controlled rank. -func snapshotCategoryKindOrder(k coredata.CookieCategoryKind) int { - switch k { - case coredata.CookieCategoryKindNecessary: - return 0 - case coredata.CookieCategoryKindNormal: - return 1 - case coredata.CookieCategoryKindUncategorised: - return 2 - default: - return 3 - } -} - -// sortCategoriesForSnapshot returns the categories ordered for snapshot -// rendering. The order is (Kind weight, ID byte order); rank is intentionally -// ignored so reordering is admin-only metadata and does not bump the version. -func sortCategoriesForSnapshot(categories coredata.CookieCategories) coredata.CookieCategories { - sorted := make(coredata.CookieCategories, len(categories)) - copy(sorted, categories) - slices.SortStableFunc(sorted, func(a, b *coredata.CookieCategory) int { - if d := snapshotCategoryKindOrder(a.Kind) - snapshotCategoryKindOrder(b.Kind); d != 0 { - return d - } - return bytes.Compare(a.ID[:], b.ID[:]) - }) - return sorted -} - -func buildSnapshot( - banner *coredata.CookieBanner, - categories coredata.CookieCategories, - allPatterns coredata.CookiePatterns, - translations coredata.CookieBannerTranslations, -) coredata.CookieBannerVersionSnapshot { - categories = sortCategoriesForSnapshot(categories) - - cookiesByCategory := make(map[gid.GID]coredata.CookieItems) - for _, p := range allPatterns { - cookiesByCategory[p.CookieCategoryID] = append( - cookiesByCategory[p.CookieCategoryID], - coredata.CookieItem{ - Name: p.DisplayName, - MaxAgeSeconds: p.MaxAgeSeconds, - Description: p.Description, - }, - ) - } - - snapshotCategories := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories)) - for i, c := range categories { - cookies := cookiesByCategory[c.ID] - if cookies == nil { - cookies = coredata.CookieItems{} - } - gcmConsentTypes := c.GCMConsentTypes - if gcmConsentTypes == nil { - gcmConsentTypes = []string{} - } - snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{ - Name: c.Name, - Slug: c.Slug, - Description: c.Description, - Kind: c.Kind, - Cookies: cookies, - GCMConsentTypes: gcmConsentTypes, - PostHogConsent: c.PostHogConsent, - } - } - - snapshotTranslations := buildSnapshotTranslations(translations, categories) - - return coredata.CookieBannerVersionSnapshot{ - PrivacyPolicyURL: banner.PrivacyPolicyURL, - CookiePolicyURL: banner.CookiePolicyURL, - ConsentExpiryDays: banner.ConsentExpiryDays, - ConsentMode: string(banner.ConsentMode), - DefaultLanguage: banner.DefaultLanguage, - Categories: snapshotCategories, - Translations: snapshotTranslations, - } -} - -func buildSnapshotTranslations( - translations coredata.CookieBannerTranslations, - categories coredata.CookieCategories, -) map[string]coredata.CookieBannerVersionSnapshotTranslation { - if len(translations) == 0 { - return nil - } - - result := make(map[string]coredata.CookieBannerVersionSnapshotTranslation, len(translations)) - - for _, t := range translations { - var raw struct { - Categories map[string]struct { - Name string `json:"name"` - Description string `json:"description"` - } `json:"categories"` - } - _ = json.Unmarshal(t.Translations, &raw) - - ui := make(map[string]string) - var flat map[string]json.RawMessage - _ = json.Unmarshal(t.Translations, &flat) - for k, v := range flat { - if k == "categories" || k == "cookies" { - continue - } - var s string - if json.Unmarshal(v, &s) == nil { - ui[k] = s - } - } - - catTranslations := make([]coredata.CookieBannerVersionSnapshotCategoryTranslation, len(categories)) - for i, c := range categories { - if raw.Categories != nil { - if ct, ok := raw.Categories[c.ID.String()]; ok { - catTranslations[i] = coredata.CookieBannerVersionSnapshotCategoryTranslation{ - Name: ct.Name, - Description: ct.Description, - } - continue - } - } - catTranslations[i] = coredata.CookieBannerVersionSnapshotCategoryTranslation{ - Name: c.Name, - Description: c.Description, - } - } - - result[t.Language] = coredata.CookieBannerVersionSnapshotTranslation{ - UI: ui, - Categories: catTranslations, - } - } - - return result -} - func (s *Service) ensureDraftVersion( ctx context.Context, tx pg.Tx, diff --git a/pkg/cookiebanner/snapshot.go b/pkg/cookiebanner/snapshot.go new file mode 100644 index 000000000..8c6ded86c --- /dev/null +++ b/pkg/cookiebanner/snapshot.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cookiebanner + +import ( + "bytes" + "encoding/json" + "reflect" + "slices" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// snapshotsEqual reports whether two version snapshots are visitor-identical. +// buildSnapshot already normalises empty slices and nil maps, so reflect.DeepEqual +// is sufficient and is the single chokepoint we'd extend if we ever wanted to +// ignore particular fields. +func snapshotsEqual(a, b coredata.CookieBannerVersionSnapshot) bool { + return reflect.DeepEqual(a, b) +} + +// snapshotCategoryKindOrder returns a stable weight per Kind so the snapshot +// keeps the visitor-facing layout invariants (NECESSARY first, UNCATEGORISED +// last) without depending on the admin-controlled rank. +func snapshotCategoryKindOrder(k coredata.CookieCategoryKind) int { + switch k { + case coredata.CookieCategoryKindNecessary: + return 0 + case coredata.CookieCategoryKindNormal: + return 1 + case coredata.CookieCategoryKindUncategorised: + return 2 + default: + return 3 + } +} + +// sortCategoriesForSnapshot returns the categories ordered for snapshot +// rendering. The order is (Kind weight, ID byte order); rank is intentionally +// ignored so reordering is admin-only metadata and does not bump the version. +func sortCategoriesForSnapshot(categories coredata.CookieCategories) coredata.CookieCategories { + sorted := make(coredata.CookieCategories, len(categories)) + copy(sorted, categories) + slices.SortStableFunc(sorted, func(a, b *coredata.CookieCategory) int { + if d := snapshotCategoryKindOrder(a.Kind) - snapshotCategoryKindOrder(b.Kind); d != 0 { + return d + } + return bytes.Compare(a.ID[:], b.ID[:]) + }) + return sorted +} + +func buildSnapshot( + banner *coredata.CookieBanner, + categories coredata.CookieCategories, + allPatterns coredata.CookiePatterns, + translations coredata.CookieBannerTranslations, +) coredata.CookieBannerVersionSnapshot { + categories = sortCategoriesForSnapshot(categories) + + cookiesByCategory := make(map[gid.GID]coredata.CookieItems) + for _, p := range allPatterns { + cookiesByCategory[p.CookieCategoryID] = append( + cookiesByCategory[p.CookieCategoryID], + coredata.CookieItem{ + Name: p.DisplayName, + MaxAgeSeconds: p.MaxAgeSeconds, + Description: p.Description, + }, + ) + } + + snapshotCategories := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories)) + for i, c := range categories { + cookies := cookiesByCategory[c.ID] + if cookies == nil { + cookies = coredata.CookieItems{} + } + gcmConsentTypes := c.GCMConsentTypes + if gcmConsentTypes == nil { + gcmConsentTypes = []string{} + } + snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{ + Name: c.Name, + Slug: c.Slug, + Description: c.Description, + Kind: c.Kind, + Cookies: cookies, + GCMConsentTypes: gcmConsentTypes, + PostHogConsent: c.PostHogConsent, + } + } + + snapshotTranslations := buildSnapshotTranslations(translations, categories) + + return coredata.CookieBannerVersionSnapshot{ + PrivacyPolicyURL: banner.PrivacyPolicyURL, + CookiePolicyURL: banner.CookiePolicyURL, + ConsentExpiryDays: banner.ConsentExpiryDays, + ConsentMode: string(banner.ConsentMode), + DefaultLanguage: banner.DefaultLanguage, + Categories: snapshotCategories, + Translations: snapshotTranslations, + } +} + +func buildSnapshotTranslations( + translations coredata.CookieBannerTranslations, + categories coredata.CookieCategories, +) map[string]coredata.CookieBannerVersionSnapshotTranslation { + if len(translations) == 0 { + return nil + } + + result := make(map[string]coredata.CookieBannerVersionSnapshotTranslation, len(translations)) + + for _, t := range translations { + var raw struct { + Categories map[string]struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"categories"` + } + _ = json.Unmarshal(t.Translations, &raw) + + ui := make(map[string]string) + var flat map[string]json.RawMessage + _ = json.Unmarshal(t.Translations, &flat) + for k, v := range flat { + if k == "categories" || k == "cookies" { + continue + } + var s string + if json.Unmarshal(v, &s) == nil { + ui[k] = s + } + } + + catTranslations := make([]coredata.CookieBannerVersionSnapshotCategoryTranslation, len(categories)) + for i, c := range categories { + if raw.Categories != nil { + if ct, ok := raw.Categories[c.ID.String()]; ok { + catTranslations[i] = coredata.CookieBannerVersionSnapshotCategoryTranslation{ + Name: ct.Name, + Description: ct.Description, + } + continue + } + } + catTranslations[i] = coredata.CookieBannerVersionSnapshotCategoryTranslation{ + Name: c.Name, + Description: c.Description, + } + } + + result[t.Language] = coredata.CookieBannerVersionSnapshotTranslation{ + UI: ui, + Categories: catTranslations, + } + } + + return result +}