Files
Mako/internal/store/store.go
T
VeillaxandClaude Sonnet 5.5 b548faa258 Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search,
pin and trash with undo, autosave with conflict warnings, API tokens,
zip export, and the Lightwell dashboard. Includes install.sh, CI and
release workflows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:16:33 -06:00

544 lines
13 KiB
Go

// 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()
}