Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
994927c704 |
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user