Basking: sign in with Authentik only, pages that nest and link
Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
+586
-73
@@ -1,34 +1,44 @@
|
||||
// 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 keeps pages as plain markdown files, one per page, so they stay readable and
|
||||
// portable with or without Basking. 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 note, in bytes.
|
||||
const MaxBody = 256 << 10
|
||||
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
|
||||
const (
|
||||
MaxBody = 256 << 10
|
||||
MaxTitle = 120
|
||||
)
|
||||
|
||||
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")
|
||||
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 {
|
||||
@@ -36,12 +46,16 @@ type Note struct {
|
||||
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
|
||||
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 body, for searching
|
||||
lower string // lower-cased title and body, for searching
|
||||
links []string // lower-cased titles this page links to with [[...]]
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -50,12 +64,15 @@ type Store struct {
|
||||
by map[string]*Note
|
||||
}
|
||||
|
||||
var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
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 note id (and so as a file name).
|
||||
// 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 note under dir, creating the folders if needed.
|
||||
// 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"} {
|
||||
@@ -85,9 +102,32 @@ func Open(dir string) (*Store, error) {
|
||||
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 {
|
||||
@@ -99,17 +139,26 @@ func (s *Store) path(n *Note) string {
|
||||
// ---- 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 note, in markdown
|
||||
// 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 {
|
||||
@@ -124,7 +173,7 @@ func format(n *Note) []byte {
|
||||
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.
|
||||
// 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()}
|
||||
@@ -137,6 +186,17 @@ func parse(id, text string, mod time.Time) *Note {
|
||||
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()
|
||||
@@ -156,25 +216,74 @@ func parse(id, text string, mod time.Time) *Note {
|
||||
text = strings.TrimPrefix(body, "\n")
|
||||
}
|
||||
}
|
||||
if n.Daily != "" {
|
||||
n.Title = n.Daily
|
||||
n.Parent = ""
|
||||
}
|
||||
n.set(text)
|
||||
return n
|
||||
}
|
||||
|
||||
// set stores the body (without trailing blank space) and works out the tags.
|
||||
// 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.lower = strings.ToLower(n.Body)
|
||||
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]*)?\]\]`)
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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") {
|
||||
@@ -186,9 +295,15 @@ func Tags(body string) []string {
|
||||
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(strings.Join(lines, "\n"), -1) {
|
||||
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
|
||||
@@ -199,6 +314,19 @@ func Tags(body string) []string {
|
||||
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' {
|
||||
@@ -236,21 +364,81 @@ func checkBody(body string) error {
|
||||
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 ----
|
||||
|
||||
// Create saves a new note.
|
||||
func (s *Store) Create(body string) (Note, error) {
|
||||
if err := checkBody(body); err != nil {
|
||||
// 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
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
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}
|
||||
n.set(body)
|
||||
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
|
||||
}
|
||||
@@ -258,7 +446,7 @@ func (s *Store) Create(body string) (Note, error) {
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
// Daily returns the note for a date (like 2026-10-31), making it if it is not there yet.
|
||||
// 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
|
||||
@@ -270,15 +458,17 @@ func (s *Store) Daily(date string) (Note, error) {
|
||||
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 {
|
||||
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}
|
||||
n := &Note{ID: id, Created: now, Updated: now, Daily: date, Title: date}
|
||||
n.refresh()
|
||||
if err := s.write(n); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
@@ -296,41 +486,143 @@ func (s *Store) Get(id string) (Note, error) {
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
if body != nil {
|
||||
if err := checkBody(*body); err != nil {
|
||||
next := n.copy()
|
||||
content := false
|
||||
if ch.Body != nil {
|
||||
if err := checkBody(*ch.Body); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
if !base.IsZero() && !n.Updated.Equal(base) {
|
||||
return n.copy(), ErrConflict
|
||||
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
|
||||
}
|
||||
}
|
||||
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 content && !base.IsZero() && !n.Updated.Equal(base) {
|
||||
return n.copy(), ErrConflict
|
||||
}
|
||||
if pinned != nil {
|
||||
next.Pinned = *pinned
|
||||
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()
|
||||
@@ -342,19 +634,44 @@ func (s *Store) move(id string, toTrash bool) (Note, error) {
|
||||
return n.copy(), nil
|
||||
}
|
||||
old := s.path(n)
|
||||
prev := n.copy()
|
||||
n.Trashed = toTrash
|
||||
if err := os.Rename(old, s.path(n)); err != nil {
|
||||
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 note to the trash. Restore brings it back.
|
||||
// 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 note that is already in the trash, for good.
|
||||
// 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()
|
||||
@@ -372,7 +689,7 @@ func (s *Store) Purge(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyTrash deletes everything in the trash for good and says how many notes that was.
|
||||
// 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()
|
||||
@@ -400,16 +717,9 @@ type Query struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
// List returns one page of matching notes, newest first, and how many matched in all.
|
||||
// List returns one page of matching pages, 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)
|
||||
}
|
||||
}
|
||||
words, tags := splitQuery(q.Text)
|
||||
if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
@@ -449,6 +759,17 @@ func (s *Store) List(q Query) ([]Note, int) {
|
||||
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) {
|
||||
@@ -470,6 +791,162 @@ func matches(n *Note, words, tags []string) bool {
|
||||
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"`
|
||||
@@ -482,7 +959,7 @@ type Counts struct {
|
||||
Trash int `json:"trash"`
|
||||
}
|
||||
|
||||
// Summary counts the notes and the tags in use (trashed notes do not count towards tags).
|
||||
// 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()
|
||||
@@ -517,27 +994,63 @@ func (s *Store) Summary() (Counts, []TagCount) {
|
||||
return c, tags
|
||||
}
|
||||
|
||||
// WriteZip writes every note that is not in the trash as a .md file in a zip archive.
|
||||
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()
|
||||
ids := make([]string, 0, len(s.by))
|
||||
for id, n := range s.by {
|
||||
all := make([]*Note, 0, len(s.by))
|
||||
for _, n := range s.by {
|
||||
if !n.Trashed {
|
||||
ids = append(ids, id)
|
||||
all = append(all, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
|
||||
used := map[string]bool{}
|
||||
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})
|
||||
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: "basking/" + name + ".md", Method: zip.Deflate, Modified: n.Updated})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(format(n)); err != nil {
|
||||
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())
|
||||
}
|
||||
+295
-64
@@ -22,6 +22,17 @@ func open(t *testing.T) (*Store, string) {
|
||||
return s, dir
|
||||
}
|
||||
|
||||
func mk(t *testing.T, s *Store, title, body string) Note {
|
||||
t.Helper()
|
||||
n, err := s.Create(NewNote{Title: title, Body: body})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
@@ -41,14 +52,47 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinks(t *testing.T) {
|
||||
got := Links("see [[Trip Plan]] and [[trip plan|the plan]] and [[ Ideas ]], not `[[code]]` or\n```\n[[fenced]]\n```\n[[]] [[a|b]]")
|
||||
want := []string{"trip plan", "ideas", "a"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Links = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameComesFromTitleOrFirstLine(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
cases := []struct{ title, body, want string }{
|
||||
{"Trip", "ignored", "Trip"},
|
||||
{"", "# Heading line\nmore", "Heading line"},
|
||||
{"", "- [ ] buy **milk** [[Shop]]", "buy milk Shop"},
|
||||
{"", "\n\n plain \n", "plain"},
|
||||
{"", strings.Repeat("word ", 30), strings.TrimSpace(strings.Repeat("word ", 30)[:60]) + "..."},
|
||||
}
|
||||
for _, c := range cases {
|
||||
n := mk(t, s, c.title, c.body)
|
||||
if n.Name != c.want {
|
||||
t.Errorf("name of %q/%q = %q, want %q", c.title, c.body, n.Name, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := s.Create(NewNote{}); !errors.Is(err, ErrEmpty) {
|
||||
t.Fatalf("empty page: %v", err)
|
||||
}
|
||||
only := mk(t, s, "Just a title", "")
|
||||
if only.Name != "Just a title" {
|
||||
t.Fatal("a page can be only a title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n")
|
||||
parent := mk(t, s, "Projects", "")
|
||||
n, err := s.Create(NewNote{Title: " Basking: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Body != "first thought #idea\n\nwith a second paragraph" {
|
||||
t.Fatalf("body %q", n.Body)
|
||||
if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Basking: a "notebook" xy` {
|
||||
t.Fatalf("clean-up: %q %q", n.Body, n.Title)
|
||||
}
|
||||
if !ValidID(n.ID) {
|
||||
t.Fatalf("id %q", n.ID)
|
||||
@@ -56,7 +100,6 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -65,23 +108,20 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Body != n.Body || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
|
||||
if got.Body != n.Body || got.Title != n.Title || got.Parent != parent.ID || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) {
|
||||
t.Fatalf("reloaded %+v, want %+v", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsEmptyAndHuge(t *testing.T) {
|
||||
func TestCreateRejectsHuge(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) {
|
||||
if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) {
|
||||
t.Fatalf("huge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainMarkdownFileIsANote(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
func TestPlainMarkdownFileIsAPage(t *testing.T) {
|
||||
_, dir := open(t)
|
||||
path := filepath.Join(dir, "notes", "from-elsewhere.md")
|
||||
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
|
||||
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
|
||||
@@ -94,44 +134,151 @@ func TestPlainMarkdownFileIsANote(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) {
|
||||
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) || n.Name != "Dropped in" {
|
||||
t.Fatalf("%+v", n)
|
||||
}
|
||||
_ = s
|
||||
}
|
||||
|
||||
func TestUpdateConflictAndPin(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
n, _ := s.Create("one")
|
||||
body := "two"
|
||||
n2, err := s.Update(n.ID, &body, nil, n.Updated)
|
||||
n := mk(t, s, "", "one")
|
||||
n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated)
|
||||
if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) {
|
||||
t.Fatalf("update: %v %+v", err, n2)
|
||||
}
|
||||
stale := "three"
|
||||
cur, err := s.Update(n.ID, &stale, nil, n.Updated)
|
||||
cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated)
|
||||
if !errors.Is(err, ErrConflict) || cur.Body != "two" {
|
||||
t.Fatalf("stale write: %v %+v", err, cur)
|
||||
}
|
||||
if _, err := s.Update(n.ID, Change{Title: str("Late title")}, n.Updated); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("a stale title should conflict too: %v", err)
|
||||
}
|
||||
yes := true
|
||||
n3, err := s.Update(n.ID, nil, &yes, time.Time{})
|
||||
n3, err := s.Update(n.ID, Change{Pinned: &yes}, time.Time{})
|
||||
if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) {
|
||||
t.Fatalf("pin should not move updated: %v %+v", err, n3)
|
||||
}
|
||||
// a write without a base always goes through (scripts appending)
|
||||
if _, err := s.Update(n.ID, &stale, nil, time.Time{}); err != nil {
|
||||
if _, err := s.Update(n.ID, Change{Body: str("four")}, time.Time{}); err != nil {
|
||||
t.Fatal(err) // a write without a base always goes through
|
||||
}
|
||||
if _, err := s.Update("nope", Change{Body: str("x")}, time.Time{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesAreUnique(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
a := mk(t, s, "Trip Plan", "")
|
||||
if _, err := s.Create(NewNote{Title: "trip plan"}); !errors.Is(err, ErrTitleTaken) {
|
||||
t.Fatalf("same title, other case: %v", err)
|
||||
}
|
||||
b := mk(t, s, "Other", "")
|
||||
if _, err := s.Update(b.ID, Change{Title: str("TRIP PLAN")}, time.Time{}); !errors.Is(err, ErrTitleTaken) {
|
||||
t.Fatalf("rename onto a taken title: %v", err)
|
||||
}
|
||||
if got, _ := s.Get(b.ID); got.Title != "Other" {
|
||||
t.Fatal("a refused rename must change nothing")
|
||||
}
|
||||
if _, err := s.Update(a.ID, Change{Title: str("trip plan")}, time.Time{}); err != nil {
|
||||
t.Fatalf("a page may change the case of its own title: %v", err)
|
||||
}
|
||||
if _, err := s.Create(NewNote{Title: "2026-10-31"}); !errors.Is(err, ErrTitleTaken) {
|
||||
t.Fatalf("dates belong to the journal: %v", err)
|
||||
}
|
||||
if _, err := s.Update(b.ID, Change{Title: str("")}, time.Time{}); err != nil {
|
||||
t.Fatalf("clearing a title: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameRewritesLinks(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
target := mk(t, s, "Trip Plan", "details")
|
||||
a := mk(t, s, "", "see [[Trip Plan]] and [[trip plan|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Trip Plan]]`")
|
||||
b := mk(t, s, "", "nothing here, [[Elsewhere]]")
|
||||
if _, err := s.Update(target.ID, Change{Title: str("Lisbon Trip")}, time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing: %v", err)
|
||||
got, _ := s.Get(a.ID)
|
||||
want := "see [[Lisbon Trip]] and [[Lisbon Trip|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Lisbon Trip]]`"
|
||||
if got.Body != want {
|
||||
t.Fatalf("body:\n%s\nwant:\n%s", got.Body, want)
|
||||
}
|
||||
if !got.Updated.After(a.Updated) {
|
||||
t.Fatal("a rewritten page should move forward so open editors notice")
|
||||
}
|
||||
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
|
||||
t.Fatal("unrelated pages must not be touched")
|
||||
}
|
||||
s2, _ := Open(s.dir)
|
||||
if again, _ := s2.Get(a.ID); again.Body != want {
|
||||
t.Fatal("rewrite was not saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacklinks(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
target := mk(t, s, "Ideas", "")
|
||||
mk(t, s, "Zeta", "mentions [[ideas]]")
|
||||
mk(t, s, "Alpha", "also [[Ideas|my ideas]]")
|
||||
mk(t, s, "Nope", "[[Something else]]")
|
||||
gone := mk(t, s, "Deleted", "[[Ideas]]")
|
||||
s.Trash(gone.ID)
|
||||
got := s.Backlinks(target.ID)
|
||||
if len(got) != 2 || got[0].Name != "Alpha" || got[1].Name != "Zeta" {
|
||||
t.Fatalf("backlinks %+v", got)
|
||||
}
|
||||
if b := s.Backlinks(mk(t, s, "", "no title").ID); len(b) != 0 {
|
||||
t.Fatal("an untitled page cannot be linked to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeParentsAndCycles(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
a := mk(t, s, "A", "")
|
||||
b, _ := s.Create(NewNote{Title: "B", Parent: a.ID})
|
||||
c, _ := s.Create(NewNote{Title: "C", Parent: b.ID})
|
||||
if _, err := s.Update(a.ID, Change{Parent: &c.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
|
||||
t.Fatalf("a page inside its own descendant: %v", err)
|
||||
}
|
||||
if _, err := s.Update(a.ID, Change{Parent: &a.ID}, time.Time{}); !errors.Is(err, ErrBadParent) {
|
||||
t.Fatalf("a page inside itself: %v", err)
|
||||
}
|
||||
if _, err := s.Create(NewNote{Title: "D", Parent: "nope"}); !errors.Is(err, ErrBadParent) {
|
||||
t.Fatalf("a missing parent: %v", err)
|
||||
}
|
||||
root := ""
|
||||
if got, err := s.Update(c.ID, Change{Parent: &root}, time.Time{}); err != nil || got.Parent != "" {
|
||||
t.Fatalf("moving to the top level: %v %+v", err, got)
|
||||
}
|
||||
|
||||
// a hand-edited file with a circle of parents is mended on load
|
||||
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
|
||||
s2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"x1", "x2", "x3"} {
|
||||
n, _ := s2.Get(id)
|
||||
if id == "x3" && n.Parent != "" {
|
||||
t.Errorf("%s keeps a parent that does not exist", id)
|
||||
}
|
||||
}
|
||||
x1, _ := s2.Get("x1")
|
||||
x2, _ := s2.Get("x2")
|
||||
if x1.Parent != "" && x2.Parent != "" {
|
||||
t.Error("a circle of parents must be broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashRestorePurge(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
n, _ := s.Create("to be thrown away")
|
||||
parent := mk(t, s, "Parent", "")
|
||||
n, _ := s.Create(NewNote{Title: "Child", Body: "to be thrown away", Parent: parent.ID})
|
||||
kid, _ := s.Create(NewNote{Title: "Grandchild", Parent: n.ID})
|
||||
if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) {
|
||||
t.Fatalf("purge of a live note: %v", err)
|
||||
t.Fatalf("purge of a live page: %v", err)
|
||||
}
|
||||
if _, err := s.Trash(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -139,35 +286,38 @@ func TestTrashRestorePurge(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil {
|
||||
t.Fatal("not moved to trash:", err)
|
||||
}
|
||||
if list, _ := s.List(Query{}); len(list) != 0 {
|
||||
t.Fatal("trashed note still listed")
|
||||
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil {
|
||||
t.Fatal("the live copy should be gone")
|
||||
}
|
||||
if got, _ := s.Get(kid.ID); got.Parent != parent.ID {
|
||||
t.Fatalf("pages inside a trashed page move up a level, got parent %q", got.Parent)
|
||||
}
|
||||
if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 {
|
||||
t.Fatal("trash view empty")
|
||||
}
|
||||
// survives a restart in the trash
|
||||
s2, _ := Open(dir)
|
||||
if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 {
|
||||
t.Fatal("trash lost on reload")
|
||||
}
|
||||
if _, err := s2.Restore(n.ID); err != nil {
|
||||
// a new page takes the title while it is away; restoring must not make two
|
||||
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := s2.List(Query{}); len(list) != 1 {
|
||||
t.Fatal("restore failed")
|
||||
s2, _ := Open(dir)
|
||||
back, err := s2.Restore(n.ID)
|
||||
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
|
||||
t.Fatalf("restore: %v %+v", err, back)
|
||||
}
|
||||
s2.Trash(parent.ID)
|
||||
s2.Trash(n.ID)
|
||||
again, _ := s2.Restore(n.ID)
|
||||
if again.Parent != "" {
|
||||
t.Fatal("restoring under a parent that is gone should go to the top level")
|
||||
}
|
||||
s2.Trash(n.ID)
|
||||
if err := s2.Purge(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal("purged note is still there")
|
||||
t.Fatal("purged page is still there")
|
||||
}
|
||||
a, _ := s2.Create("a")
|
||||
b, _ := s2.Create("b")
|
||||
s2.Trash(a.ID)
|
||||
s2.Trash(b.ID)
|
||||
if k, err := s2.EmptyTrash(); err != nil || k != 2 {
|
||||
if k, err := s2.EmptyTrash(); err != nil || k != 1 {
|
||||
t.Fatalf("empty trash: %d %v", k, err)
|
||||
}
|
||||
}
|
||||
@@ -175,18 +325,21 @@ func TestTrashRestorePurge(t *testing.T) {
|
||||
func TestDaily(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
d1, err := s.Daily("2026-10-31")
|
||||
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" {
|
||||
if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" || d1.Title != "2026-10-31" {
|
||||
t.Fatalf("%v %+v", err, d1)
|
||||
}
|
||||
d2, _ := s.Daily("2026-10-31")
|
||||
if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) {
|
||||
t.Fatal("asking twice should give the same note")
|
||||
t.Fatal("asking twice should give the same page")
|
||||
}
|
||||
for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} {
|
||||
if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) {
|
||||
t.Errorf("%q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.Update(d1.ID, Change{Title: str("Renamed")}, time.Time{}); !errors.Is(err, ErrDailyTitle) {
|
||||
t.Fatalf("renaming a day: %v", err)
|
||||
}
|
||||
s.Trash(d1.ID)
|
||||
d3, _ := s.Daily("2026-10-31")
|
||||
if d3.Trashed {
|
||||
@@ -197,24 +350,38 @@ func TestDaily(t *testing.T) {
|
||||
if len(list) != 2 || list[0].Daily != "2026-10-31" {
|
||||
t.Fatalf("daily view order: %+v", list)
|
||||
}
|
||||
linker := mk(t, s, "", "on [[2026-10-31]] I wrote")
|
||||
if got := s.Backlinks(d1.ID); len(got) != 1 || got[0].ID != linker.ID {
|
||||
t.Fatalf("a day can be linked by its date: %+v", got)
|
||||
}
|
||||
// reload keeps the title from the date, and an empty day stays out of navigation
|
||||
s2, _ := Open(s.dir)
|
||||
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
|
||||
t.Fatalf("title after reload: %q", n.Title)
|
||||
}
|
||||
for _, r := range s2.Pages() {
|
||||
if r.Daily != "" {
|
||||
t.Fatalf("an empty day should not be listed: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAndTags(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
s.Create("Buy oat MILK #errands")
|
||||
s.Create(NewNote{Body: "Buy oat MILK #errands"})
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
s.Create("Call the dentist #errands #health")
|
||||
s.Create(NewNote{Body: "Call the dentist #errands #health"})
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
s.Create("Idea: a #projects/basking dashboard")
|
||||
s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/basking dashboard"})
|
||||
|
||||
if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 {
|
||||
t.Fatalf("case-insensitive search: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Text: "the dentist"}); len(list) != 1 {
|
||||
t.Fatalf("all words must match: %d", len(list))
|
||||
if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 {
|
||||
t.Fatalf("titles are searched: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 {
|
||||
t.Fatalf("words in different notes: %d", len(list))
|
||||
t.Fatalf("words in different pages: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
|
||||
t.Fatalf("tag filter: %d", len(list))
|
||||
@@ -226,39 +393,93 @@ func TestSearchAndTags(t *testing.T) {
|
||||
t.Fatalf("a parent tag finds its children: %d", len(list))
|
||||
}
|
||||
all, total := s.List(Query{Limit: 2})
|
||||
if len(all) != 2 || total != 3 || !strings.HasPrefix(all[0].Body, "Idea") {
|
||||
t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body)
|
||||
if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" {
|
||||
t.Fatalf("newest first with a page size: %d of %d", len(all), total)
|
||||
}
|
||||
if page2, _ := s.List(Query{Limit: 2, Offset: 2}); len(page2) != 1 {
|
||||
t.Fatalf("second page: %d", len(page2))
|
||||
}
|
||||
|
||||
counts, tags := s.Summary()
|
||||
if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 {
|
||||
t.Fatalf("summary %+v %+v", counts, tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
mk(t, s, "Trip to Lisbon", "flights and hotels")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
mk(t, s, "Packing list", "passport, charger\nlisbon weather is warm")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
mk(t, s, "Lis", "")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
mk(t, s, "Unrelated", "nothing")
|
||||
|
||||
hits := s.Find("lis", 10)
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"Lis", "Packing list", "Trip to Lisbon"}) {
|
||||
t.Fatalf("exact title first, then newer titles ahead of older: %v", names)
|
||||
}
|
||||
body := s.Find("warm", 10)
|
||||
if len(body) != 1 || body[0].Name != "Packing list" || body[0].Snippet != "lisbon weather is warm" {
|
||||
t.Fatalf("body match: %+v", body)
|
||||
}
|
||||
if recent := s.Find("", 2); len(recent) != 2 || recent[0].Name != "Unrelated" {
|
||||
t.Fatalf("no query lists recent pages: %+v", recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesListsWhatBelongsInNavigation(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
titled := mk(t, s, "Titled", "")
|
||||
quick := mk(t, s, "", "a quick thought")
|
||||
holder := mk(t, s, "", "untitled but holds a page")
|
||||
child, _ := s.Create(NewNote{Body: "child", Parent: holder.ID})
|
||||
pinned := mk(t, s, "", "pinned thought")
|
||||
yes := true
|
||||
s.Update(pinned.ID, Change{Pinned: &yes}, time.Time{})
|
||||
gone := mk(t, s, "Gone", "")
|
||||
s.Trash(gone.ID)
|
||||
|
||||
ids := map[string]bool{}
|
||||
for _, r := range s.Pages() {
|
||||
ids[r.ID] = true
|
||||
}
|
||||
for _, id := range []string{titled.ID, holder.ID, child.ID, pinned.ID} {
|
||||
if !ids[id] {
|
||||
t.Errorf("%s should be listed", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{quick.ID, gone.ID} {
|
||||
if ids[id] {
|
||||
t.Errorf("%s should not be listed", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDsCannotEscape(t *testing.T) {
|
||||
for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} {
|
||||
if ValidID(id) {
|
||||
t.Errorf("%q should not be a valid id", id)
|
||||
}
|
||||
}
|
||||
s, dir := open(t)
|
||||
_, dir := open(t)
|
||||
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
|
||||
s2, _ := Open(dir)
|
||||
if list, _ := s2.List(Query{}); len(list) != 0 {
|
||||
t.Fatal("a file whose name is not a valid id should be ignored")
|
||||
}
|
||||
_ = s
|
||||
}
|
||||
|
||||
func TestZip(t *testing.T) {
|
||||
func TestZipIsAnObsidianVault(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
a, _ := s.Create("keep me")
|
||||
b, _ := s.Create("bin me")
|
||||
s.Trash(b.ID)
|
||||
mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]")
|
||||
mk(t, s, "Packing", "")
|
||||
mk(t, s, "", "# A quick one")
|
||||
mk(t, s, "", "# A quick one")
|
||||
bin := mk(t, s, "Bin me", "")
|
||||
s.Trash(bin.ID)
|
||||
s.Daily("2026-10-31")
|
||||
var buf bytes.Buffer
|
||||
if err := s.WriteZip(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -267,7 +488,17 @@ func TestZip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" {
|
||||
t.Fatalf("zip has %d files", len(zr.File))
|
||||
var names []string
|
||||
for _, f := range zr.File {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
want := map[string]bool{"basking/Trip- Lisbon-Porto.md": true, "basking/Packing.md": true, "basking/A quick one.md": true, "basking/A quick one 2.md": true, "basking/Journal/2026-10-31.md": true}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("zip has %v", names)
|
||||
}
|
||||
for _, n := range names {
|
||||
if !want[n] {
|
||||
t.Errorf("unexpected %q in %v", n, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user