1057 lines
25 KiB
Go
1057 lines
25 KiB
Go
// Package store keeps pages as plain markdown files, one per page, so they stay readable and
|
|
// portable with or without Mako. 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"
|
|
)
|
|
|
|
// 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
|
|
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.
|
|
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
|
|
}
|
|
}
|
|
s.mend()
|
|
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, 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
|
|
}
|
|
|
|
// 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())
|
|
}
|