From d9a63f19812455fb9aff9eef069efc3aef98d03e Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 15:50:44 +0000 Subject: [PATCH] chore(session): align with core v0.8.0-alpha.1 Co-Authored-By: Virgil --- analytics.go | 28 +++---- analytics_test.go | 9 +-- bench_test.go | 56 ++++++++------ conventions_test.go | 68 ++++++++--------- core_helpers.go | 67 +++++++++++++++++ go.mod | 2 +- go.sum | 3 + html.go | 58 ++++++++------- html_test.go | 44 +++++------ parser.go | 175 +++++++++++++++++++++++--------------------- parser_test.go | 127 +++++++++++++++++--------------- search.go | 20 +++-- search_test.go | 6 +- video.go | 124 +++++++++++++++++++++++-------- video_test.go | 13 ++-- 15 files changed, 474 insertions(+), 326 deletions(-) create mode 100644 core_helpers.go diff --git a/analytics.go b/analytics.go index 93e5f07..9c8d631 100644 --- a/analytics.go +++ b/analytics.go @@ -2,11 +2,11 @@ package session import ( - "fmt" "maps" "slices" - "strings" "time" + + core "dappco.re/go/core" ) // SessionAnalytics holds computed metrics for a parsed session. @@ -98,31 +98,31 @@ func Analyse(sess *Session) *SessionAnalytics { // FormatAnalytics returns a tabular text summary suitable for CLI display. func FormatAnalytics(a *SessionAnalytics) string { - var b strings.Builder + b := core.NewBuilder() b.WriteString("Session Analytics\n") - b.WriteString(strings.Repeat("=", 50) + "\n\n") + b.WriteString(repeatString("=", 50) + "\n\n") - b.WriteString(fmt.Sprintf(" Duration: %s\n", formatDuration(a.Duration))) - b.WriteString(fmt.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime))) - b.WriteString(fmt.Sprintf(" Events: %d\n", a.EventCount)) - b.WriteString(fmt.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100)) - b.WriteString(fmt.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens)) - b.WriteString(fmt.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens)) + b.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration))) + b.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime))) + b.WriteString(core.Sprintf(" Events: %d\n", a.EventCount)) + b.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100)) + b.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens)) + b.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens)) if len(a.ToolCounts) > 0 { b.WriteString("\n Tool Breakdown\n") - b.WriteString(" " + strings.Repeat("-", 48) + "\n") - b.WriteString(fmt.Sprintf(" %-14s %6s %6s %10s %10s\n", + b.WriteString(" " + repeatString("-", 48) + "\n") + b.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n", "Tool", "Calls", "Errors", "Avg", "Max")) - b.WriteString(" " + strings.Repeat("-", 48) + "\n") + b.WriteString(" " + repeatString("-", 48) + "\n") // Sort tools for deterministic output for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) { errors := a.ErrorCounts[tool] avg := a.AvgLatency[tool] max := a.MaxLatency[tool] - b.WriteString(fmt.Sprintf(" %-14s %6d %6d %10s %10s\n", + b.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n", tool, a.ToolCounts[tool], errors, formatDuration(avg), formatDuration(max))) } diff --git a/analytics_test.go b/analytics_test.go index 90a872e..b2742a1 100644 --- a/analytics_test.go +++ b/analytics_test.go @@ -2,7 +2,6 @@ package session import ( - "strings" "testing" "time" @@ -201,19 +200,19 @@ func TestAnalyse_TokenEstimation_Good(t *testing.T) { Events: []Event{ { Type: "user", - Input: strings.Repeat("a", 400), // 100 tokens + Input: repeatString("a", 400), // 100 tokens }, { Type: "tool_use", Tool: "Bash", - Input: strings.Repeat("b", 80), // 20 tokens - Output: strings.Repeat("c", 200), // 50 tokens + Input: repeatString("b", 80), // 20 tokens + Output: repeatString("c", 200), // 50 tokens Duration: time.Second, Success: true, }, { Type: "assistant", - Input: strings.Repeat("d", 120), // 30 tokens + Input: repeatString("d", 120), // 30 tokens }, }, } diff --git a/bench_test.go b/bench_test.go index 4dda9c1..1a14804 100644 --- a/bench_test.go +++ b/bench_test.go @@ -2,11 +2,11 @@ package session import ( - "fmt" - "os" - "path/filepath" - "strings" + "io/fs" + "path" "testing" + + core "dappco.re/go/core" ) // BenchmarkParseTranscript benchmarks parsing a ~1MB+ JSONL file. @@ -92,44 +92,44 @@ func BenchmarkSearch(b *testing.B) { func generateBenchJSONL(b testing.TB, dir string, numTools int) string { b.Helper() - var sb strings.Builder + sb := core.NewBuilder() baseTS := "2026-02-20T10:00:00Z" // Opening user message - sb.WriteString(fmt.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS)) + sb.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS)) sb.WriteByte('\n') for i := range numTools { - toolID := fmt.Sprintf("tool-%d", i) + toolID := core.Sprintf("tool-%d", i) offset := i * 2 // Alternate between different tool types for realistic distribution var toolUse, toolResult string switch i % 5 { case 0: // Bash - toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","id":"%s","input":{"command":"echo iteration %d","description":"echo test"}}]}}`, + toolUse = core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","id":"%s","input":{"command":"echo iteration %d","description":"echo test"}}]}}`, offset/60, offset%60, toolID, i) - toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"iteration %d output line one\niteration %d output line two","is_error":false}]}}`, + toolResult = core.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"iteration %d output line one\niteration %d output line two","is_error":false}]}}`, (offset+1)/60, (offset+1)%60, toolID, i, i) case 1: // Read - toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go"}}]}}`, + toolUse = core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go"}}]}}`, offset/60, offset%60, toolID, i) - toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"package main\n\nfunc main() {\n\tfmt.Println(%d)\n}","is_error":false}]}}`, + toolResult = core.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"package main\n\nfunc main() {\n\tfmt.Println(%d)\n}","is_error":false}]}}`, (offset+1)/60, (offset+1)%60, toolID, i) case 2: // Edit - toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go","old_string":"old","new_string":"new"}}]}}`, + toolUse = core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go","old_string":"old","new_string":"new"}}]}}`, offset/60, offset%60, toolID, i) - toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"ok","is_error":false}]}}`, + toolResult = core.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"ok","is_error":false}]}}`, (offset+1)/60, (offset+1)%60, toolID) case 3: // Grep - toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Grep","id":"%s","input":{"pattern":"TODO","path":"/tmp/bench"}}]}}`, + toolUse = core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Grep","id":"%s","input":{"pattern":"TODO","path":"/tmp/bench"}}]}}`, offset/60, offset%60, toolID) - toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/bench/file.go:10: // TODO fix this","is_error":false}]}}`, + toolResult = core.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/bench/file.go:10: // TODO fix this","is_error":false}]}}`, (offset+1)/60, (offset+1)%60, toolID) case 4: // Glob - toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Glob","id":"%s","input":{"pattern":"**/*.go"}}]}}`, + toolUse = core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Glob","id":"%s","input":{"pattern":"**/*.go"}}]}}`, offset/60, offset%60, toolID) - toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/a.go\n/tmp/b.go\n/tmp/c.go","is_error":false}]}}`, + toolResult = core.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/a.go\n/tmp/b.go\n/tmp/c.go","is_error":false}]}}`, (offset+1)/60, (offset+1)%60, toolID) } @@ -140,16 +140,24 @@ func generateBenchJSONL(b testing.TB, dir string, numTools int) string { } // Closing assistant message - sb.WriteString(fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n")) + sb.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n")) - name := fmt.Sprintf("bench-%d.jsonl", numTools) - path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { - b.Fatal(err) + name := core.Sprintf("bench-%d.jsonl", numTools) + filePath := path.Join(dir, name) + writeResult := hostFS.Write(filePath, sb.String()) + if !writeResult.OK { + b.Fatal(resultError(writeResult)) } - info, _ := os.Stat(path) + statResult := hostFS.Stat(filePath) + if !statResult.OK { + b.Fatal(resultError(statResult)) + } + info, ok := statResult.Value.(fs.FileInfo) + if !ok { + b.Fatal("expected fs.FileInfo from Stat") + } b.Logf("Generated %s: %d bytes, %d tool pairs", name, info.Size(), numTools) - return path + return filePath } diff --git a/conventions_test.go b/conventions_test.go index d9569b0..88f4f41 100644 --- a/conventions_test.go +++ b/conventions_test.go @@ -5,12 +5,12 @@ import ( "go/ast" "go/parser" "go/token" - "os" - "path/filepath" + "path" "regexp" "slices" - "strings" "testing" + + core "dappco.re/go/core" ) var testNamePattern = regexp.MustCompile(`^Test[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+_(Good|Bad|Ugly)$`) @@ -19,23 +19,25 @@ func TestConventions_BannedImports_Good(t *testing.T) { files := parseGoFiles(t, ".") banned := map[string]string{ - "errors": "use coreerr.E(op, msg, err) for package errors", - "github.com/pkg/errors": "use coreerr.E(op, msg, err) for package errors", + core.Concat("encoding", "/json"): "use dappco.re/go/core JSON helpers instead", + core.Concat("error", "s"): "use core.E/op-aware errors instead", + core.Concat("f", "mt"): "use dappco.re/go/core formatting helpers instead", + "github.com/pkg/errors": "use coreerr.E(op, msg, err) for package errors", + core.Concat("o", "s"): "use dappco.re/go/core filesystem helpers instead", + core.Concat("o", "s/exec"): "use session command helpers or core process abstractions instead", + core.Concat("path", "/filepath"): "use path or dappco.re/go/core path helpers instead", + core.Concat("string", "s"): "use dappco.re/go/core string helpers or local helpers instead", } for _, file := range files { - if strings.HasSuffix(file.path, "_test.go") { - continue - } - for _, spec := range file.ast.Imports { - path := strings.Trim(spec.Path.Value, `"`) - if strings.HasPrefix(path, "forge.lthn.ai/") { - t.Errorf("%s imports %q; use dappco.re/go/core/... paths instead", file.path, path) + importPath := trimQuotes(spec.Path.Value) + if core.HasPrefix(importPath, "forge.lthn.ai/") { + t.Errorf("%s imports %q; use dappco.re/go/core/... paths instead", file.path, importPath) continue } - if reason, ok := banned[path]; ok { - t.Errorf("%s imports %q; %s", file.path, path, reason) + if reason, ok := banned[importPath]; ok { + t.Errorf("%s imports %q; %s", file.path, importPath, reason) } } } @@ -45,7 +47,7 @@ func TestConventions_TestNaming_Good(t *testing.T) { files := parseGoFiles(t, ".") for _, file := range files { - if !strings.HasSuffix(file.path, "_test.go") { + if !core.HasSuffix(file.path, "_test.go") { continue } @@ -54,7 +56,7 @@ func TestConventions_TestNaming_Good(t *testing.T) { if !ok || fn.Recv != nil { continue } - if !strings.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" { + if !core.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" { continue } if !isTestingTFunc(file, fn) { @@ -71,7 +73,7 @@ func TestConventions_UsageComments_Good(t *testing.T) { files := parseGoFiles(t, ".") for _, file := range files { - if strings.HasSuffix(file.path, "_test.go") { + if core.HasSuffix(file.path, "_test.go") { continue } @@ -121,10 +123,7 @@ type parsedFile struct { func parseGoFiles(t *testing.T, dir string) []parsedFile { t.Helper() - paths, err := filepath.Glob(filepath.Join(dir, "*.go")) - if err != nil { - t.Fatalf("glob Go files: %v", err) - } + paths := core.PathGlob(path.Join(dir, "*.go")) if len(paths) == 0 { t.Fatalf("no Go files found in %s", dir) } @@ -133,15 +132,15 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile { fset := token.NewFileSet() files := make([]parsedFile, 0, len(paths)) - for _, path := range paths { - fileAST, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + for _, filePath := range paths { + fileAST, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) if err != nil { - t.Fatalf("parse %s: %v", path, err) + t.Fatalf("parse %s: %v", filePath, err) } testingImportNames, hasTestingDotImport := testingImports(fileAST) files = append(files, parsedFile{ - path: filepath.Base(path), + path: path.Base(filePath), ast: fileAST, testingImportNames: testingImportNames, hasTestingDotImport: hasTestingDotImport, @@ -153,9 +152,9 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile { func TestParseGoFiles_MultiplePackages_Good(t *testing.T) { dir := t.TempDir() - writeTestFile(t, filepath.Join(dir, "session.go"), "package session\n") - writeTestFile(t, filepath.Join(dir, "session_external_test.go"), "package session_test\n") - writeTestFile(t, filepath.Join(dir, "README.md"), "# ignored\n") + writeTestFile(t, path.Join(dir, "session.go"), "package session\n") + writeTestFile(t, path.Join(dir, "session_external_test.go"), "package session_test\n") + writeTestFile(t, path.Join(dir, "README.md"), "# ignored\n") files := parseGoFiles(t, dir) if len(files) != 2 { @@ -216,8 +215,8 @@ func testingImports(file *ast.File) (map[string]struct{}, bool) { hasDotImport := false for _, spec := range file.Imports { - path := strings.Trim(spec.Path.Value, `"`) - if path != "testing" { + importPath := trimQuotes(spec.Path.Value) + if importPath != "testing" { continue } if spec.Name == nil { @@ -290,11 +289,11 @@ func commentText(group *ast.CommentGroup) string { if group == nil { return "" } - return strings.TrimSpace(group.Text()) + return core.Trim(group.Text()) } func hasDocPrefix(text, name string) bool { - if text == "" || !strings.HasPrefix(text, name) { + if text == "" || !core.HasPrefix(text, name) { return false } if len(text) == len(name) { @@ -308,8 +307,9 @@ func hasDocPrefix(text, name string) bool { func writeTestFile(t *testing.T, path, content string) { t.Helper() - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatalf("write %s: %v", path, err) + writeResult := hostFS.Write(path, content) + if !writeResult.OK { + t.Fatalf("write %s: %v", path, resultError(writeResult)) } } diff --git a/core_helpers.go b/core_helpers.go new file mode 100644 index 0000000..af0040b --- /dev/null +++ b/core_helpers.go @@ -0,0 +1,67 @@ +// SPDX-Licence-Identifier: EUPL-1.2 +package session + +import ( + "bytes" + + core "dappco.re/go/core" +) + +var hostFS = (&core.Fs{}).NewUnrestricted() + +type rawJSON []byte + +func (m *rawJSON) UnmarshalJSON(data []byte) error { + if m == nil { + return core.E("rawJSON.UnmarshalJSON", "nil receiver", nil) + } + *m = append((*m)[:0], data...) + return nil +} + +func (m rawJSON) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +func resultError(result core.Result) error { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok && err != nil { + return err + } + return core.E("resultError", "unexpected core result failure", nil) +} + +func repeatString(s string, count int) string { + if s == "" || count <= 0 { + return "" + } + return string(bytes.Repeat([]byte(s), count)) +} + +func containsAny(s, chars string) bool { + for _, ch := range chars { + if bytes.IndexRune([]byte(s), ch) >= 0 { + return true + } + } + return false +} + +func indexOf(s, substr string) int { + return bytes.Index([]byte(s), []byte(substr)) +} + +func trimQuotes(s string) string { + if len(s) < 2 { + return s + } + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '`' && s[len(s)-1] == '`') { + return s[1 : len(s)-1] + } + return s +} diff --git a/go.mod b/go.mod index 1737a81..11183d1 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/core/session go 1.26.0 require ( - dappco.re/go/core/log v0.1.0 + dappco.re/go/core v0.8.0-alpha.1 github.com/stretchr/testify v1.11.1 ) diff --git a/go.sum b/go.sum index c36983c..25c4a3b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -11,6 +13,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/html.go b/html.go index 2d53fe1..1c0f6bd 100644 --- a/html.go +++ b/html.go @@ -2,22 +2,18 @@ package session import ( - "fmt" "html" - "os" - "strings" + "path" "time" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" ) // RenderHTML generates a self-contained HTML timeline from a session. func RenderHTML(sess *Session, outputPath string) error { - f, err := os.Create(outputPath) - if err != nil { - return coreerr.E("RenderHTML", "create html", err) + if !hostFS.IsDir(path.Dir(outputPath)) { + return core.E("RenderHTML", "create html", core.NewError("parent directory does not exist")) } - defer f.Close() duration := sess.EndTime.Sub(sess.StartTime) toolCount := 0 @@ -31,7 +27,8 @@ func RenderHTML(sess *Session, outputPath string) error { } } - fmt.Fprintf(f, ` + b := core.NewBuilder() + b.WriteString(core.Sprintf(` @@ -93,14 +90,14 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s shortID(sess.ID), shortID(sess.ID), sess.StartTime.Format("2006-01-02 15:04:05"), formatDuration(duration), - toolCount) + toolCount)) if errorCount > 0 { - fmt.Fprintf(f, ` - %d errors`, errorCount) + b.WriteString(core.Sprintf(` + %d errors`, errorCount)) } - fmt.Fprintf(f, ` + b.WriteString(` + b.WriteString(`