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,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