Move pages to and from the vault
CI / test (push) Successful in 2m12s
Release / release (push) Successful in 3m42s

A page, including a journal day, can be moved into the unlocked vault with its id kept. Links to it elsewhere become [[locked:id]], shown as Locked Page until the vault is unlocked, so its title is kept only in the vault. Move out reverses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 01:58:29 -07:00
1 parent c08a8b8718
commit 994927c704
10 files changed
+443 -14

No files matched your search

+115 -3
View File
@@ -591,7 +591,7 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
if m.ID == except || m.Trashed || !slices.Contains(m.links, old) {
continue
}
body := rewriteLinks(m.Body, old, newTitle)
body := rewriteLinks(m.Body, old, newTitle, true)
if body == m.Body {
continue
}
@@ -606,7 +606,8 @@ func (s *Store) relink(oldTitle, newTitle, except string) error {
return nil
}
func rewriteLinks(body, old, newTitle string) string {
// rewriteLinks points the [[old]] links in body at newTitle, keeping their |text unless keepAlias is false.
func rewriteLinks(body, old, newTitle string, keepAlias bool) string {
lines := strings.Split(body, "\n")
fence := false
for i, l := range lines {
@@ -623,7 +624,7 @@ func rewriteLinks(body, old, newTitle string) string {
return x
}
alias := ""
if k := strings.Index(x, "|"); k >= 0 {
if k := strings.Index(x, "|"); keepAlias && k >= 0 {
alias = x[k : len(x)-2]
}
return "[[" + newTitle + alias + "]]"
@@ -1076,3 +1077,114 @@ func exportFormat(n *Note) []byte {
b.WriteString("\n")
return []byte(b.String())
}
// ---- moving pages between notebooks (the vault) ----
// LockedPrefix starts the target of a [[link]] to a page that has been moved to the vault, so that the
// ordinary notebook keeps the page's id and never its title.
const LockedPrefix = "locked:"
var ErrExists = errors.New("there is already a page with that id")
// Import adds a page that was made in another notebook, keeping its id, dates and text. It is placed at
// the top level, its title is changed if another page has it, and it is written before anything else
// is touched, so a failure leaves the page where it came from.
func (s *Store) Import(n Note) (Note, error) {
if !ValidID(n.ID) {
return Note{}, ErrBadID
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.by[n.ID]; ok {
return Note{}, ErrExists
}
in := n.copy()
in.Parent, in.Trashed = "", false
if in.Daily != "" {
in.Title = in.Daily
} else {
in.Title = s.free(in.Title, in.ID)
}
in.refresh()
if err := s.write(&in); err != nil {
return Note{}, err
}
s.by[in.ID] = &in
return in.copy(), nil
}
// Discard removes a page with no trash and no questions. It undoes an Import that cannot be completed.
func (s *Store) Discard(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok {
return ErrNotFound
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
delete(s.by, id)
return nil
}
// Detach takes a page out of this notebook to be kept somewhere else. Pages inside it move up a level,
// and every [[link]] to it, even in the trash, becomes [[locked:id]], so its title is left nowhere.
func (s *Store) Detach(id string) (Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, ok := s.by[id]
if !ok || n.Trashed {
return Note{}, ErrNotFound
}
gone := n.copy()
for _, c := range s.by {
if c.Parent == id {
next := c.copy()
next.Parent = n.Parent
if err := s.write(&next); err != nil {
return Note{}, err
}
*c = next
}
}
if err := s.relinkAll(strings.ToLower(n.Title), LockedPrefix+id, false); err != nil {
return Note{}, err
}
if err := os.Remove(s.path(n)); err != nil && !errors.Is(err, os.ErrNotExist) {
return Note{}, err
}
delete(s.by, id)
return gone, nil
}
// Reveal points the [[locked:id]] links of a page that has come back at its title again.
func (s *Store) Reveal(id, title string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.relinkAll(LockedPrefix+id, title, true)
}
// relinkAll rewrites the links to old (lower-cased) in every page, trashed or not.
func (s *Store) relinkAll(old, newTarget string, keepAlias bool) error {
if old == "" || newTarget == "" {
return nil
}
for _, m := range s.by {
if !slices.Contains(m.links, old) {
continue
}
body := rewriteLinks(m.Body, old, newTarget, keepAlias)
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
}
+111
View File
@@ -576,3 +576,114 @@ func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
t.Fatal("opened with no key")
}
}
func openAt(t *testing.T, dir string, box *vault.Cipher) *Store {
t.Helper()
s, err := Open(dir, box)
if err != nil {
t.Fatal(err)
}
return s
}
func TestDetachRewritesLinksAndImportKeepsThePage(t *testing.T) {
main, mdir := open(t)
other := openAt(t, t.TempDir(), testBox(t))
secret := mk(t, main, "Secret", "the plan #hush")
kid, _ := main.Create(NewNote{Title: "Kid", Parent: secret.ID})
b := mk(t, main, "Notes", "see [[Secret]] and [[secret|the plan]]\n`[[Secret]]` in code\n```\n[[Secret]]\n```")
gone := mk(t, main, "Old", "also [[Secret]]")
if _, err := main.Trash(gone.ID); err != nil {
t.Fatal(err)
}
got, err := other.Import(secret)
if err != nil || got.ID != secret.ID || got.Title != "Secret" || !got.Updated.Equal(secret.Updated) || got.Body != "the plan #hush" {
t.Fatalf("import: %+v %v", got, err)
}
if _, err := other.Import(secret); !errors.Is(err, ErrExists) {
t.Fatalf("a second import: %v", err)
}
if _, err := main.Detach(secret.ID); err != nil {
t.Fatal(err)
}
if _, err := main.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("the page is still in the notebook: %v", err)
}
nb, _ := main.Get(b.ID)
link := "[[" + LockedPrefix + secret.ID + "]]"
want := "see " + link + " and " + link + "\n`" + link + "` in code\n```\n[[Secret]]\n```" // only a fenced block is left as typed
if nb.Body != want {
t.Fatalf("links:\n%q\nwant\n%q", nb.Body, want)
}
if og, _ := main.Get(gone.ID); og.Body != "also "+link {
t.Fatalf("a link in the trash was left alone: %q", og.Body)
}
if k, _ := main.Get(kid.ID); k.Parent != "" {
t.Fatalf("a subpage still points at the moved page: %q", k.Parent)
}
// and it is the same after a reload, with nothing of the title in the page that linked to it
again := openAt(t, mdir, testBox(t))
if n2, _ := again.Get(b.ID); n2.Body != want {
t.Fatalf("after reload: %q", n2.Body)
}
if _, err := again.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("the page came back from disk: %v", err)
}
// coming back: the title is free again, and the links name it
back, err := main.Import(got)
if err != nil {
t.Fatal(err)
}
if err := main.Reveal(back.ID, back.Title); err != nil {
t.Fatal(err)
}
if nb, _ := main.Get(b.ID); !strings.Contains(nb.Body, "see [[Secret]] and [[Secret]]") {
t.Fatalf("links after coming back: %q", nb.Body)
}
if err := other.Discard(secret.ID); err != nil {
t.Fatal(err)
}
if _, err := other.Get(secret.ID); !errors.Is(err, ErrNotFound) {
t.Fatal("discard left the page")
}
}
func TestMovingAJournalDayAndATitleThatIsTaken(t *testing.T) {
main, _ := open(t)
other := openAt(t, t.TempDir(), testBox(t))
day, err := main.Daily("2026-10-31")
if err != nil {
t.Fatal(err)
}
day, _ = main.Update(day.ID, Change{Body: ptr("dear diary")}, time.Time{})
ref := mk(t, main, "Index", "that day: [[2026-10-31]]")
got, err := other.Import(day)
if err != nil || got.Daily != "2026-10-31" || got.Title != "2026-10-31" || got.Body != "dear diary" {
t.Fatalf("import day: %+v %v", got, err)
}
if _, err := main.Detach(day.ID); err != nil {
t.Fatal(err)
}
if n, _ := main.Get(ref.ID); n.Body != "that day: [["+LockedPrefix+"daily-2026-10-31]]" {
t.Fatalf("the link to the day: %q", n.Body)
}
again, _ := main.Daily("2026-10-31") // the day can be started afresh
if _, err := other.Import(again); !errors.Is(err, ErrExists) {
t.Fatalf("two pages for one day in the vault: %v", err)
}
other.Create(NewNote{Title: "Plans"})
p := mk(t, main, "Plans", "mine")
got, err = other.Import(p)
if err != nil || got.Title != "Plans 2" {
t.Fatalf("a taken title: %+v %v", got, err)
}
}
func ptr[T any](v T) *T { return &v }