Skip to content

Commit 101e00e

Browse files
committed
fix(import): consume complete Gemini Apps timestamp zones
1 parent 4b2b8ae commit 101e00e

5 files changed

Lines changed: 133 additions & 17 deletions

File tree

docs/chat-import.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ activity kinds are reported as skipped. Each prompt becomes a one-turn session;
3636
the importer resolves the timestamp's explicit exported zone instead of using
3737
the host timezone. Mixed Takeout archives may contain other product activity;
3838
those explicitly identified cells are ignored. The current parser supports the
39-
observed English rendering for Gemini Apps cells. Declared non-English or
40-
otherwise unsupported localized Gemini candidates are reported as unsupported
41-
before any sessions are emitted.
39+
observed English rendering for Gemini Apps cells, including the named zones it
40+
currently recognizes and complete `GMT±H`, `GMT±HH`, `GMT±H:MM`, and
41+
`GMT±HH:MM` zones; omitted minutes mean zero. Declared non-English or otherwise
42+
unsupported localized Gemini candidates and malformed zone tokens are reported
43+
as unsupported before any sessions are emitted.
4244

4345
## Importing via the UI
4446

docs/internal/session-format-sources.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,11 @@ Grok section and remove the explicit registry exception in the coverage test.
273273
were searched 2026-08-01. Google does not publish a versioned Gemini Apps
274274
activity HTML schema, so markup, labels, timestamp zones, and future record
275275
kinds remain observed compatibility evidence from sanitized exports. No
276-
translated label or timestamp vocabulary is claimed; unsupported localized
277-
formats return an explicit compatibility error.
276+
translated label or timestamp vocabulary is claimed. Timestamp compatibility
277+
includes the existing named zones and complete `GMT±H`, `GMT±HH`,
278+
`GMT±H:MM`, and `GMT±HH:MM` forms, with omitted minutes treated as zero;
279+
unsupported localized formats and malformed zone tokens return an explicit
280+
compatibility error.
278281
- **Usage and cost:** Takeout activity records expose no authoritative token,
279282
cache, reasoning, credit, or monetary-cost fields to Agentsview.
280283
- **Agentsview:** `internal/parser/gemini_apps_takeout.go` and

internal/importer/gemini_apps_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,45 @@ func TestImportGeminiAppsUnknownZoneDoesNotWriteSession(t *testing.T) {
237237
assert.Empty(t, page.Sessions)
238238
}
239239

240+
func TestImportGeminiAppsWholeHourZonePersistsCorrectTimestamp(t *testing.T) {
241+
root := t.TempDir()
242+
path := filepath.Join(root, "activity.html")
243+
valid := geminiAppsImportPromptedCell(
244+
"Jan 2, 2025, 3:04:05 PM GMT+8", "prompt",
245+
)
246+
malformed := geminiAppsImportPromptedCell(
247+
"Jan 3, 2025, 3:04:05 PM GMT+8junk", "malformed",
248+
)
249+
require.NoError(t, os.WriteFile(path, []byte(geminiAppsImportDocument(valid)), 0o644))
250+
251+
d := testDB(t)
252+
stats, err := ImportGeminiApps(context.Background(), d, root, nil)
253+
require.NoError(t, err)
254+
assert.Equal(t, 1, stats.Imported)
255+
256+
page, err := d.ListSessions(context.Background(), db.SessionFilter{Agent: "gemini-apps"})
257+
require.NoError(t, err)
258+
require.Len(t, page.Sessions, 1)
259+
stored, err := d.GetSessionFull(context.Background(), page.Sessions[0].ID)
260+
require.NoError(t, err)
261+
require.NotNil(t, stored.StartedAt)
262+
assert.Equal(t, "2025-01-02T07:04:05Z", *stored.StartedAt)
263+
messages, err := d.GetAllMessages(context.Background(), page.Sessions[0].ID)
264+
require.NoError(t, err)
265+
require.Len(t, messages, 1)
266+
assert.Equal(t, *stored.StartedAt, messages[0].Timestamp)
267+
268+
require.NoError(t, os.WriteFile(path, []byte(geminiAppsImportDocument(malformed)), 0o644))
269+
stats, err = ImportGeminiApps(context.Background(), d, root, nil)
270+
assert.Error(t, err)
271+
assert.Zero(t, stats.Imported)
272+
assert.Zero(t, stats.Updated)
273+
assert.Zero(t, stats.Errors)
274+
page, err = d.ListSessions(context.Background(), db.SessionFilter{Agent: "gemini-apps"})
275+
require.NoError(t, err)
276+
assert.Len(t, page.Sessions, 1)
277+
}
278+
240279
func TestImportGeminiAppsPersistenceGuards(t *testing.T) {
241280
t.Run("unchanged and superset preserve revision", func(t *testing.T) {
242281
root := t.TempDir()

internal/parser/gemini_apps_takeout.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type GeminiAppsExportParser interface {
2222
ParseGeminiAppsExport(string, func(ParseResult) error) (GeminiAppsParseSummary, error)
2323
}
2424

25-
var geminiAppsTimestampRE = regexp.MustCompile(`(?i)\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4},\s+\d{1,2}:\d{2}:\d{2}[\x{00a0}\x{202f} ]*(?:AM|PM)[\x{00a0}\x{202f} ]+(GMT[+-]\d{1,2}:\d{2}|[A-Za-z]{2,5})\b`)
25+
var geminiAppsTimestampRE = regexp.MustCompile(`(?i)\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4},\s+\d{1,2}:\d{2}:\d{2}[\x{00a0}\x{202f} ]*(?:AM|PM)[\x{00a0}\x{202f} ]+(\S+)`)
2626
var geminiAppsTimestampLikeRE = regexp.MustCompile(`(?i)(?:\b\d{1,2}\D+\d{4}\b|\b\d{4}\D+\d{1,2}\D+\d{1,2}\b)`)
2727

2828
type geminiAppsFilePlan struct {
@@ -529,16 +529,37 @@ func geminiAppsZoneOffset(zone string) (int, bool) {
529529
return -7 * 60 * 60, true
530530
}
531531
if strings.HasPrefix(zone, "GMT+") || strings.HasPrefix(zone, "GMT-") {
532-
parts := strings.Split(strings.TrimPrefix(zone, "GMT"), ":")
533-
if len(parts) != 2 {
532+
numeric := strings.TrimPrefix(strings.TrimPrefix(zone, "GMT+"), "GMT-")
533+
if numeric == "" {
534534
return 0, false
535535
}
536536
sign := 1
537-
if strings.HasPrefix(parts[0], "-") {
537+
if zone[3] == '-' {
538538
sign = -1
539539
}
540-
hours, e1 := strconv.Atoi(strings.TrimLeft(parts[0], "+-"))
541-
minutes, e2 := strconv.Atoi(parts[1])
540+
parts := strings.Split(numeric, ":")
541+
if len(parts) > 2 || len(parts[0]) < 1 || len(parts[0]) > 2 {
542+
return 0, false
543+
}
544+
for _, part := range parts {
545+
if part == "" {
546+
return 0, false
547+
}
548+
for _, char := range part {
549+
if char < '0' || char > '9' {
550+
return 0, false
551+
}
552+
}
553+
}
554+
if len(parts) == 2 && len(parts[1]) != 2 {
555+
return 0, false
556+
}
557+
hours, e1 := strconv.Atoi(parts[0])
558+
minutes := 0
559+
var e2 error
560+
if len(parts) == 2 {
561+
minutes, e2 = strconv.Atoi(parts[1])
562+
}
542563
if e1 != nil || e2 != nil || hours > 23 || minutes > 59 {
543564
return 0, false
544565
}

internal/parser/gemini_apps_takeout_test.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ func TestParseGeminiAppsIDsDisambiguateSameTimestampOccurrences(t *testing.T) {
151151
assert.Equal(t, ids, repeated)
152152
}
153153

154+
func TestParseGeminiAppsEquivalentNumericZonesKeepStableIdentity(t *testing.T) {
155+
cell := func(zone string) string {
156+
return geminiAppsProductCellHTML(
157+
"Gemini Apps", "Prompted",
158+
"Jan 2, 2025, 3:04:05 PM "+zone, "<p>prompt</p>",
159+
)
160+
}
161+
ids := parseGeminiAppsIDs(t, geminiAppsPromptedDocument(cell("GMT+8")))
162+
padded := parseGeminiAppsIDs(t, geminiAppsPromptedDocument(cell("GMT+08:00")))
163+
assert.Equal(t, ids["prompt"], padded["prompt"])
164+
}
165+
154166
func TestParseGeminiAppsRejectsNotPromptedActivityLabel(t *testing.T) {
155167
fixture := strings.ReplaceAll(
156168
sanitizedGeminiAppsHTML,
@@ -1027,6 +1039,36 @@ func TestParseGeminiAppsPreflightsUnknownZoneFileBeforeCallback(t *testing.T) {
10271039
assert.Zero(t, callbacks)
10281040
}
10291041

1042+
func TestParseGeminiAppsPreflightsMalformedZoneBeforeCallback(t *testing.T) {
1043+
supported := geminiAppsSingleCellHTML(
1044+
"", "My Activity History", "Prompted",
1045+
"Jan 2, 2025, 3:04:05 PM EDT", "<p>prompt</p>",
1046+
)
1047+
for _, zone := range []string{"GMT+8:3", "GMT+8junk", "GMT+8:30junk", "GMT+24", "GMT+8:60"} {
1048+
t.Run(zone, func(t *testing.T) {
1049+
unsupported := geminiAppsSingleCellHTML(
1050+
"", "My Activity History", "Prompted",
1051+
"Jan 3, 2025, 3:04:05 PM "+zone, "<p>prompt</p>",
1052+
)
1053+
path := filepath.Join(t.TempDir(), "malformed-zone.html")
1054+
require.NoError(t, os.WriteFile(path, []byte(strings.Replace(
1055+
supported, "</body>", unsupported+"</body>", 1),
1056+
), 0o644))
1057+
1058+
provider, ok := NewProvider(AgentGeminiApps, ProviderConfig{})
1059+
require.True(t, ok)
1060+
exporter := provider.(GeminiAppsExportParser)
1061+
callbacks := 0
1062+
_, err := exporter.ParseGeminiAppsExport(path, func(ParseResult) error {
1063+
callbacks++
1064+
return nil
1065+
})
1066+
assert.Error(t, err)
1067+
assert.Zero(t, callbacks)
1068+
})
1069+
}
1070+
}
1071+
10301072
func TestParseGeminiAppsSkipsUnknownCompatibleActivityLabels(t *testing.T) {
10311073
for _, label := range []string{"Nicht Prompted", "Unknown Ereignis", "Prompted extra"} {
10321074
t.Run(label, func(t *testing.T) {
@@ -1123,6 +1165,9 @@ func TestParseGeminiAppsTimestampUsesExplicitZones(t *testing.T) {
11231165
}{
11241166
{"edt", "Jan 2, 2025, 3:04:05 PM EDT", "2025-01-02T19:04:05Z"},
11251167
{"pst", "Jan 2, 2025, 3:04:05 PM PST", "2025-01-02T23:04:05Z"},
1168+
{"whole-hour", "Jan 2, 2025, 3:04:05 PM GMT+8", "2025-01-02T07:04:05Z"},
1169+
{"whole-hour-padded", "Jan 2, 2025, 3:04:05 PM GMT+08:00", "2025-01-02T07:04:05Z"},
1170+
{"whole-hour-negative", "Jan 2, 2025, 3:04:05 PM GMT-8", "2025-01-02T23:04:05Z"},
11261171
{"numeric", "Jan 2, 2025, 3:04:05 PM GMT+05:30", "2025-01-02T09:34:05Z"},
11271172
{"negative-zero", "Jan 2, 2025, 3:04:05 PM GMT-00:30", "2025-01-02T15:34:05Z"},
11281173
}
@@ -1136,12 +1181,18 @@ func TestParseGeminiAppsTimestampUsesExplicitZones(t *testing.T) {
11361181
})
11371182
}
11381183

1139-
match := geminiAppsTimestampRE.FindStringSubmatch(
1140-
"Jan 2, 2025, 3:04:05 PM XYZ",
1141-
)
1142-
require.Len(t, match, 2)
1143-
_, err := parseGeminiAppsTimestamp(match[0], match[1])
1144-
assert.ErrorContains(t, err, "unsupported")
1184+
for _, zone := range []string{
1185+
"GMT+8:3", "GMT+8junk", "GMT+8:30junk", "GMT+24", "GMT+8:60", "XYZ",
1186+
} {
1187+
t.Run("reject-"+zone, func(t *testing.T) {
1188+
match := geminiAppsTimestampRE.FindStringSubmatch(
1189+
"Jan 2, 2025, 3:04:05 PM " + zone,
1190+
)
1191+
require.Len(t, match, 2)
1192+
_, err := parseGeminiAppsTimestamp(match[0], match[1])
1193+
assert.ErrorContains(t, err, "unsupported")
1194+
})
1195+
}
11451196
}
11461197

11471198
func TestParseGeminiAppsMissingContentCellIsCountedError(t *testing.T) {

0 commit comments

Comments
 (0)