// Browser-only stand-in for the Electron bridge. Same method names and shapes as preload.cjs. // Keeps notes in memory (and localStorage when available) so `npm run dev:web` is usable. import { resolveLink } from './links.js'; const KEY = 'notes-web-mock-v1'; const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i; const today = () => new Date().toISOString().slice(0, 10); const SEED = { 'Getting started': { meta: { icon: 'sparkle', cover: 'blush', tags: ['guide'], created: today() }, body: `> [!note] > This is the browser preview. In the desktop app every page is a .md file in your notes folder. ## Write the way you think - Type \`/\` on any line to add a heading, to-do, quote, toggle or code block. - Start a line with \`#\`, \`-\`, \`[]\` or \`>\` and it turns into that block as you type. - Press Ctrl K to search every note. - [x] Pick a folder for your notes - [ ] Write one page, however short ` }, Projects: { meta: { icon: 'compass', cover: 'sage', created: today() }, body: 'Things in motion.\n' }, 'Projects/Note app roadmap': { meta: { icon: 'code', cover: 'dusk', tags: ['dev', 'todo'], created: today() }, body: `An offline notes app built on Electron and SvelteKit. ### v0.1 - [x] Block editor with a slash menu - [ ] Full-text search with SQLite FTS5 \`\`\`js const id = await window.vault.create(null, 'Untitled') \`\`\` ` } }; export function createMock() { let files = null; let settings = { theme: 'dark' }; try { const saved = JSON.parse(localStorage.getItem(KEY) || 'null'); if (saved) { files = saved.files; settings = saved.settings || settings; } } catch { /* storage unavailable */ } let opened = !!files; files = files || structuredClone(SEED); const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ files, settings })); } catch { /* ignore */ } }; const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null); const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1); const pages = () => { const ids = new Set(Object.keys(files)); for (const id of [...ids]) { let p = parentOf(id); while (p) { ids.add(p); p = parentOf(p); } } return [...ids].map((id) => { const f = files[id]; return { id, parent: parentOf(id), title: titleOf(id), exists: !!f, mtime: 0, icon: f?.meta.icon || null, cover: f?.meta.cover || null, tags: f?.meta.tags || [], created: f?.meta.created || null, ...(f?.meta['ink-database'] ? { db: true } : {}), props: Object.fromEntries(Object.entries(f?.meta || {}).filter(([k]) => !['icon', 'cover', 'tags', 'created'].includes(k))) }; }); }; const snap = () => ({ path: '~/Notes', name: 'Notes', pages: pages() }); const free = (parent, base) => { for (let n = 1; ; n++) { const id = (parent ? parent + '/' : '') + (n === 1 ? base : `${base} ${n}`); if (!files[id] && !pages().some((p) => p.id === id)) return id; } }; const listeners = new Set(); const images = new Map(); const trash = []; // [{ entry, id, title, icon, pages, deletedAt, gone: { id: file } }], kept for this session return { platform: 'web', async listTemplates() { return ['Daily Note', 'Meeting']; }, async duplicate(id) { const f = files[id]; if (!f) throw new Error('That page has no file to copy.'); let next = id + ' copy'; for (let n = 2; files[next]; n++) next = `${id} copy ${n}`; files[next] = structuredClone(f); save(); return next; }, async saveImageFromUrl() { throw new Error('Adding a picture from a link needs the desktop app.'); }, async pickFile() { throw new Error('Attaching files needs the desktop app.'); }, async openFile() { throw new Error('Opening files needs the desktop app.'); }, async createMany(parent, items) { const ids = []; for (const it of items || []) ids.push(await this.create(parent, it.title, it.meta, '')); return ids; }, async createCanvas() { throw new Error('Canvases need the desktop app.'); }, async openTemplatesFolder() { return 'The templates folder needs the desktop app.'; }, async getTemplate(name) { if (name === 'Daily Note') return { meta: { tags: ['daily'] }, body: '# Daily Note\n\n' }; if (name === 'Meeting') return { meta: { tags: ['meeting'] }, body: '# Meeting\n\n' }; return null; }, async get() { return opened ? snap() : null; }, async list() { return snap(); }, async pick() { opened = true; save(); return snap(); }, async read(id) { const f = files[id]; return { id, title: titleOf(id), meta: f ? structuredClone(f.meta) : {}, body: f ? f.body : '', exists: !!f, mtime: 0 }; }, async write(id, meta, body) { files[id] = { meta: structuredClone(meta || {}), body: body || '' }; save(); return Date.now(); }, async create(parent, title, meta, body) { const id = free(parent, (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled'); files[id] = { meta: { created: today(), ...(meta || {}) }, body: body || '' }; save(); return id; }, async rename(id, title) { const clean = (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled'; if (clean === titleOf(id)) return id; const next = free(parentOf(id), clean); for (const k of Object.keys(files)) { if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; } } save(); return next; }, async move(id, parent) { if ((parent || null) === parentOf(id)) return id; if (parent && (parent === id || parent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.'); const next = free(parent || null, titleOf(id)); for (const k of Object.keys(files)) { if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; } } save(); return next; }, // No version history in the browser preview. async keepVersion() {}, async listHistory() { return []; }, async readHistory() { throw new Error('Version history needs the desktop app.'); }, async restoreHistory() { throw new Error('Version history needs the desktop app.'); }, async graph() { const all = pages(); const nodes = all.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon, parent: p.parent, tags: p.tags })); const edges = [], ghosts = new Map(), seen = new Set(); for (const [id, f] of Object.entries(files)) { for (const m of f.body.matchAll(/(!?)\[\[([^\[\]\n|#]*)/g)) { // ![[Page]] embeds count as links; ![[photo.png]] is a file, and [[#Heading]] points at its own page if ((m[1] && FILE.test(m[2].trim())) || !m[2].trim()) continue; let to = resolveLink(m[2], all); if (!to) { to = '?' + m[2].trim().toLowerCase(); ghosts.set(to, { id: to, title: m[2].trim() }); } if (to !== id && !seen.has(id + to)) { seen.add(id + to); edges.push([id, to]); } } } return { nodes, edges, ghosts: [...ghosts.values()] }; }, // A heading of page `id` was renamed: update [[Page#from]] links and ![[Page#from]] embeds elsewhere (browser preview). async renameHeading(id, from, to) { const all = pages(); const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); const safe = String(to).replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim(); const touched = []; let count = 0; if (!safe) return { pages: touched, count }; for (const [k, f] of Object.entries(files)) { if (k === id) continue; let n = 0; const body = f.body.replace(/(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g, (m, bang, ref, alias) => { const at = ref.lastIndexOf('#'); if (at < 0 || !ref.slice(0, at).replace(/#.*/, '').trim() || resolveLink(ref, all) !== id || norm(ref.slice(at + 1)) !== norm(from)) return m; n++; return `${bang}[[${ref.slice(0, at + 1)}${safe}${alias || ''}]]`; }); if (n) { f.body = body; touched.push(k); count += n; } } if (touched.length) save(); return { pages: touched, count }; }, async tasks() { const out = []; for (const [id, f] of Object.entries(files)) { if (f.meta['kanban-plugin'] || id.endsWith('.canvas')) continue; f.body.split('\n').forEach((l, line) => { const m = l.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/); if (!m) return; const due = m[2].match(/(?:📅|\bdue:)\s*(\d{4}-\d{2}-\d{2})/); const tags = new Set(f.meta.tags || []); for (const t of m[2].matchAll(/(?:^|\s)#([\w/-]+)/g)) tags.add(t[1]); out.push({ id, title: titleOf(id), icon: f.meta.icon || null, line, done: m[1] !== ' ', text: m[2].trim(), due: due ? due[1] : '', tags: [...tags], depth: 0 }); }); } return out; }, async setTask(id, line, text, done) { const rows = files[id].body.split('\n'); const m = rows[line] && rows[line].match(/^(\s*[-*+]\s+)\[[ xX]\](\s+)(.*)$/); if (!m || m[3].trim() !== text.trim()) throw new Error('That task changed. Reopen the list.'); rows[line] = m[1] + (done ? '[x]' : '[ ]') + m[2] + m[3]; files[id].body = rows.join('\n'); save(); }, async unlinked(id) { const title = titleOf(id); if (title.length < 3) return []; const all = pages(); const re = new RegExp('(? k !== id && re.test(f.body.replace(/\[\[[^\]]*\]\]/g, '')) && ![...f.body.matchAll(/\[\[([^\[\]\n|#]+)/g)].some((m) => resolveLink(m[1], all) === id)) .map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => re.test(l)) || '').trim() })); }, async linkMention(id, target) { const title = titleOf(target); const re = new RegExp('(? (m === title ? `[[${title}]]` : `[[${title}|${m}]]`)); save(); }, async openCapture() {}, async takeCaptures() { return []; }, onCapture() { return () => {}; }, async backlinks(id) { const all = pages(); return Object.entries(files) .filter(([k, f]) => k !== id && [...f.body.matchAll(/(!?)\[\[([^\[\]\n|]+)/g)].some((m) => !(m[1] && FILE.test(m[2].trim())) && resolveLink(m[2], all) === id)) .map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => l.includes('[[')) || '').replace(/[[\]]/g, '') })); }, // Images live in memory only here; the desktop app writes them next to the note. async saveImage(_id, name, bytes) { const key = `${Date.now().toString(36)}-${name}`; images.set(key, URL.createObjectURL(new Blob([bytes]))); return key; }, async pickImage(id) { const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' }); const file = await new Promise((done) => { input.onchange = () => done(input.files[0] || null); input.click(); }); return file ? this.saveImage(id, file.name, new Uint8Array(await file.arrayBuffer())) : null; }, mediaUrl: (_id, src) => images.get(src) || src, async remove(id) { const gone = {}; for (const k of Object.keys(files)) if (k === id || k.startsWith(id + '/')) { gone[k] = files[k]; delete files[k]; } const entry = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); trash.unshift({ entry, id, title: titleOf(id), icon: gone[id]?.meta.icon || null, pages: Object.keys(gone).length, deletedAt: Date.now(), gone }); save(); return entry; }, async trash() { return trash.map(({ gone, ...t }) => t); }, async restore(entry) { const t = trash.find((x) => x.entry === entry); if (!t) throw new Error('Unknown trash item.'); const next = free(parentOf(t.id), titleOf(t.id)); for (const [k, f] of Object.entries(t.gone)) files[next + k.slice(t.id.length)] = f; trash.splice(trash.indexOf(t), 1); save(); return next; }, async purge(entry) { const keep = entry ? trash.filter((x) => x.entry !== entry) : []; trash.splice(0, trash.length, ...keep); }, async search(q) { const terms = String(q).toLowerCase().match(/[\p{L}\p{N}_]+/gu) || []; if (!terms.length) return []; return Object.entries(files) .filter(([id, f]) => terms.every((t) => (id + ' ' + f.body).toLowerCase().includes(t))) .slice(0, 20) .map(([id, f]) => { const lo = f.body.toLowerCase(); const at = lo.indexOf(terms[0]); const start = Math.max(0, at - 30); const snip = at < 0 ? f.body.slice(0, 90) : (start ? '…' : '') + f.body.slice(start, at) + '\u0001' + f.body.slice(at, at + terms[0].length) + '\u0002' + f.body.slice(at + terms[0].length, at + 70); return { id, title: titleOf(id), snip }; }); }, async reveal() {}, async filePath(id) { return `~/Notes/${id}.md`; }, async saveExport(name, content) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain' })); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); return name; }, async linkPreview() { return null; }, async getSettings() { return { ...settings }; }, async setSettings(patch) { settings = { ...settings, ...patch }; save(); return settings; }, async getSpellcheck() { return { enabled: settings.spellcheck !== false, languages: [], available: [], system: true }; }, async setSpellcheck(enabled) { settings = { ...settings, spellcheck: !!enabled }; save(); return true; }, async spellWords() { return Array.isArray(settings.words) ? settings.words : []; }, async addSpellWord(word) { settings = { ...settings, words: [...new Set([...(settings.words || []), word])] }; save(); }, async removeSpellWord(word) { settings = { ...settings, words: (settings.words || []).filter((w) => w !== word) }; save(); }, async backupNow() { throw new Error('Backups need the desktop app.'); }, async backupInfo() { return { folder: '', last: 0 }; }, async pickBackupFolder() { return null; }, async revealBackups() {}, onNav(cb) { return () => {}; }, onChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }, onFlush() { return () => {}; } }; }