Skip to content

Commit 2dbc869

Browse files
committed
fix(import): enforce Gemini Takeout field boundaries
1 parent dcf4b27 commit 2dbc869

4 files changed

Lines changed: 148 additions & 47 deletions

File tree

cmd/agentsview/import.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ func runImport(cfg ImportConfig) {
5757
}
5858

5959
if err != nil {
60-
fmt.Fprintln(os.Stderr)
60+
if summary := formatImportFailureSummary(stats); summary != "" {
61+
fmt.Fprint(os.Stderr, summary)
62+
} else {
63+
fmt.Fprintln(os.Stderr)
64+
}
6165
log.Fatalf("Import failed: %v", err)
6266
}
6367

@@ -168,8 +172,13 @@ func runGeminiAppsImport(
168172
}
169173

170174
func printImportSummary(stats importer.ImportStats) {
175+
fmt.Fprint(os.Stderr, formatImportSummary(stats))
176+
}
177+
178+
func formatImportSummary(stats importer.ImportStats) string {
179+
var summary strings.Builder
171180
total := stats.Imported + stats.Updated + stats.Skipped
172-
fmt.Fprintf(os.Stderr, "\rDone: %d processed", total)
181+
fmt.Fprintf(&summary, "\rDone: %d processed", total)
173182
var parts []string
174183
if stats.Imported > 0 {
175184
parts = append(
@@ -187,14 +196,20 @@ func printImportSummary(stats importer.ImportStats) {
187196
)
188197
}
189198
if len(parts) > 0 {
190-
fmt.Fprintf(
191-
os.Stderr, " (%s)", strings.Join(parts, ", "),
192-
)
199+
fmt.Fprintf(&summary, " (%s)", strings.Join(parts, ", "))
193200
}
194-
fmt.Fprintln(os.Stderr)
201+
fmt.Fprintln(&summary)
195202
if stats.Errors > 0 {
196-
fmt.Fprintf(os.Stderr, " %d errors\n", stats.Errors)
203+
fmt.Fprintf(&summary, " %d errors\n", stats.Errors)
204+
}
205+
return summary.String()
206+
}
207+
208+
func formatImportFailureSummary(stats importer.ImportStats) string {
209+
if stats.Imported+stats.Updated+stats.Skipped+stats.Errors == 0 {
210+
return ""
197211
}
212+
return formatImportSummary(stats)
198213
}
199214

200215
// resolveImportSource handles zip extraction. If the path is

cmd/agentsview/parse_diff_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/stretchr/testify/require"
2020
"go.kenn.io/agentsview/internal/db"
2121
"go.kenn.io/agentsview/internal/dbtest"
22+
"go.kenn.io/agentsview/internal/importer"
2223
"go.kenn.io/agentsview/internal/parser"
2324
"go.kenn.io/agentsview/internal/sync"
2425
"go.kenn.io/agentsview/internal/testjsonl"
@@ -63,6 +64,20 @@ func TestGeminiAppsImportDispatchesDirectAndZipSources(t *testing.T) {
6364
filepath.Join(t.TempDir(), "missing.html"), t.TempDir(), "test-machine",
6465
)
6566
assert.ErrorContains(t, err, "stat import source")
67+
68+
nonPrompt := filepath.Join(t.TempDir(), "non-prompt.html")
69+
require.NoError(t, os.WriteFile(
70+
nonPrompt,
71+
[]byte(strings.Replace(geminiAppsCLIHTML, "<p>Prompted</p>", "<p>Canvas</p>", 1)),
72+
0o644,
73+
))
74+
stats, err = runImportDispatch(
75+
context.Background(), database, "gemini-apps", nonPrompt, t.TempDir(), "test-machine",
76+
)
77+
assert.ErrorContains(t, err, "no admissible Prompted records")
78+
assert.Equal(t, 1, stats.Skipped)
79+
assert.Equal(t, "\rDone: 1 processed (1 skipped)\n", formatImportFailureSummary(stats))
80+
assert.Empty(t, formatImportFailureSummary(importer.ImportStats{}))
6681
}
6782

6883
// isolateParseDiffEnv points the data dir, HOME, and every per-agent

internal/parser/gemini_apps_takeout.go

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -325,29 +325,35 @@ func isGeminiAppsHeading(tag string) bool {
325325

326326
func geminiAppsRecordKind(tokens []html.Token) string {
327327
zones := geminiAppsZones(tokens)
328-
var label string
329328
for _, zone := range zones {
330-
if zone.name == "header" {
331-
label = strings.ToLower(renderGeminiAppsTokens(zone.tokens))
332-
break
329+
if zone.name != "header" {
330+
continue
333331
}
334-
}
335-
if label == "" {
336-
label = strings.ToLower(renderGeminiAppsTokens(tokens))
337-
}
338-
label = strings.Join(strings.Fields(label), " ")
339-
for _, candidate := range []struct{ label, kind string }{
340-
{"prompted", "prompted"},
341-
{"canvas", "canvas"},
342-
{"feedback", "feedback"},
343-
} {
344-
if containsWord(label, candidate.label) {
345-
return candidate.kind
332+
for _, field := range geminiAppsTextFields(renderGeminiAppsTokens(zone.tokens)) {
333+
switch field {
334+
case "prompted":
335+
return "prompted"
336+
case "canvas":
337+
return "canvas"
338+
case "feedback":
339+
return "feedback"
340+
}
346341
}
347342
}
348343
return "unknown"
349344
}
350345

346+
func geminiAppsTextFields(value string) []string {
347+
var fields []string
348+
for _, line := range strings.Split(value, "\n") {
349+
field := strings.ToLower(strings.Join(strings.Fields(line), " "))
350+
if field != "" {
351+
fields = append(fields, field)
352+
}
353+
}
354+
return fields
355+
}
356+
351357
func containsWord(text, word string) bool {
352358
for _, field := range strings.FieldsFunc(text, func(r rune) bool {
353359
return r < 'A' || (r > 'Z' && r < 'a') || r > 'z'
@@ -425,23 +431,26 @@ func hasHTMLClass(token html.Token, wanted string) bool {
425431
}
426432

427433
func parseGeminiAppsCell(tokens []html.Token) (ParseResult, error) {
428-
text := renderGeminiAppsTokens(tokens)
429-
match := geminiAppsTimestampRE.FindStringSubmatch(text)
434+
zones := geminiAppsZones(tokens)
435+
var headerText string
436+
var contentZones [][]html.Token
437+
for _, zone := range zones {
438+
switch zone.name {
439+
case "header":
440+
headerText = renderGeminiAppsTokens(zone.tokens)
441+
case "content":
442+
contentZones = append(contentZones, zone.tokens)
443+
}
444+
}
445+
match := geminiAppsTimestampRE.FindStringSubmatch(headerText)
430446
if len(match) != 2 {
431-
return ParseResult{}, fmt.Errorf("activity record has no supported timestamp")
447+
return ParseResult{}, fmt.Errorf("activity record has no supported header timestamp")
432448
}
433449
ts, err := parseGeminiAppsTimestamp(match[0], match[1])
434450
if err != nil {
435451
return ParseResult{}, err
436452
}
437453

438-
zones := geminiAppsZones(tokens)
439-
var contentZones [][]html.Token
440-
for _, zone := range zones {
441-
if zone.name == "content" {
442-
contentZones = append(contentZones, zone.tokens)
443-
}
444-
}
445454
if len(contentZones) == 0 {
446455
return ParseResult{}, fmt.Errorf(
447456
"Prompted activity record has no content cell",
@@ -505,27 +514,13 @@ func geminiAppsPromptAndResponse(zones [][]html.Token) (string, string) {
505514
}
506515

507516
value := rendered[0]
508-
for _, marker := range []string{"Response:", "Answer:"} {
509-
if before, after, ok := cutFold(value, marker); ok {
510-
return strings.TrimSpace(strings.TrimSuffix(before, "Prompt:")), strings.TrimSpace(after)
511-
}
512-
}
513517
blocks := splitGeminiAppsBlocks(zones[0])
514518
if len(blocks) > 1 {
515519
return blocks[0], strings.TrimSpace(strings.Join(blocks[1:], "\n\n"))
516520
}
517521
return value, ""
518522
}
519523

520-
func cutFold(value, marker string) (string, string, bool) {
521-
lower := strings.ToLower(value)
522-
index := strings.Index(lower, strings.ToLower(marker))
523-
if index < 0 {
524-
return "", "", false
525-
}
526-
return value[:index], value[index+len(marker):], true
527-
}
528-
529524
func splitGeminiAppsBlocks(tokens []html.Token) []string {
530525
var blocks []string
531526
var block []html.Token

internal/parser/gemini_apps_takeout_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,82 @@ func TestParseGeminiAppsExportRealOuterCellHeaderShape(t *testing.T) {
7272
assert.Equal(t, firstID, repeated[0].Session.ID)
7373
}
7474

75+
func TestParseGeminiAppsRejectsNotPromptedActivityLabel(t *testing.T) {
76+
fixture := strings.ReplaceAll(
77+
sanitizedGeminiAppsHTML,
78+
"<p>Prompted<br></p>",
79+
"<p>Not Prompted</p>",
80+
)
81+
path := filepath.Join(t.TempDir(), "not-prompted.html")
82+
require.NoError(t, os.WriteFile(path, []byte(fixture), 0o644))
83+
84+
provider, ok := NewProvider(AgentGeminiApps, ProviderConfig{})
85+
require.True(t, ok)
86+
exporter := provider.(GeminiAppsExportParser)
87+
var results []ParseResult
88+
summary, err := exporter.ParseGeminiAppsExport(path, func(result ParseResult) error {
89+
results = append(results, result)
90+
return nil
91+
})
92+
assert.Empty(t, results)
93+
assert.Equal(t, 5, summary.Skipped)
94+
assert.ErrorContains(t, err, "no admissible Prompted records")
95+
}
96+
97+
func TestParseGeminiAppsPreservesOrdinaryResponseAndAnswerText(t *testing.T) {
98+
fixture := strings.Replace(
99+
sanitizedGeminiAppsHTML,
100+
`<div class="content-cell mdl-cell"><p>first prompt<br></p><p><strong>first</strong> answer &amp; detail</p><script>secret script</script><style>secret style</style><template>secret template</template><noscript>secret noscript</noscript></div>`,
101+
`<div class="content-cell mdl-cell"><p>ordinary Response: and Answer: text</p></div>`,
102+
1,
103+
)
104+
path := filepath.Join(t.TempDir(), "ordinary-markers.html")
105+
require.NoError(t, os.WriteFile(path, []byte(fixture), 0o644))
106+
107+
provider, ok := NewProvider(AgentGeminiApps, ProviderConfig{})
108+
require.True(t, ok)
109+
exporter := provider.(GeminiAppsExportParser)
110+
var results []ParseResult
111+
_, err := exporter.ParseGeminiAppsExport(path, func(result ParseResult) error {
112+
results = append(results, result)
113+
return nil
114+
})
115+
require.NoError(t, err)
116+
require.Len(t, results, 2)
117+
require.Len(t, results[0].Messages, 1)
118+
assert.Equal(t, "ordinary Response: and Answer: text", results[0].Messages[0].Content)
119+
}
120+
121+
func TestParseGeminiAppsTimestampMustBeInHeader(t *testing.T) {
122+
fixture := strings.Replace(
123+
sanitizedGeminiAppsHTML,
124+
"<p>Jan 2, 2025, 3:04:05 PM EDT</p>",
125+
"<p>header timestamp missing</p>",
126+
1,
127+
)
128+
fixture = strings.Replace(
129+
fixture,
130+
"<p>first prompt<br></p>",
131+
"<p>Jan 2, 2025, 3:04:05 PM EDT</p><p>first prompt</p>",
132+
1,
133+
)
134+
path := filepath.Join(t.TempDir(), "content-date.html")
135+
require.NoError(t, os.WriteFile(path, []byte(fixture), 0o644))
136+
137+
provider, ok := NewProvider(AgentGeminiApps, ProviderConfig{})
138+
require.True(t, ok)
139+
exporter := provider.(GeminiAppsExportParser)
140+
var results []ParseResult
141+
summary, err := exporter.ParseGeminiAppsExport(path, func(result ParseResult) error {
142+
results = append(results, result)
143+
return nil
144+
})
145+
require.NoError(t, err)
146+
assert.Len(t, results, 1)
147+
assert.Equal(t, 1, summary.Errors)
148+
assert.Equal(t, "second prompt", results[0].Messages[0].Content)
149+
}
150+
75151
func TestParseGeminiAppsExportAdmitsDirectoryAndRejectsOtherHTML(t *testing.T) {
76152
root := t.TempDir()
77153
require.NoError(t, os.WriteFile(

0 commit comments

Comments
 (0)