// Package store keeps notes as plain markdown files, one per note, so they stay readable and // portable with or without Basking. An in-memory index makes listing and searching quick. package store import ( "archive/zip" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" ) // MaxBody is the longest note, in bytes. const MaxBody = 256 << 10 var ( ErrNotFound = errors.New("note not found") ErrBadID = errors.New("bad note id") ErrEmpty = errors.New("a note needs some text") ErrTooLong = errors.New("that note is too long") ErrConflict = errors.New("this note changed somewhere else") ErrBadDate = errors.New("the date must look like 2026-10-31") ErrNotTrash = errors.New("only a note in the trash can be deleted for good") ) type Note struct { ID string `json:"id"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` Pinned bool `json:"pinned"` Daily string `json:"daily,omitempty"` // the date, for a daily note Trashed bool `json:"trashed,omitempty"` Body string `json:"body"` Tags []string `json:"tags"` lower string // lower-cased body, for searching } type Store struct { dir string mu sync.RWMutex by map[string]*Note } var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) // ValidID says whether s can be used as a note id (and so as a file name). func ValidID(s string) bool { return idRe.MatchString(s) } // Open reads every note under dir, creating the folders if needed. func Open(dir string) (*Store, error) { s := &Store{dir: dir, by: map[string]*Note{}} for _, sub := range []string{"notes", "trash"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { return nil, err } entries, err := os.ReadDir(filepath.Join(dir, sub)) if err != nil { return nil, err } for _, e := range entries { id := strings.TrimSuffix(e.Name(), ".md") if e.IsDir() || id == e.Name() || !ValidID(id) { continue } path := filepath.Join(dir, sub, e.Name()) b, err := os.ReadFile(path) if err != nil { return nil, err } info, err := e.Info() if err != nil { return nil, err } n := parse(id, string(b), info.ModTime()) n.Trashed = sub == "trash" s.by[id] = n } } return s, nil } func (s *Store) path(n *Note) string { sub := "notes" if n.Trashed { sub = "trash" } return filepath.Join(s.dir, sub, n.ID+".md") } // ---- file format ---- // // --- // created: 2026-10-31T14:32:05.123Z // updated: 2026-10-31T14:35:00.456Z // pinned: true // daily: 2026-10-31 // --- // // the note, in markdown func format(n *Note) []byte { var b strings.Builder b.WriteString("---\n") fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano)) fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano)) if n.Pinned { b.WriteString("pinned: true\n") } if n.Daily != "" { fmt.Fprintf(&b, "daily: %s\n", n.Daily) } b.WriteString("---\n\n") b.WriteString(n.Body) b.WriteString("\n") return []byte(b.String()) } // parse reads a note file. A file with no header is still a note: it takes its time from the file. func parse(id, text string, mod time.Time) *Note { text = strings.ReplaceAll(text, "\r\n", "\n") n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()} if rest, ok := strings.CutPrefix(text, "---\n"); ok { if head, body, ok := strings.Cut(rest, "\n---\n"); ok || strings.HasSuffix(rest, "\n---") { if !ok { head, body = strings.TrimSuffix(rest, "\n---"), "" } for _, line := range strings.Split(head, "\n") { k, v, _ := strings.Cut(line, ":") v = strings.TrimSpace(v) switch strings.TrimSpace(k) { case "created": if t, err := time.Parse(time.RFC3339Nano, v); err == nil { n.Created = t.UTC() } case "updated": if t, err := time.Parse(time.RFC3339Nano, v); err == nil { n.Updated = t.UTC() } case "pinned": n.Pinned = v == "true" case "daily": if _, err := time.Parse("2006-01-02", v); err == nil { n.Daily = v } } } text = strings.TrimPrefix(body, "\n") } } n.set(text) return n } // set stores the body (without trailing blank space) and works out the tags. func (n *Note) set(body string) { n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n") n.lower = strings.ToLower(n.Body) n.Tags = Tags(n.Body) } var ( tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`) inlineCodeRe = regexp.MustCompile("`[^`\n]*`") ) // Tags returns the #tags in a note, lower-cased and without repeats. Code is skipped, and so are // numbers such as #12. func Tags(body string) []string { var lines []string fence := false for _, l := range strings.Split(body, "\n") { if strings.HasPrefix(strings.TrimSpace(l), "```") { fence = !fence continue } if !fence { lines = append(lines, inlineCodeRe.ReplaceAllString(l, "")) } } seen := map[string]bool{} out := []string{} for _, m := range tagRe.FindAllStringSubmatch(strings.Join(lines, "\n"), -1) { t := strings.ToLower(strings.TrimRight(m[1], "/-")) if t == "" || len(t) > 40 || isNumber(t) || seen[t] { continue } seen[t] = true out = append(out, t) } return out } func isNumber(s string) bool { for _, r := range s { if r < '0' || r > '9' { return false } } return true } func (s *Store) write(n *Note) error { path := s.path(n) tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp") if err := os.WriteFile(tmp, format(n), 0o644); err != nil { return err } return os.Rename(tmp, path) } func newID(t time.Time) string { b := make([]byte, 3) rand.Read(b) return t.UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b) } func (n *Note) copy() Note { c := *n c.Tags = append([]string{}, n.Tags...) return c } func checkBody(body string) error { if len(body) > MaxBody { return ErrTooLong } return nil } // ---- changes ---- // Create saves a new note. func (s *Store) Create(body string) (Note, error) { if err := checkBody(body); err != nil { return Note{}, err } if strings.TrimSpace(body) == "" { return Note{}, ErrEmpty } now := time.Now().UTC() n := &Note{ID: newID(now), Created: now, Updated: now} n.set(body) s.mu.Lock() defer s.mu.Unlock() if err := s.write(n); err != nil { return Note{}, err } s.by[n.ID] = n return n.copy(), nil } // Daily returns the note for a date (like 2026-10-31), making it if it is not there yet. func (s *Store) Daily(date string) (Note, error) { if _, err := time.Parse("2006-01-02", date); err != nil { return Note{}, ErrBadDate } id := "daily-" + date s.mu.Lock() defer s.mu.Unlock() if n, ok := s.by[id]; ok { if n.Trashed { // asking for the day brings it back old := s.path(n) n.Trashed = false if err := os.Rename(old, s.path(n)); err != nil { n.Trashed = true return Note{}, err } } return n.copy(), nil } now := time.Now().UTC() n := &Note{ID: id, Created: now, Updated: now, Daily: date} if err := s.write(n); err != nil { return Note{}, err } s.by[id] = n return n.copy(), nil } func (s *Store) Get(id string) (Note, error) { s.mu.RLock() defer s.mu.RUnlock() n, ok := s.by[id] if !ok { return Note{}, ErrNotFound } return n.copy(), nil } // Update changes the text and/or the pin. When base is given, the text is only replaced if the // note has not been changed since then; otherwise the current note comes back with ErrConflict. func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) { s.mu.Lock() defer s.mu.Unlock() n, ok := s.by[id] if !ok { return Note{}, ErrNotFound } if body != nil { if err := checkBody(*body); err != nil { return Note{}, err } if !base.IsZero() && !n.Updated.Equal(base) { return n.copy(), ErrConflict } } next := n.copy() if body != nil { next.set(*body) next.Updated = time.Now().UTC() if !next.Updated.After(n.Updated) { // keep updated moving forward, so a stale tab is always noticed next.Updated = n.Updated.Add(time.Microsecond) } } if pinned != nil { next.Pinned = *pinned } if err := s.write(&next); err != nil { return Note{}, err } *n = next return n.copy(), nil } func (s *Store) move(id string, toTrash bool) (Note, error) { s.mu.Lock() defer s.mu.Unlock() n, ok := s.by[id] if !ok { return Note{}, ErrNotFound } if n.Trashed == toTrash { return n.copy(), nil } old := s.path(n) n.Trashed = toTrash if err := os.Rename(old, s.path(n)); err != nil { n.Trashed = !toTrash return Note{}, err } return n.copy(), nil } // Trash moves a note to the trash. Restore brings it back. func (s *Store) Trash(id string) (Note, error) { return s.move(id, true) } func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) } // Purge deletes a note that is already in the trash, for good. func (s *Store) Purge(id string) error { s.mu.Lock() defer s.mu.Unlock() n, ok := s.by[id] if !ok { return ErrNotFound } if !n.Trashed { return ErrNotTrash } if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } delete(s.by, id) return nil } // EmptyTrash deletes everything in the trash for good and says how many notes that was. func (s *Store) EmptyTrash() (int, error) { s.mu.Lock() defer s.mu.Unlock() count := 0 for id, n := range s.by { if !n.Trashed { continue } if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) { return count, err } delete(s.by, id) count++ } return count, nil } // ---- reading ---- type Query struct { Text string // words that must all appear; #tag words must be tags Tag string View string // "" or "all", "pinned", "daily", "trash" Offset int Limit int } // List returns one page of matching notes, newest first, and how many matched in all. func (s *Store) List(q Query) ([]Note, int) { var words, tags []string for _, w := range strings.Fields(strings.ToLower(q.Text)) { if t := strings.TrimPrefix(w, "#"); t != w && t != "" { tags = append(tags, t) } else { words = append(words, w) } } if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" { tags = append(tags, t) } s.mu.RLock() var hits []*Note for _, n := range s.by { if n.Trashed != (q.View == "trash") || (q.View == "pinned" && !n.Pinned) || (q.View == "daily" && n.Daily == "") { continue } if matches(n, words, tags) { hits = append(hits, n) } } sort.Slice(hits, func(i, j int) bool { a, b := hits[i], hits[j] if q.View == "daily" && a.Daily != b.Daily { return a.Daily > b.Daily } if !a.Created.Equal(b.Created) { return a.Created.After(b.Created) } return a.ID > b.ID }) total := len(hits) if q.Offset > 0 { hits = hits[min(q.Offset, len(hits)):] } if q.Limit > 0 && len(hits) > q.Limit { hits = hits[:q.Limit] } out := make([]Note, len(hits)) for i, n := range hits { out[i] = n.copy() } s.mu.RUnlock() return out, total } func matches(n *Note, words, tags []string) bool { for _, w := range words { if !strings.Contains(n.lower, w) { return false } } for _, want := range tags { found := false for _, have := range n.Tags { if have == want || strings.HasPrefix(have, want+"/") { found = true break } } if !found { return false } } return true } type TagCount struct { Tag string `json:"tag"` Count int `json:"count"` } type Counts struct { All int `json:"all"` Pinned int `json:"pinned"` Daily int `json:"daily"` Trash int `json:"trash"` } // Summary counts the notes and the tags in use (trashed notes do not count towards tags). func (s *Store) Summary() (Counts, []TagCount) { s.mu.RLock() defer s.mu.RUnlock() var c Counts per := map[string]int{} for _, n := range s.by { if n.Trashed { c.Trash++ continue } c.All++ if n.Pinned { c.Pinned++ } if n.Daily != "" { c.Daily++ } for _, t := range n.Tags { per[t]++ } } tags := make([]TagCount, 0, len(per)) for t, k := range per { tags = append(tags, TagCount{t, k}) } sort.Slice(tags, func(i, j int) bool { if tags[i].Count != tags[j].Count { return tags[i].Count > tags[j].Count } return tags[i].Tag < tags[j].Tag }) return c, tags } // WriteZip writes every note that is not in the trash as a .md file in a zip archive. func (s *Store) WriteZip(w io.Writer) error { s.mu.RLock() defer s.mu.RUnlock() ids := make([]string, 0, len(s.by)) for id, n := range s.by { if !n.Trashed { ids = append(ids, id) } } sort.Strings(ids) zw := zip.NewWriter(w) for _, id := range ids { n := s.by[id] f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated}) if err != nil { return err } if _, err := f.Write(format(n)); err != nil { return err } } return zw.Close() }