// Package store keeps pages as markdown files, one per page, encrypted at rest (see package vault) // and exportable as plain markdown. An in-memory index makes listing, searching and linking quick. package store import ( "archive/zip" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "slices" "sort" "strings" "sync" "time" "unicode" "unicode/utf8" "git.veillax.com/Veillax/mako/internal/vault" ) // MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters. const ( MaxBody = 256 << 10 MaxTitle = 120 ) var ( ErrNotFound = errors.New("page not found") ErrBadID = errors.New("bad page id") ErrEmpty = errors.New("a page needs a title or some text") ErrTooLong = errors.New("that page is too long") ErrConflict = errors.New("this page changed somewhere else") ErrBadDate = errors.New("the date must look like 2026-10-31") ErrNotTrash = errors.New("only a page in the trash can be deleted for good") ErrTitleTaken = errors.New("another page already has that title") ErrDailyTitle = errors.New("a journal day is named by its date") ErrBadParent = errors.New("a page can only go inside another page, and not inside itself") ) 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 journal day Trashed bool `json:"trashed,omitempty"` Title string `json:"title"` Name string `json:"name"` // what to call it: the title, else the first line Parent string `json:"parent,omitempty"` Body string `json:"body"` Tags []string `json:"tags"` lower string // lower-cased title and body, for searching links []string // lower-cased titles this page links to with [[...]] } type Store struct { dir string box *vault.Cipher mu sync.RWMutex by map[string]*Note } var ( idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) // ValidID says whether s can be used as a page id (and so as a file name). func ValidID(s string) bool { return idRe.MatchString(s) } // Open reads every page under dir, creating the folders if needed. Pages are encrypted with box. A // plain markdown file found there is still read, and is saved again encrypted. func Open(dir string, box *vault.Cipher) (*Store, error) { if box == nil { return nil, errors.New("a notebook needs an encryption key") } s := &Store{dir: dir, box: box, by: map[string]*Note{}} var plain []*Note for _, sub := range []string{"notes", "trash"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); 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 } sealed := vault.IsSealed(b) if sealed { if b, err = box.Open(b, id); err != nil { return nil, fmt.Errorf("%s: %w; put the original key back (key in the settings file, or MAKO_KEY)", path, err) } } n := parse(id, string(b), info.ModTime()) n.Trashed = sub == "trash" s.by[id] = n if !sealed { plain = append(plain, n) } } } s.mend() for _, n := range plain { if err := s.write(n); err != nil { return nil, err } } return s, nil } // mend drops parents that point nowhere, or in a circle, so a hand-edited file cannot break the tree. func (s *Store) mend() { for _, n := range s.by { if n.Parent == "" { continue } p, ok := s.by[n.Parent] if !ok || (p.Trashed && !n.Trashed) || n.Daily != "" { n.Parent = "" continue } seen := map[string]bool{n.ID: true} for cur := p; cur != nil; cur = s.by[cur.Parent] { if seen[cur.ID] { n.Parent = "" break } seen[cur.ID] = true } } } 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 ---- // // --- // title: "Trip to Lisbon" // parent: 20261031-143205-a1b2c3 // created: 2026-10-31T14:32:05.123Z // updated: 2026-10-31T14:35:00.456Z // pinned: true // daily: 2026-10-31 // --- // // the page, in markdown func format(n *Note) []byte { var b strings.Builder b.WriteString("---\n") if n.Title != "" && n.Title != n.Daily { t, _ := json.Marshal(n.Title) fmt.Fprintf(&b, "title: %s\n", t) } if n.Parent != "" { fmt.Fprintf(&b, "parent: %s\n", n.Parent) } 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 page file. A file with no header is still a page: 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 "title": var t string if strings.HasPrefix(v, `"`) && json.Unmarshal([]byte(v), &t) == nil { n.Title = CleanTitle(t) } else { n.Title = CleanTitle(v) } case "parent": if ValidID(v) { n.Parent = v } 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") } } if n.Daily != "" { n.Title = n.Daily n.Parent = "" } n.set(text) return n } // CleanTitle makes a title safe to link to: one line, no brackets or bars, not too long. func CleanTitle(s string) string { s = strings.Map(func(r rune) rune { switch { case r == '[' || r == ']' || r == '|': return -1 case unicode.IsControl(r): return ' ' } return r }, s) s = strings.Join(strings.Fields(s), " ") if utf8.RuneCountInString(s) > MaxTitle { s = strings.TrimSpace(string([]rune(s)[:MaxTitle])) } return s } // set stores the body (without trailing blank space) and works out what is derived from it. func (n *Note) set(body string) { n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n") n.refresh() } func (n *Note) refresh() { n.lower = strings.ToLower(n.Title + "\n" + n.Body) n.Tags = Tags(n.Body) n.links = Links(n.Body) n.Name = nameOf(n) } var leadRe = regexp.MustCompile(`^(?:#{1,6}\s+|>\s*|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)+`) func nameOf(n *Note) string { if n.Title != "" { return n.Title } for _, l := range strings.Split(n.Body, "\n") { l = strings.TrimSpace(leadRe.ReplaceAllString(strings.TrimSpace(l), "")) if l == "" || strings.HasPrefix(l, "```") { continue } l = strings.NewReplacer("[[", "", "]]", "", "**", "", "`", "").Replace(l) if r := []rune(l); len(r) > 60 { l = strings.TrimSpace(string(r[:60])) + "..." } return l } return "Untitled" } var ( tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`) inlineCodeRe = regexp.MustCompile("`[^`\n]*`") linkRe = regexp.MustCompile(`\[\[([^\[\]|\n]+?)(?:\|[^\[\]\n]*)?\]\]`) ) // withoutCode is the body with fenced and inline code taken out, so tags and links inside code // are not counted. func withoutCode(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, "")) } } return strings.Join(lines, "\n") } // Tags returns the #tags in a page, lower-cased and without repeats. Code is skipped, and so are // numbers such as #12. func Tags(body string) []string { seen := map[string]bool{} out := []string{} for _, m := range tagRe.FindAllStringSubmatch(withoutCode(body), -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 } // Links returns the titles a page links to with [[Title]] or [[Title|text]], lower-cased and // without repeats. func Links(body string) []string { var out []string for _, m := range linkRe.FindAllStringSubmatch(withoutCode(body), -1) { t := strings.ToLower(strings.TrimSpace(m[1])) if t != "" && !slices.Contains(out, t) { 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, s.box.Seal(format(n), n.ID), 0o600); 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 } // moved keeps updated moving forward, so a stale tab is always noticed. func moved(prev time.Time) time.Time { now := time.Now().UTC() if !now.After(prev) { return prev.Add(time.Microsecond) } return now } // ---- changes ---- // titleTaken says whether another live page has this title. Dates belong to the journal. func (s *Store) titleTaken(title, except string) bool { l := strings.ToLower(title) if dateRe.MatchString(title) && !strings.HasPrefix(except, "daily-") { return true } for id, n := range s.by { if id != except && !n.Trashed && n.Title != "" && strings.ToLower(n.Title) == l { return true } } return false } // underneath says whether id is parent or lies somewhere below it. func (s *Store) underneath(id, parent string) bool { for cur, hops := parent, 0; cur != "" && hops < 200; hops++ { if cur == id { return true } n, ok := s.by[cur] if !ok { return false } cur = n.Parent } return false } func (s *Store) checkParent(id, parent string) error { if parent == "" { return nil } p, ok := s.by[parent] if !ok || p.Trashed || p.Daily != "" || s.underneath(id, parent) { return ErrBadParent } return nil } type NewNote struct { Title, Body, Parent string } // Create saves a new page. func (s *Store) Create(in NewNote) (Note, error) { if err := checkBody(in.Body); err != nil { return Note{}, err } title := CleanTitle(in.Title) if title == "" && strings.TrimSpace(in.Body) == "" { return Note{}, ErrEmpty } now := time.Now().UTC() n := &Note{ID: newID(now), Created: now, Updated: now, Title: title, Parent: in.Parent} n.set(in.Body) s.mu.Lock() defer s.mu.Unlock() if title != "" && s.titleTaken(title, n.ID) { return Note{}, ErrTitleTaken } if err := s.checkParent(n.ID, n.Parent); err != nil { return Note{}, err } if err := s.write(n); err != nil { return Note{}, err } s.by[n.ID] = n return n.copy(), nil } // Daily returns the page 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 := s.write(n); err != nil { n.Trashed = true return Note{}, err } os.Remove(old) } return n.copy(), nil } now := time.Now().UTC() n := &Note{ID: id, Created: now, Updated: now, Daily: date, Title: date} n.refresh() 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 } // Change is an update; fields that are nil stay as they are. type Change struct { Title, Body, Parent *string Pinned *bool } // Update changes a page. When base is given, the title and text are only replaced if the page has // not been changed since then; otherwise the current page comes back with ErrConflict. Everything is // checked before anything is written. func (s *Store) Update(id string, ch Change, base time.Time) (Note, error) { s.mu.Lock() defer s.mu.Unlock() n, ok := s.by[id] if !ok { return Note{}, ErrNotFound } next := n.copy() content := false if ch.Body != nil { if err := checkBody(*ch.Body); err != nil { return Note{}, err } content = true } if ch.Title != nil { t := CleanTitle(*ch.Title) if t != n.Title { if n.Daily != "" { return Note{}, ErrDailyTitle } if t != "" && s.titleTaken(t, id) { return Note{}, ErrTitleTaken } next.Title = t content = true } } if content && !base.IsZero() && !n.Updated.Equal(base) { return n.copy(), ErrConflict } if ch.Parent != nil { if n.Daily != "" && *ch.Parent != "" { return Note{}, ErrBadParent } if err := s.checkParent(id, *ch.Parent); err != nil { return Note{}, err } next.Parent = *ch.Parent } if ch.Pinned != nil { next.Pinned = *ch.Pinned } if ch.Body != nil { next.set(*ch.Body) } else { next.refresh() } if content { next.Updated = moved(n.Updated) } if err := s.write(&next); err != nil { return Note{}, err } old := n.Title *n = next if old != "" && n.Title != old { if err := s.relink(old, n.Title, id); err != nil { return n.copy(), err } } return n.copy(), nil } // relink points the [[old]] links of other pages at a renamed page. It rewrites nothing inside code. func (s *Store) relink(oldTitle, newTitle, except string) error { if newTitle == "" { return nil } old := strings.ToLower(oldTitle) for _, m := range s.by { if m.ID == except || m.Trashed || !slices.Contains(m.links, old) { continue } body := rewriteLinks(m.Body, old, newTitle) if body == m.Body { continue } next := m.copy() next.set(body) next.Updated = moved(m.Updated) if err := s.write(&next); err != nil { return err } *m = next } return nil } func rewriteLinks(body, old, newTitle string) string { lines := strings.Split(body, "\n") fence := false for i, l := range lines { if strings.HasPrefix(strings.TrimSpace(l), "```") { fence = !fence continue } if fence { continue } lines[i] = linkRe.ReplaceAllStringFunc(l, func(x string) string { sub := linkRe.FindStringSubmatch(x) if strings.ToLower(strings.TrimSpace(sub[1])) != old { return x } alias := "" if k := strings.Index(x, "|"); k >= 0 { alias = x[k : len(x)-2] } return "[[" + newTitle + alias + "]]" }) } return strings.Join(lines, "\n") } // free finds a title that no live page has, by adding a number if it must. func (s *Store) free(title, except string) string { if title == "" || !s.titleTaken(title, except) { return title } for i := 2; ; i++ { t := fmt.Sprintf("%s %d", title, i) if !s.titleTaken(t, except) { return t } } } 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) prev := n.copy() n.Trashed = toTrash if toTrash { // Pages inside it move up a level, so nothing is lost with it. for _, c := range s.by { if c.Parent == id && !c.Trashed { next := c.copy() next.Parent = n.Parent if err := s.write(&next); err != nil { *n = prev return Note{}, err } *c = next } } } else { if p, ok := s.by[n.Parent]; n.Parent != "" && (!ok || p.Trashed) { n.Parent = "" } n.Title = s.free(n.Title, id) if n.Daily != "" { n.Title = n.Daily } n.refresh() } if err := s.write(n); err != nil { *n = prev return Note{}, err } os.Remove(old) return n.copy(), nil } // Trash moves a page 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 page 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 pages 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 pages, newest first, and how many matched in all. func (s *Store) List(q Query) ([]Note, int) { words, tags := splitQuery(q.Text) 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 splitQuery(text string) (words, tags []string) { for _, w := range strings.Fields(strings.ToLower(text)) { if t := strings.TrimPrefix(w, "#"); t != w && t != "" { tags = append(tags, t) } else { words = append(words, w) } } return } 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 } // Ref is a page as the sidebar and link pickers see it: no text. type Ref struct { ID string `json:"id"` Title string `json:"title"` Name string `json:"name"` Parent string `json:"parent,omitempty"` Daily string `json:"daily,omitempty"` Pinned bool `json:"pinned,omitempty"` } func ref(n *Note) Ref { return Ref{ID: n.ID, Title: n.Title, Name: n.Name, Parent: n.Parent, Daily: n.Daily, Pinned: n.Pinned} } // Pages lists the pages worth showing in navigation: those with a title, those inside another page or // holding one, the pinned ones, and journal days with something written. Untitled quick notes are left // out, so jotting things down never clutters the sidebar. func (s *Store) Pages() []Ref { s.mu.RLock() defer s.mu.RUnlock() holders := map[string]bool{} for _, n := range s.by { if !n.Trashed && n.Parent != "" { holders[n.Parent] = true } } out := []Ref{} for _, n := range s.by { switch { case n.Trashed: case n.Daily != "": if n.Body != "" { out = append(out, ref(n)) } case n.Title != "" || n.Parent != "" || n.Pinned || holders[n.ID]: out = append(out, ref(n)) } } sort.Slice(out, func(i, j int) bool { a, b := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name) if a != b { return a < b } return out[i].ID < out[j].ID }) return out } // Backlinks lists the pages that link to this one with [[its title]]. func (s *Store) Backlinks(id string) []Ref { s.mu.RLock() defer s.mu.RUnlock() n, ok := s.by[id] out := []Ref{} if !ok || n.Title == "" { return out } want := strings.ToLower(n.Title) for _, m := range s.by { if m.ID != id && !m.Trashed && slices.Contains(m.links, want) { out = append(out, ref(m)) } } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } // Hit is a search result for the quick switcher. type Hit struct { Ref Snippet string `json:"snippet,omitempty"` } // Find searches titles and text for the quick switcher and the [[ picker. Title matches come first. // With no query it returns the pages touched most recently. func (s *Store) Find(q string, limit int) []Hit { words, _ := splitQuery(q) whole := strings.ToLower(strings.TrimSpace(q)) type scored struct { n *Note score int } s.mu.RLock() defer s.mu.RUnlock() var hits []scored for _, n := range s.by { if n.Trashed { continue } if len(words) == 0 { hits = append(hits, scored{n, 0}) continue } if !matches(n, words, nil) { continue } t, score := strings.ToLower(n.Name), 3 switch { case t == whole: score = 0 case strings.HasPrefix(t, whole): score = 1 case strings.Contains(t, whole) || allIn(t, words): score = 2 } hits = append(hits, scored{n, score}) } sort.Slice(hits, func(i, j int) bool { a, b := hits[i], hits[j] if a.score != b.score { return a.score < b.score } if !a.n.Updated.Equal(b.n.Updated) { return a.n.Updated.After(b.n.Updated) } return a.n.ID < b.n.ID }) if limit > 0 && len(hits) > limit { hits = hits[:limit] } out := make([]Hit, len(hits)) for i, h := range hits { out[i] = Hit{Ref: ref(h.n)} if h.score == 3 { out[i].Snippet = snippet(h.n.Body, words) } } return out } func allIn(s string, words []string) bool { for _, w := range words { if !strings.Contains(s, w) { return false } } return true } // snippet is the first line that has one of the words in it, cut to a sensible length. func snippet(body string, words []string) string { for _, l := range strings.Split(body, "\n") { low := strings.ToLower(l) for _, w := range words { if strings.Contains(low, w) { l = strings.TrimSpace(l) if r := []rune(l); len(r) > 100 { l = string(r[:100]) + "..." } return l } } } return "" } 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 pages and the tags in use (trashed pages 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 } var badFile = strings.NewReplacer("/", "-", `\`, "-", ":", "-", "*", "-", "?", "-", `"`, "-", "<", "-", ">", "-", "|", "-", "#", "-", "^", "-") func fileName(n *Note) string { name := strings.Trim(badFile.Replace(n.Name), ". ") if name == "" { name = "Untitled" } if n.Daily != "" { return "Journal/" + n.Daily } return name } // WriteZip writes every page that is not in the trash as a .md file in a zip archive that opens as a // vault in Obsidian: a page's file name is its title, so [[links]] keep working, and journal days sit // in a Journal folder. func (s *Store) WriteZip(w io.Writer) error { s.mu.RLock() defer s.mu.RUnlock() all := make([]*Note, 0, len(s.by)) for _, n := range s.by { if !n.Trashed { all = append(all, n) } } sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) used := map[string]bool{} zw := zip.NewWriter(w) for _, n := range all { base := fileName(n) name := base for i := 2; used[strings.ToLower(name)]; i++ { name = fmt.Sprintf("%s %d", base, i) } used[strings.ToLower(name)] = true f, err := zw.CreateHeader(&zip.FileHeader{Name: "mako/" + name + ".md", Method: zip.Deflate, Modified: n.Updated}) if err != nil { return err } if _, err := f.Write(exportFormat(n)); err != nil { return err } } return zw.Close() } // exportFormat is the page with a header Obsidian understands. func exportFormat(n *Note) []byte { var b strings.Builder b.WriteString("---\n") fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339)) fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339)) if n.Pinned { b.WriteString("pinned: true\n") } b.WriteString("---\n\n") b.WriteString(n.Body) b.WriteString("\n") return []byte(b.String()) }