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

+7 -3
View File
@@ -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
+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 }
+2
View File
@@ -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))
+17 -2
View File
@@ -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();
+13 -2
View File
@@ -7,13 +7,24 @@ const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^
const tagChip = t => 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 {
+2
View File
@@ -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); }
+26 -4
View File
@@ -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;
+80
View File
@@ -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"
}
+70
View File
@@ -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), &notes)
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)
}
}