From 994927c70429a8345eafe3aa018bf46adf014b1a Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:58:29 -0700 Subject: [PATCH] Move pages to and from the vault 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 --- README.md | 10 ++- internal/store/store.go | 118 +++++++++++++++++++++++++++++++- internal/store/store_test.go | 111 ++++++++++++++++++++++++++++++ internal/web/server.go | 2 + internal/web/static/app.js | 19 ++++- internal/web/static/markdown.js | 15 +++- internal/web/static/style.css | 2 + internal/web/static/vault.js | 30 ++++++-- internal/web/vault.go | 80 ++++++++++++++++++++++ internal/web/web_test.go | 70 +++++++++++++++++++ 10 files changed, 443 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c2c756f..d5ff22a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ Named for the mako shark, the fastest in the sea, because a notebook should keep - **A vault for what needs more.** Pages in the Vault open only with a passphrase of their own, on top of signing in. The server cannot read them until you unlock it, and they are in no list, search or download of your ordinary notebook. It locks itself after 15 minutes idle, when you press Lock, and when you - sign out. See Encryption below. + sign out. See Encryption below. Press To vault on any page, journal days included, and Move out on a + vault page to bring it back. A page that links to a vaulted page shows Locked Page in its place until + you unlock the vault, and the vaulted page's title is kept only in the vault. - **Take it with you.** Download everything as a zip that opens as a vault in Obsidian: file names are titles, so the links keep working, and journal days sit in a Journal folder. @@ -200,8 +202,10 @@ file time as its date, and is then encrypted (name it with lower-case letters, n - Searching holds every page in memory, which is comfortable for tens of thousands of pages and not built for millions. - Pages are only reloaded from disk when Mako starts. -- The Vault has its own pages only: no links into or out of it, no tags, no journal, and it is not in the - zip download. You cannot change its passphrase yet. +- A page in the Vault cannot link to your ordinary pages or use their tags, and the vault is not in the zip + download. Moving a page in takes its subpages up a level but does not move them with it, and a journal day + moved in leaves that day empty, so starting it again makes a new page which cannot be moved in while + the first is there. You cannot change its passphrase yet. - Live preview works a line at a time (a code block counts as one), so a table or a quote spread over several lines shows rendered line by line. View shows the whole page as one. Selecting across lines (Ctrl+A, Shift+Up or Down, Shift+click, or dragging) opens those whole lines as raw markdown, so a diff --git a/internal/store/store.go b/internal/store/store.go index ddac9f8..00330a4 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4190414..7bf82b8 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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 } diff --git a/internal/web/server.go b/internal/web/server.go index cb53467..6814c1f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -111,6 +111,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/notes/{id}", s.api(s.updateNote)) mux.HandleFunc("DELETE /api/notes/{id}", s.api(s.deleteNote)) mux.HandleFunc("POST /api/notes/{id}/restore", s.api(s.restoreNote)) + mux.HandleFunc("POST /api/notes/{id}/vault", s.api(s.moveToVault)) mux.HandleFunc("DELETE /api/trash", s.api(s.emptyTrash)) mux.HandleFunc("POST /api/daily", s.api(s.daily)) mux.HandleFunc("GET /api/find", s.api(s.find)) @@ -127,6 +128,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/vault/notes/{id}", s.vaultAPI(s.updateNote)) mux.HandleFunc("DELETE /api/vault/notes/{id}", s.vaultAPI(s.deleteNote)) mux.HandleFunc("POST /api/vault/notes/{id}/restore", s.vaultAPI(s.restoreNote)) + mux.HandleFunc("POST /api/vault/notes/{id}/out", s.vaultAPI(s.moveFromVault)) mux.HandleFunc("DELETE /api/vault/trash", s.vaultAPI(s.emptyTrash)) mux.HandleFunc("GET /api/vault/find", s.vaultAPI(s.find)) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 56122cb..f95959e 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -47,14 +47,14 @@ async function boot() { if (e.status === 401) return showLogin(why); return $app.replaceChildren(h('div', { class: 'center' }, card('', brand(), h('p', {}, e.message), btn('Try again', 'restore', boot, 'btn')))); } - await refreshPages().catch(() => { }); + await Promise.all([refreshPages().catch(() => { }), refreshVaultNames()]); S.ready = true; buildShell(); show(); } function showLogin(why) { - S.editors.clear(); S.view = null; document.title = 'Sign in · Mako'; + S.editors.clear(); S.view = null; S.vaultNames = null; document.title = 'Sign in · Mako'; const next = location.hash.length > 2 ? location.hash : ''; $app.replaceChildren(h('main', { class: 'center' }, card('', brand(), h('h1', { class: 'login-h' }, 'Your private notebook'), @@ -233,10 +233,25 @@ function pageView({ id, date, parent = '' }) { })), date ? null : btn('Move', 'move', () => have ? movePage(note, async () => { await refreshPages(); note = await api('GET', '/api/notes/' + note.id); drawCrumbs(); }) : toast('Type something first, then you can move the page')), date ? null : btn('Subpage', 'add', act(async () => { if (!have) return toast('Type something first, then you can add a subpage'); await ed.flush(); location.hash = '#/new/' + note.id; })), + btn('To vault', 'lock', act(toVault), 'ghost'), btn('Delete', 'trash', act(trashIt), 'ghost danger')); } ed = null; + // Moving a page to the vault needs the vault to be unlocked. Links to the page elsewhere read "Locked Page". + async function toVault() { + if (!note || !note.id) return toast('Type something first, then you can move the page to the vault'); + await ed.flush(); + const v = await api('GET', '/api/vault'); + if (!v.unlocked) return toast(v.exists ? 'Unlock your vault first, then move the page.' : 'Make your vault first, then move the page.', false, { label: 'Open vault', fn: () => { location.hash = '#/vault'; } }); + const n = note; + if (!await confirmDialog({ title: 'Move this page to the vault?', body: `"${n.daily ? dayLabel(n.daily) : n.name}" will be locked away in your vault. Pages that link to it will show "Locked Page" until you unlock the vault.`, confirm: 'Move to vault', danger: false })) return; + await api('POST', `/api/notes/${n.id}/vault`); + await Promise.all([refreshPages(), refreshVaultNames()]); + toast('Moved to the vault', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/out`); await Promise.all([refreshPages(), refreshVaultNames()]); location.hash = hrefOf(n); }) }); + location.hash = date ? '#/journal' : n.parent && S.byId.has(n.parent) ? '#/p/' + n.parent : '#/'; + } + async function trashIt() { if (!note) { location.hash = '#/'; return; } await ed.flush(); diff --git a/internal/web/static/markdown.js b/internal/web/static/markdown.js index 12f0c38..8faea7c 100644 --- a/internal/web/static/markdown.js +++ b/internal/web/static/markdown.js @@ -7,13 +7,24 @@ const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(? h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t); const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label); +// A link to a page that was moved to the vault reads [[locked:id]]. It says "Locked Page" until the vault is +// unlocked, and then shows the page's own name and opens it. +function lockedLink(id) { + const name = S.vaultNames?.get(id) ?? (S.vaultNames && id.startsWith('daily-') ? dayLabel(id.slice(6)) : null); + if (name != null) return h('a', { class: 'wikilink in-vault', href: '#/vault/' + id }, icon('lock', 16), name); + return h('a', { class: 'wikilink locked', href: '#/vault', title: 'This page is in your vault. Unlock the vault to open it.' }, icon('lock', 16), 'Locked Page'); +} + function wikiLink(target, text) { - const title = target.trim(), page = pageByTitle(title); + const title = target.trim(); + if (/^locked:[a-z0-9-]+$/.test(title)) return lockedLink(title.slice(7)); + const page = pageByTitle(title); if (page) return h('a', { class: 'wikilink', href: '#/p/' + page.id }, text || title); return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title); } // With plain set (the vault) [[links]] and #tags stay as typed, so a page there never points into the other notebook. +// Only a link to another page in the vault, which is [[locked:id]], is shown as a link. function inline(text, plain) { const re = new RegExp(INLINE_SRC, 'gu'), out = []; let last = 0, m; @@ -23,7 +34,7 @@ function inline(text, plain) { if (m[1] != null) out.push(h('code', {}, m[1])); else if (m[2] != null) out.push(h('strong', {}, inline(m[2], plain))); else if (m[3] != null) out.push(h('em', {}, inline(m[3], plain))); - else if (m[4] != null) out.push(plain ? m[0] : wikiLink(m[4], m[5])); + else if (m[4] != null) out.push(plain && !m[4].trim().startsWith('locked:') ? m[0] : wikiLink(m[4], m[5])); else if (m[6] != null) out.push(extLink(m[7], m[6])); else if (m[8] != null) out.push(extLink(m[8], m[8])); else { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 8a805f3..c50abb3 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -347,3 +347,5 @@ .vault-row:hover { background:var(--accent-soft); } .vault-name { display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; color:var(--text); text-decoration:none; } .clip1 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .wikilink .icon { vertical-align:-2px; margin-right:var(--s1); } + .wikilink.locked { color:var(--text-muted); border-bottom-style:dotted; border-bottom-color:var(--text-faint); } diff --git a/internal/web/static/vault.js b/internal/web/static/vault.js index 9a57839..d398953 100644 --- a/internal/web/static/vault.js +++ b/internal/web/static/vault.js @@ -6,6 +6,17 @@ // or download, and they cannot link to it. const VAULT = '/api/vault'; +// S.vaultNames maps the id of each vault page to its name while the vault is unlocked, so that a +// [[locked:id]] link elsewhere can say what it points at. It is null while the vault is locked. +async function refreshVaultNames() { + try { + const v = await api('GET', VAULT); + if (!v.unlocked) { S.vaultNames = null; return; } + const r = await api('GET', VAULT + '/pages'); + S.vaultNames = new Map(r.pages.map(p => [p.id, p.name])); + } catch { S.vaultNames = null; } +} + function vaultView(arg) { const root = h('div', { class: 'page vault' }); let ed = null, dead = false; @@ -14,6 +25,7 @@ function vaultView(arg) { const lock = async () => { await flushAll(); await api('POST', VAULT + '/lock'); + S.vaultNames = null; location.hash = '#/vault'; show(); }; @@ -115,9 +127,9 @@ function vaultView(arg) { h('a', { href: '#/vault' }, 'Vault')); const meta = h('p', { class: 'sub page-meta' }); ed = pageEditor({ - note, vault: true, - onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); }, - onSaved(c) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; }, + note, vault: true, fixedTitle: note?.daily ? note.title : undefined, // a journal day is named by its date + onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); refreshVaultNames(); }, + onSaved(c, { renamed }) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; if (renamed) refreshVaultNames(); }, }); ed.onMode = () => draw(); view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read'); @@ -126,6 +138,15 @@ function vaultView(arg) { [['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) => h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label))); fill(actions, ed.status, h('span', { class: 'grow' }), seg, btn('Lock', 'lock', act(lock)), + btn('Move out', 'move', act(async () => { + if (!note || !note.id) return toast('Type something first, then you can move the page out'); + await ed.flush(); + const n = note; + await api('POST', `${VAULT}/notes/${n.id}/out`); + await Promise.all([refreshPages(), refreshVaultNames()]); + toast('Moved out of the vault', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/vault`); await Promise.all([refreshPages(), refreshVaultNames()]); location.hash = '#/vault/' + n.id; }) }); + location.hash = hrefOf(n); + })), btn('Delete', 'trash', act(async () => { if (!note) { location.hash = '#/vault'; return; } await ed.flush(); @@ -152,7 +173,8 @@ function vaultView(arg) { const v = await api('GET', VAULT); if (dead) return; if (!v.exists) return create(); - if (!v.unlocked) return unlock(); + if (!v.unlocked) { S.vaultNames = null; return unlock(); } + refreshVaultNames(); return arg ? page(arg) : list(false); }); return view; diff --git a/internal/web/vault.go b/internal/web/vault.go index 4d23a30..2df8b4e 100644 --- a/internal/web/vault.go +++ b/internal/web/vault.go @@ -289,3 +289,83 @@ func (s *Server) vaultLock(w http.ResponseWriter, r *http.Request, p principal) s.lockVault(w, r) writeJSON(w, map[string]bool{"exists": true, "unlocked": false}) } + +// moveToVault takes a page out of the ordinary notebook and puts it in the vault, which must be unlocked. +// Links to it elsewhere become [[locked:id]], so the page's title stays in the vault only. +func (s *Server) moveToVault(w http.ResponseWriter, r *http.Request, p principal) { + c := s.cfg.Get() + _, vst, ok := s.openVault(c, r, p.Sub) + if !ok { + failCode(w, http.StatusLocked, "unlock the vault first", "locked") + return + } + id := r.PathValue("id") + n, err := p.st.Get(id) + if err == nil && n.Trashed { + err = store.ErrNotFound + } + if err != nil { + storeFail(w, err) + return + } + if _, err := vst.Import(n); err != nil { + if errors.Is(err, store.ErrExists) { + failCode(w, http.StatusConflict, existsMessage(n, "your vault"), "exists") + return + } + storeFail(w, err) + return + } + if _, err := p.st.Detach(id); err != nil { + vst.Discard(id) // the page stays where it was + storeFail(w, err) + return + } + writeJSON(w, map[string]string{"id": id}) +} + +// moveFromVault brings a page back out of the vault into the ordinary notebook and points the +// [[locked:id]] links to it at its title again. +func (s *Server) moveFromVault(w http.ResponseWriter, r *http.Request, p principal) { + main, err := s.notebook(s.cfg.Get(), p.Sub, p.Name, p.Email) + if err != nil { + fail(w, http.StatusInternalServerError, "could not open your notes; the server's log says why") + return + } + id := r.PathValue("id") + n, err := p.st.Get(id) + if err == nil && n.Trashed { + err = store.ErrNotFound + } + if err != nil { + storeFail(w, err) + return + } + in, err := main.Import(n) + if err != nil { + if errors.Is(err, store.ErrExists) { + failCode(w, http.StatusConflict, existsMessage(n, "your notebook"), "exists") + return + } + storeFail(w, err) + return + } + if err := main.Reveal(id, in.Title); err != nil { + main.Discard(id) + storeFail(w, err) + return + } + if err := p.st.Discard(id); err != nil { + log.Printf("vault: moved %s out but could not remove it from the vault: %v", id, err) + fail(w, http.StatusInternalServerError, "the page is back in your notebook, but a copy is still in the vault; the server's log says why") + return + } + writeJSON(w, map[string]string{"id": id}) +} + +func existsMessage(n store.Note, where string) string { + if n.Daily != "" { + return where + " already has a journal page for that day" + } + return where + " already has this page" +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a1f9f74..8021116 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -834,3 +834,73 @@ func TestVaultNeedsSignInAndHeader(t *testing.T) { t.Fatalf("no header: %d", w.Code) } } + +func TestMovePagesIntoAndOutOfTheVault(t *testing.T) { + e := withVault(t) + a := e.signIn(alice) + var secret, notes, day struct{ ID, Body, Title string } + jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Secret plan", "body": "the plan"}, a), &secret) + jsonOf(t, e.do("POST", "/api/notes", map[string]string{"title": "Notes", "body": "see [[Secret plan]] and [[secret plan|it]]"}, a), ¬es) + jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, a), &day) + e.do("PUT", "/api/notes/"+day.ID, map[string]string{"body": "dear diary"}, a) + + // there is nowhere to put it until the vault is open + if w := e.do("POST", "/api/notes/"+secret.ID+"/vault", nil, a); w.Code != http.StatusLocked { + t.Fatalf("moving with no vault: %d", w.Code) + } + w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a) + open := vaultCookies(a, w) + + for _, id := range []string{secret.ID, day.ID} { + if w := e.do("POST", "/api/notes/"+id+"/vault", nil, open); w.Code != http.StatusOK { + t.Fatalf("move %s in: %d %s", id, w.Code, w.Body) + } + } + if w := e.do("GET", "/api/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound { + t.Fatalf("the page is still in the notebook: %d", w.Code) + } + var in struct{ ID, Title, Body, Daily string } + jsonOf(t, e.do("GET", "/api/vault/notes/"+secret.ID, nil, open), &in) + if in.ID != secret.ID || in.Title != "Secret plan" || in.Body != "the plan" { + t.Fatalf("in the vault: %+v", in) + } + jsonOf(t, e.do("GET", "/api/vault/notes/"+day.ID, nil, open), &in) + if in.Daily != "2026-10-31" || in.Body != "dear diary" { + t.Fatalf("the day in the vault: %+v", in) + } + + // the page that linked to it now shows a locked link and says nothing of the title + var n struct{ Body string } + jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n) + if want := "see [[locked:" + secret.ID + "]] and [[locked:" + secret.ID + "]]"; n.Body != want { + t.Fatalf("links: %q want %q", n.Body, want) + } + for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=Secret", "/api/find?q=diary"} { + if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "Secret plan") || strings.Contains(w.Body.String(), "diary") { + t.Fatalf("%s still shows the moved pages: %s", path, w.Body) + } + } + + // the same day cannot be moved in twice + var again struct{ ID string } + jsonOf(t, e.do("POST", "/api/daily", map[string]string{"date": "2026-10-31"}, open), &again) + if w := e.do("POST", "/api/notes/"+again.ID+"/vault", nil, open); w.Code != http.StatusConflict { + t.Fatalf("a second journal page for the day: %d %s", w.Code, w.Body) + } + + // and back out: the links name the page again + if w := e.do("POST", "/api/vault/notes/"+secret.ID+"/out", nil, open); w.Code != http.StatusOK { + t.Fatalf("move out: %d %s", w.Code, w.Body) + } + if w := e.do("GET", "/api/vault/notes/"+secret.ID, nil, open); w.Code != http.StatusNotFound { + t.Fatalf("the page is still in the vault: %d", w.Code) + } + jsonOf(t, e.do("GET", "/api/notes/"+secret.ID, nil, open), &in) + if in.Title != "Secret plan" || in.Body != "the plan" { + t.Fatalf("back in the notebook: %+v", in) + } + jsonOf(t, e.do("GET", "/api/notes/"+notes.ID, nil, open), &n) + if n.Body != "see [[Secret plan]] and [[Secret plan]]" { + t.Fatalf("links after coming back: %q", n.Body) + } +}