package notes import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" ) type Note struct { ID string `json:"id"` Title string `json:"title"` Body string `json:"body"` BodyFormats []BodyFormat `json:"bodyFormats"` Owner string `json:"owner"` Collaborators []string `json:"collaborators"` Color string `json:"color"` UpdatedAt int64 `json:"updatedAt"` SortOrder int64 `json:"sortOrder"` Pinned bool `json:"pinned"` Archived bool `json:"archived"` Deleted bool `json:"deleted"` Checklist bool `json:"checklist"` Items []Item `json:"checklistItems"` Labels []string `json:"labels"` ReminderAt *int64 `json:"reminderAt"` Images []Image `json:"images"` Audios []Audio `json:"audios"` } type Item struct { ID string `json:"id"` Text string `json:"text"` Checked bool `json:"checked"` } type BodyFormat struct { Start int `json:"start"` End int `json:"end"` Style string `json:"style"` } type Image struct { ID string `json:"id"` MimeType string `json:"mimeType"` Base64 string `json:"base64"` } type Audio struct { ID string `json:"id"` MimeType string `json:"mimeType"` Base64 string `json:"base64"` DurationMillis int64 `json:"durationMillis"` } type Store interface { List() []Note Upsert(note Note) (Note, error) Delete(id string) error } type FileStore struct { mu sync.Mutex path string notes map[string]Note } func OpenFileStore(path string) (*FileStore, error) { store := &FileStore{ path: path, notes: map[string]Note{}, } if err := store.load(); err != nil { return nil, err } return store, nil } func (s *FileStore) List() []Note { s.mu.Lock() defer s.mu.Unlock() return sortedNotes(s.notes) } func (s *FileStore) Upsert(note Note) (Note, error) { s.mu.Lock() defer s.mu.Unlock() note.ID = strings.TrimSpace(note.ID) if note.ID == "" { return Note{}, errors.New("note id is required") } if note.Color == "" { note.Color = "#FFF475" } if note.UpdatedAt <= 0 { note.UpdatedAt = time.Now().UnixMilli() } if note.SortOrder <= 0 { note.SortOrder = note.UpdatedAt } note.Owner = strings.TrimSpace(note.Owner) note.Collaborators = normalizedStrings(note.Collaborators, 50) s.notes[note.ID] = note if err := s.persistLocked(); err != nil { return Note{}, err } return note, nil } func (s *FileStore) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.notes, strings.TrimSpace(id)) return s.persistLocked() } func (s *FileStore) load() error { dir := filepath.Dir(s.path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create data directory: %w", err) } data, err := os.ReadFile(s.path) if errors.Is(err, os.ErrNotExist) { return s.persistLocked() } if err != nil { return fmt.Errorf("read data file: %w", err) } if len(strings.TrimSpace(string(data))) == 0 { return nil } var loaded []Note if err := json.Unmarshal(data, &loaded); err != nil { return fmt.Errorf("decode data file: %w", err) } for _, note := range loaded { if strings.TrimSpace(note.ID) == "" { continue } s.notes[note.ID] = note } return nil } func (s *FileStore) persistLocked() error { notes := sortedNotes(s.notes) dir := filepath.Dir(s.path) tmp, err := os.CreateTemp(dir, ".keeper-notes-*.tmp") if err != nil { return fmt.Errorf("create temp data file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) encoder := json.NewEncoder(tmp) encoder.SetIndent("", " ") if err := encoder.Encode(notes); err != nil { _ = tmp.Close() return fmt.Errorf("encode data file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp data file: %w", err) } if err := os.Rename(tmpName, s.path); err != nil { _ = os.Remove(s.path) if retryErr := os.Rename(tmpName, s.path); retryErr != nil { return fmt.Errorf("replace data file: %w", retryErr) } } return nil } func normalizedStrings(values []string, limit int) []string { seen := map[string]struct{}{} normalized := []string{} for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } key := strings.ToLower(trimmed) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} normalized = append(normalized, trimmed) if len(normalized) >= limit { break } } return normalized } func sortedNotes(noteMap map[string]Note) []Note { notes := make([]Note, 0, len(noteMap)) for _, note := range noteMap { notes = append(notes, note) } sort.Slice(notes, func(i, j int) bool { leftOrder := notes[i].SortOrder if leftOrder <= 0 { leftOrder = notes[i].UpdatedAt } rightOrder := notes[j].SortOrder if rightOrder <= 0 { rightOrder = notes[j].UpdatedAt } if leftOrder == rightOrder { if notes[i].UpdatedAt != notes[j].UpdatedAt { return notes[i].UpdatedAt > notes[j].UpdatedAt } return notes[i].ID < notes[j].ID } return leftOrder > rightOrder }) return notes }