// This file implements session export/import (US-008, #124): a session can be // exported to a self-contained JSONL or HTML file, and a JSONL export can be // imported back as a fresh, resumable session. The JSONL form is the same // role-discriminated schema the store persists, so an export → import round-trip // is lossless (message contents and the id/parentId tree survive verbatim); the // HTML form is a read-only, self-contained transcript with inline styles and no // external network resources, suitable for sharing. package session import ( "fmt" "io" "os" "path/filepath" "strings" "time" ) // WriteJSONL writes header + entries as JSONL in the store's on-disk schema // (header line first, then one entry line each, ids/parentIds preserved). It is // the export counterpart to writeSessionEntries and the exact input ReadJSONL // expects, so a WriteJSONL → ReadJSONL round-trip is lossless. func WriteJSONL(w io.Writer, header SessionHeader, entries []Entry) error { header.Version = SchemaVersion return writeSessionEntries(w, header, entries) } // ReadJSONL decodes a JSONL export (as produced by WriteJSONL or a raw session // file) into a header and entries, migrating v1/v2 bare-message files the same // way LoadEntries does. It is the import counterpart to WriteJSONL. func ReadJSONL(r io.Reader) (SessionHeader, []Entry, error) { return readSession(r) } // Export writes the session identified by id to outPath. The format is chosen // by outPath's extension: ".html"/".htm" produces a self-contained HTML // transcript; anything else (including ".jsonl") produces JSONL. The parent // directory of outPath must already exist. It returns the number of entries // written so a caller can report progress. func (s *Store) Export(id, outPath string) (int, error) { header, entries, err := s.LoadEntries(id) if err != nil { return 0, err } f, err := os.Create(outPath) if err != nil { return 0, fmt.Errorf("session: create export %s: %w", outPath, err) } defer f.Close() ext := strings.ToLower(filepath.Ext(outPath)) if ext == ".html" || ext == ".htm" { if err := WriteHTML(f, header, entries); err != nil { return 0, err } } else { if err := WriteJSONL(f, header, entries); err != nil { return 0, err } } if err := f.Close(); err != nil { return 0, fmt.Errorf("session: finalize export %s: %w", outPath, err) } return len(entries), nil } // Import reads a JSONL export at inPath and materializes it as a fresh session // in the store: a new id (derived from now) is assigned, the original id is // recorded as ParentSession for lineage, and the entries are written verbatim // (ids/parentIds preserved) so the tree — and thus PathToLeaf/resume — behaves // exactly as in the source. It returns the new header and the imported entries. // An HTML file (or any non-JSONL input) fails to parse and returns an error // rather than importing garbage. func (s *Store) Import(inPath string, now time.Time) (SessionHeader, []Entry, error) { f, err := os.Open(inPath) if err != nil { return SessionHeader{}, nil, fmt.Errorf("session: open import %s: %w", inPath, err) } defer f.Close() srcHeader, entries, err := ReadJSONL(f) if err != nil { return SessionHeader{}, nil, err } newHeader := SessionHeader{ ID: NewID(now), CreatedAt: now, UpdatedAt: now, Model: srcHeader.Model, Provider: srcHeader.Provider, SystemPrompt: srcHeader.SystemPrompt, ParentSession: srcHeader.ID, } if err := s.SaveEntries(newHeader, entries); err != nil { return SessionHeader{}, nil, err } return newHeader, entries, nil } // WriteHTML writes a self-contained HTML transcript of the session: inline CSS // only (no external stylesheets, fonts, scripts, or network resources), role // color-coding, and tool-call/result blocks. All message text is HTML-escaped // so a transcript containing markup or a crafted "" cannot break out of // its container or inject active content (defensive against a hostile session). func WriteHTML(w io.Writer, header SessionHeader, entries []Entry) error { var b strings.Builder b.WriteString(htmlHead(header)) for _, e := range entries { b.WriteString(renderEntryHTML(e)) } b.WriteString(htmlFoot()) _, err := io.WriteString(w, b.String()) return err }