Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search, pin and trash with undo, autosave with conflict warnings, API tokens, zip export, and the Lightwell dashboard. Includes install.sh, CI and release workflows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b548faa258
23 files changed
+4026
No files matched your search
@@ -0,0 +1,543 @@
|
||||
// Package store keeps notes as plain markdown files, one per note, so they stay readable and
|
||||
// portable with or without Basking. An in-memory index makes listing and searching quick.
|
||||
package store
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxBody is the longest note, in bytes.
|
||||
const MaxBody = 256 << 10
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("note not found")
|
||||
ErrBadID = errors.New("bad note id")
|
||||
ErrEmpty = errors.New("a note needs some text")
|
||||
ErrTooLong = errors.New("that note is too long")
|
||||
ErrConflict = errors.New("this note changed somewhere else")
|
||||
ErrBadDate = errors.New("the date must look like 2026-10-31")
|
||||
ErrNotTrash = errors.New("only a note in the trash can be deleted for good")
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Daily string `json:"daily,omitempty"` // the date, for a daily note
|
||||
Trashed bool `json:"trashed,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
lower string // lower-cased body, for searching
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
by map[string]*Note
|
||||
}
|
||||
|
||||
var idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
// ValidID says whether s can be used as a note id (and so as a file name).
|
||||
func ValidID(s string) bool { return idRe.MatchString(s) }
|
||||
|
||||
// Open reads every note under dir, creating the folders if needed.
|
||||
func Open(dir string) (*Store, error) {
|
||||
s := &Store{dir: dir, by: map[string]*Note{}}
|
||||
for _, sub := range []string{"notes", "trash"} {
|
||||
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
id := strings.TrimSuffix(e.Name(), ".md")
|
||||
if e.IsDir() || id == e.Name() || !ValidID(id) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, sub, e.Name())
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := parse(id, string(b), info.ModTime())
|
||||
n.Trashed = sub == "trash"
|
||||
s.by[id] = n
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) path(n *Note) string {
|
||||
sub := "notes"
|
||||
if n.Trashed {
|
||||
sub = "trash"
|
||||
}
|
||||
return filepath.Join(s.dir, sub, n.ID+".md")
|
||||
}
|
||||
|
||||
// ---- file format ----
|
||||
//
|
||||
// ---
|
||||
// created: 2026-10-31T14:32:05.123Z
|
||||
// updated: 2026-10-31T14:35:00.456Z
|
||||
// pinned: true
|
||||
// daily: 2026-10-31
|
||||
// ---
|
||||
//
|
||||
// the note, in markdown
|
||||
|
||||
func format(n *Note) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
fmt.Fprintf(&b, "created: %s\n", n.Created.UTC().Format(time.RFC3339Nano))
|
||||
fmt.Fprintf(&b, "updated: %s\n", n.Updated.UTC().Format(time.RFC3339Nano))
|
||||
if n.Pinned {
|
||||
b.WriteString("pinned: true\n")
|
||||
}
|
||||
if n.Daily != "" {
|
||||
fmt.Fprintf(&b, "daily: %s\n", n.Daily)
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(n.Body)
|
||||
b.WriteString("\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// parse reads a note file. A file with no header is still a note: it takes its time from the file.
|
||||
func parse(id, text string, mod time.Time) *Note {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
n := &Note{ID: id, Created: mod.UTC(), Updated: mod.UTC()}
|
||||
if rest, ok := strings.CutPrefix(text, "---\n"); ok {
|
||||
if head, body, ok := strings.Cut(rest, "\n---\n"); ok || strings.HasSuffix(rest, "\n---") {
|
||||
if !ok {
|
||||
head, body = strings.TrimSuffix(rest, "\n---"), ""
|
||||
}
|
||||
for _, line := range strings.Split(head, "\n") {
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
v = strings.TrimSpace(v)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "created":
|
||||
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||||
n.Created = t.UTC()
|
||||
}
|
||||
case "updated":
|
||||
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||||
n.Updated = t.UTC()
|
||||
}
|
||||
case "pinned":
|
||||
n.Pinned = v == "true"
|
||||
case "daily":
|
||||
if _, err := time.Parse("2006-01-02", v); err == nil {
|
||||
n.Daily = v
|
||||
}
|
||||
}
|
||||
}
|
||||
text = strings.TrimPrefix(body, "\n")
|
||||
}
|
||||
}
|
||||
n.set(text)
|
||||
return n
|
||||
}
|
||||
|
||||
// set stores the body (without trailing blank space) and works out the tags.
|
||||
func (n *Note) set(body string) {
|
||||
n.Body = strings.TrimRight(strings.ReplaceAll(body, "\r\n", "\n"), " \t\n")
|
||||
n.lower = strings.ToLower(n.Body)
|
||||
n.Tags = Tags(n.Body)
|
||||
}
|
||||
|
||||
var (
|
||||
tagRe = regexp.MustCompile(`(?:^|[\s(])#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)`)
|
||||
inlineCodeRe = regexp.MustCompile("`[^`\n]*`")
|
||||
)
|
||||
|
||||
// Tags returns the #tags in a note, lower-cased and without repeats. Code is skipped, and so are
|
||||
// numbers such as #12.
|
||||
func Tags(body string) []string {
|
||||
var lines []string
|
||||
fence := false
|
||||
for _, l := range strings.Split(body, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "```") {
|
||||
fence = !fence
|
||||
continue
|
||||
}
|
||||
if !fence {
|
||||
lines = append(lines, inlineCodeRe.ReplaceAllString(l, ""))
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, m := range tagRe.FindAllStringSubmatch(strings.Join(lines, "\n"), -1) {
|
||||
t := strings.ToLower(strings.TrimRight(m[1], "/-"))
|
||||
if t == "" || len(t) > 40 || isNumber(t) || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isNumber(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Store) write(n *Note) error {
|
||||
path := s.path(n)
|
||||
tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp")
|
||||
if err := os.WriteFile(tmp, format(n), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func newID(t time.Time) string {
|
||||
b := make([]byte, 3)
|
||||
rand.Read(b)
|
||||
return t.UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (n *Note) copy() Note {
|
||||
c := *n
|
||||
c.Tags = append([]string{}, n.Tags...)
|
||||
return c
|
||||
}
|
||||
|
||||
func checkBody(body string) error {
|
||||
if len(body) > MaxBody {
|
||||
return ErrTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- changes ----
|
||||
|
||||
// Create saves a new note.
|
||||
func (s *Store) Create(body string) (Note, error) {
|
||||
if err := checkBody(body); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return Note{}, ErrEmpty
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
n := &Note{ID: newID(now), Created: now, Updated: now}
|
||||
n.set(body)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.write(n); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
s.by[n.ID] = n
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
// Daily returns the note for a date (like 2026-10-31), making it if it is not there yet.
|
||||
func (s *Store) Daily(date string) (Note, error) {
|
||||
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||
return Note{}, ErrBadDate
|
||||
}
|
||||
id := "daily-" + date
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if n, ok := s.by[id]; ok {
|
||||
if n.Trashed { // asking for the day brings it back
|
||||
old := s.path(n)
|
||||
n.Trashed = false
|
||||
if err := os.Rename(old, s.path(n)); err != nil {
|
||||
n.Trashed = true
|
||||
return Note{}, err
|
||||
}
|
||||
}
|
||||
return n.copy(), nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
n := &Note{ID: id, Created: now, Updated: now, Daily: date}
|
||||
if err := s.write(n); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
s.by[id] = n
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (Note, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n, ok := s.by[id]
|
||||
if !ok {
|
||||
return Note{}, ErrNotFound
|
||||
}
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
// Update changes the text and/or the pin. When base is given, the text is only replaced if the
|
||||
// note has not been changed since then; otherwise the current note comes back with ErrConflict.
|
||||
func (s *Store) Update(id string, body *string, pinned *bool, base time.Time) (Note, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n, ok := s.by[id]
|
||||
if !ok {
|
||||
return Note{}, ErrNotFound
|
||||
}
|
||||
if body != nil {
|
||||
if err := checkBody(*body); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
if !base.IsZero() && !n.Updated.Equal(base) {
|
||||
return n.copy(), ErrConflict
|
||||
}
|
||||
}
|
||||
next := n.copy()
|
||||
if body != nil {
|
||||
next.set(*body)
|
||||
next.Updated = time.Now().UTC()
|
||||
if !next.Updated.After(n.Updated) { // keep updated moving forward, so a stale tab is always noticed
|
||||
next.Updated = n.Updated.Add(time.Microsecond)
|
||||
}
|
||||
}
|
||||
if pinned != nil {
|
||||
next.Pinned = *pinned
|
||||
}
|
||||
if err := s.write(&next); err != nil {
|
||||
return Note{}, err
|
||||
}
|
||||
*n = next
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
func (s *Store) move(id string, toTrash bool) (Note, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n, ok := s.by[id]
|
||||
if !ok {
|
||||
return Note{}, ErrNotFound
|
||||
}
|
||||
if n.Trashed == toTrash {
|
||||
return n.copy(), nil
|
||||
}
|
||||
old := s.path(n)
|
||||
n.Trashed = toTrash
|
||||
if err := os.Rename(old, s.path(n)); err != nil {
|
||||
n.Trashed = !toTrash
|
||||
return Note{}, err
|
||||
}
|
||||
return n.copy(), nil
|
||||
}
|
||||
|
||||
// Trash moves a note to the trash. Restore brings it back.
|
||||
func (s *Store) Trash(id string) (Note, error) { return s.move(id, true) }
|
||||
func (s *Store) Restore(id string) (Note, error) { return s.move(id, false) }
|
||||
|
||||
// Purge deletes a note that is already in the trash, for good.
|
||||
func (s *Store) Purge(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n, ok := s.by[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if !n.Trashed {
|
||||
return ErrNotTrash
|
||||
}
|
||||
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
delete(s.by, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyTrash deletes everything in the trash for good and says how many notes that was.
|
||||
func (s *Store) EmptyTrash() (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
count := 0
|
||||
for id, n := range s.by {
|
||||
if !n.Trashed {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return count, err
|
||||
}
|
||||
delete(s.by, id)
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ---- reading ----
|
||||
|
||||
type Query struct {
|
||||
Text string // words that must all appear; #tag words must be tags
|
||||
Tag string
|
||||
View string // "" or "all", "pinned", "daily", "trash"
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// List returns one page of matching notes, newest first, and how many matched in all.
|
||||
func (s *Store) List(q Query) ([]Note, int) {
|
||||
var words, tags []string
|
||||
for _, w := range strings.Fields(strings.ToLower(q.Text)) {
|
||||
if t := strings.TrimPrefix(w, "#"); t != w && t != "" {
|
||||
tags = append(tags, t)
|
||||
} else {
|
||||
words = append(words, w)
|
||||
}
|
||||
}
|
||||
if t := strings.ToLower(strings.TrimPrefix(q.Tag, "#")); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
var hits []*Note
|
||||
for _, n := range s.by {
|
||||
if n.Trashed != (q.View == "trash") || (q.View == "pinned" && !n.Pinned) || (q.View == "daily" && n.Daily == "") {
|
||||
continue
|
||||
}
|
||||
if matches(n, words, tags) {
|
||||
hits = append(hits, n)
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
a, b := hits[i], hits[j]
|
||||
if q.View == "daily" && a.Daily != b.Daily {
|
||||
return a.Daily > b.Daily
|
||||
}
|
||||
if !a.Created.Equal(b.Created) {
|
||||
return a.Created.After(b.Created)
|
||||
}
|
||||
return a.ID > b.ID
|
||||
})
|
||||
total := len(hits)
|
||||
if q.Offset > 0 {
|
||||
hits = hits[min(q.Offset, len(hits)):]
|
||||
}
|
||||
if q.Limit > 0 && len(hits) > q.Limit {
|
||||
hits = hits[:q.Limit]
|
||||
}
|
||||
out := make([]Note, len(hits))
|
||||
for i, n := range hits {
|
||||
out[i] = n.copy()
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, total
|
||||
}
|
||||
|
||||
func matches(n *Note, words, tags []string) bool {
|
||||
for _, w := range words {
|
||||
if !strings.Contains(n.lower, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, want := range tags {
|
||||
found := false
|
||||
for _, have := range n.Tags {
|
||||
if have == want || strings.HasPrefix(have, want+"/") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type TagCount struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
All int `json:"all"`
|
||||
Pinned int `json:"pinned"`
|
||||
Daily int `json:"daily"`
|
||||
Trash int `json:"trash"`
|
||||
}
|
||||
|
||||
// Summary counts the notes and the tags in use (trashed notes do not count towards tags).
|
||||
func (s *Store) Summary() (Counts, []TagCount) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var c Counts
|
||||
per := map[string]int{}
|
||||
for _, n := range s.by {
|
||||
if n.Trashed {
|
||||
c.Trash++
|
||||
continue
|
||||
}
|
||||
c.All++
|
||||
if n.Pinned {
|
||||
c.Pinned++
|
||||
}
|
||||
if n.Daily != "" {
|
||||
c.Daily++
|
||||
}
|
||||
for _, t := range n.Tags {
|
||||
per[t]++
|
||||
}
|
||||
}
|
||||
tags := make([]TagCount, 0, len(per))
|
||||
for t, k := range per {
|
||||
tags = append(tags, TagCount{t, k})
|
||||
}
|
||||
sort.Slice(tags, func(i, j int) bool {
|
||||
if tags[i].Count != tags[j].Count {
|
||||
return tags[i].Count > tags[j].Count
|
||||
}
|
||||
return tags[i].Tag < tags[j].Tag
|
||||
})
|
||||
return c, tags
|
||||
}
|
||||
|
||||
// WriteZip writes every note that is not in the trash as a .md file in a zip archive.
|
||||
func (s *Store) WriteZip(w io.Writer) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
ids := make([]string, 0, len(s.by))
|
||||
for id, n := range s.by {
|
||||
if !n.Trashed {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
zw := zip.NewWriter(w)
|
||||
for _, id := range ids {
|
||||
n := s.by[id]
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: "basking/" + id + ".md", Method: zip.Deflate, Modified: n.Updated})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(format(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func open(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, dir
|
||||
}
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"buy milk #errands #Errands and #home/garden", []string{"errands", "home/garden"}},
|
||||
{"issue #12 is not a tag, nor is a#b", []string{}},
|
||||
{"# Heading is not a tag\n#idea at the start", []string{"idea"}},
|
||||
{"`#code` and\n```\n#fenced\n```\n#real", []string{"real"}},
|
||||
{"(#paren) and #trailing-", []string{"paren", "trailing"}},
|
||||
{"#ünï and #日本語", []string{"ünï", "日本語"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Tags(c.in); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("Tags(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Body != "first thought #idea\n\nwith a second paragraph" {
|
||||
t.Fatalf("body %q", n.Body)
|
||||
}
|
||||
if !ValidID(n.ID) {
|
||||
t.Fatalf("id %q", n.ID)
|
||||
}
|
||||
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)
|
||||
}
|
||||
got, err := s2.Get(n.ID)
|
||||
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"}) {
|
||||
t.Fatalf("reloaded %+v, want %+v", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsEmptyAndHuge(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) {
|
||||
t.Fatalf("huge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainMarkdownFileIsANote(t *testing.T) {
|
||||
s, 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)
|
||||
os.Chtimes(path, mod, mod)
|
||||
s2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := s2.Get("from-elsewhere")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) {
|
||||
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)
|
||||
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)
|
||||
if !errors.Is(err, ErrConflict) || cur.Body != "two" {
|
||||
t.Fatalf("stale write: %v %+v", err, cur)
|
||||
}
|
||||
yes := true
|
||||
n3, err := s.Update(n.ID, nil, &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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashRestorePurge(t *testing.T) {
|
||||
s, dir := open(t)
|
||||
n, _ := s.Create("to be thrown away")
|
||||
if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) {
|
||||
t.Fatalf("purge of a live note: %v", err)
|
||||
}
|
||||
if _, err := s.Trash(n.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list, _ := s2.List(Query{}); len(list) != 1 {
|
||||
t.Fatal("restore failed")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("empty trash: %d %v", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
s.Trash(d1.ID)
|
||||
d3, _ := s.Daily("2026-10-31")
|
||||
if d3.Trashed {
|
||||
t.Fatal("asking for a trashed day should bring it back")
|
||||
}
|
||||
s.Daily("2026-10-30")
|
||||
list, _ := s.List(Query{View: "daily"})
|
||||
if len(list) != 2 || list[0].Daily != "2026-10-31" {
|
||||
t.Fatalf("daily view order: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAndTags(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
s.Create("Buy oat MILK #errands")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
s.Create("Call the dentist #errands #health")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
s.Create("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: "milk dentist"}); len(list) != 0 {
|
||||
t.Fatalf("words in different notes: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 {
|
||||
t.Fatalf("tag filter: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Text: "#errands #health"}); len(list) != 1 {
|
||||
t.Fatalf("two tags in the query: %d", len(list))
|
||||
}
|
||||
if list, _ := s.List(Query{Tag: "projects"}); len(list) != 1 {
|
||||
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 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 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)
|
||||
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) {
|
||||
s, _ := open(t)
|
||||
a, _ := s.Create("keep me")
|
||||
b, _ := s.Create("bin me")
|
||||
s.Trash(b.ID)
|
||||
var buf bytes.Buffer
|
||||
if err := s.WriteZip(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user