// Package importer reads Simplenote export archives into the store. package importer import ( "archive/zip" "crypto/sha256" "encoding/json" "fmt" "io" "strings" "time" "tefter/server/store" ) // Verified against a real export (2026): the zip contains source/notes.json with // {activeNotes: [...], trashedNotes: [...]}; each note has id, content, // creationDate, lastModified (ISO 8601) and optionally tags. There is no // `markdown` field in current exports. type snNote struct { ID string `json:"id"` Content string `json:"content"` CreationDate string `json:"creationDate"` LastModified string `json:"lastModified"` Tags []string `json:"tags"` } type snExport struct { ActiveNotes []snNote `json:"activeNotes"` TrashedNotes []snNote `json:"trashedNotes"` } type Summary struct { Imported int `json:"imported"` Skipped int `json:"skipped"` Trashed int `json:"trashed"` } func (s Summary) String() string { return fmt.Sprintf("imported %d, skipped %d duplicates, %d trashed", s.Imported, s.Skipped, s.Trashed) } func parseTime(iso string, fallback time.Time) int64 { if t, err := time.Parse(time.RFC3339, iso); err == nil { return t.UnixMilli() } return fallback.UnixMilli() } // ImportZip imports a Simplenote export zip (as a reader) into st. Idempotent: // notes are deduped by Simplenote id kept in the source_id column. func ImportZip(st *store.Store, r io.ReaderAt, size int64) (Summary, error) { var sum Summary zr, err := zip.NewReader(r, size) if err != nil { return sum, fmt.Errorf("open zip: %w", err) } var data []byte for _, f := range zr.File { if f.Name == "source/notes.json" || strings.HasSuffix(f.Name, "/notes.json") || f.Name == "notes.json" { rc, err := f.Open() if err != nil { return sum, err } data, err = io.ReadAll(rc) rc.Close() if err != nil { return sum, err } break } } if data == nil { return sum, fmt.Errorf("notes.json not found in archive (expected source/notes.json)") } var exp snExport if err := json.Unmarshal(data, &exp); err != nil { return sum, fmt.Errorf("parse notes.json: %w", err) } now := time.Now() doImport := func(notes []snNote, deleted bool) error { for _, sn := range notes { if sn.ID == "" { // No id to dedupe on — synthesize one from content+creation date. sn.ID = fmt.Sprintf("synth-%x", contentHash(sn)) } n := store.Note{ Content: strings.ReplaceAll(sn.Content, "\r\n", "\n"), Tags: sn.Tags, CreatedAt: parseTime(sn.CreationDate, now), ModifiedAt: parseTime(sn.LastModified, now), Deleted: deleted, } inserted, err := st.UpsertImported("simplenote:"+sn.ID, n) if err != nil { return err } if inserted { if deleted { sum.Trashed++ } else { sum.Imported++ } } else { sum.Skipped++ } } return nil } if err := doImport(exp.ActiveNotes, false); err != nil { return sum, err } if err := doImport(exp.TrashedNotes, true); err != nil { return sum, err } return sum, nil } func contentHash(sn snNote) []byte { h := sha256.Sum256([]byte(sn.CreationDate + "\x00" + sn.Content)) return h[:16] }