notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

Vault files stay the source of truth (bind-mounted read-write at /notes):
atomic writes chowned to the vault owner, hash-checked saves (409 on a
newer file), deletes to .trash. notes.* is a full-text/link index kept
current by a recursive watcher plus a 60s rescan; live updates over the
owner-only /ws/notes channel (paths and hashes only). Wikilinks, embeds,
backlinks, tags, callouts, image paste. Owner-only everywhere (404).
This commit is contained in:
2026-10-26 01:27:41 +00:00
parent 6654c0990a
commit e33afe5387
28 changed files with 44610 additions and 3 deletions

175
src/lib/notes/render.ts Normal file
View File

@@ -0,0 +1,175 @@
import { Marked, type Tokens } from 'marked';
import {
IMAGE_EXT,
baseName,
extOf,
noteName,
parseWikiInner,
splitFrontmatter,
type Resolver
} from './shared';
// Markdown preview for the notes editor (browser side). Obsidian flavoured: [[wikilinks]] and
// ![[embeds]] resolved against the vault, #tags, ==highlights==, callouts. Raw HTML in a note is
// shown as text, and links only keep http(s)/mailto/tel or vault-relative targets.
const esc = (s: string) =>
s.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!
);
export type RenderCtx = {
resolver: Resolver;
from: string;
noteHref: (path: string, heading?: string | null) => string;
fileHref: (path: string) => string;
};
/** Anchor id for a heading, also used for [[note#heading]] links. */
export const headingSlug = (s: string) =>
'h-' +
s
.replace(/&[a-z]+;|&#\d+;/g, '')
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-|-$/g, '');
const SAFE_URL = /^(https?:|mailto:|tel:|#)/i;
export function renderNote(
text: string,
ctx: RenderCtx
): { html: string; properties: Record<string, unknown> } {
const { data, body } = splitFrontmatter(text);
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
heading({ tokens, depth }: Tokens.Heading) {
const inner = this.parser.parseInline(tokens);
const id = headingSlug(inner.replace(/<[^>]+>/g, ''));
return `<h${depth} id="${esc(id)}">${inner}</h${depth}>\n`;
},
html({ text }: Tokens.HTML | Tokens.Tag) {
return esc(text);
},
link({ href, title, tokens }: Tokens.Link) {
const inner = this.parser.parseInline(tokens);
if (SAFE_URL.test(href))
return `<a href="${esc(href)}"${title ? ` title="${esc(title)}"` : ''} target="_blank" rel="noopener noreferrer">${inner}</a>`;
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return inner;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* raw */
}
const l = parseWikiInner(target, false, href);
const hit = ctx.resolver.resolve(l.target, ctx.from);
if (!hit)
return `<a class="wikilink unresolved" href="${esc(ctx.noteHref(l.target.endsWith('.md') ? l.target : `${l.target}.md`))}">${inner}</a>`;
const h = extOf(hit) === 'md' ? ctx.noteHref(hit, l.heading) : ctx.fileHref(hit);
return `<a class="wikilink" href="${esc(h)}">${inner}</a>`;
},
image({ href, title, text }: Tokens.Image) {
if (/^https?:/i.test(href))
return `<img src="${esc(href)}" alt="${esc(text)}"${title ? ` title="${esc(title)}"` : ''} loading="lazy" referrerpolicy="no-referrer">`;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* raw */
}
const hit = ctx.resolver.resolve(target, ctx.from);
if (!hit) return `<span class="embed-missing">${esc(text || target)}</span>`;
return `<img src="${esc(ctx.fileHref(hit))}" alt="${esc(text)}" loading="lazy">`;
},
blockquote({ tokens }: Tokens.Blockquote) {
const inner = this.parser.parse(tokens);
const m = /^<p>\[!([a-z-]+)\]([+-]?)\s*([^<\n]*)(?:<br>|\n)?/i.exec(inner);
if (!m) return `<blockquote>${inner}</blockquote>`;
const kind = m[1].toLowerCase();
const titleText = m[3].trim() || kind.charAt(0).toUpperCase() + kind.slice(1);
const rest = inner.slice(m[0].length);
return `<div class="callout callout-${esc(kind)}"><div class="callout-title">${titleText}</div><div class="callout-body"><p>${rest}</div></div>`;
}
},
extensions: [
{
name: 'wikilink',
level: 'inline',
start: (src: string) => {
const i = src.indexOf('[[');
if (i < 0) return undefined;
return i > 0 && src[i - 1] === '!' ? i - 1 : i;
},
tokenizer(src: string) {
const m = /^(!?)\[\[([^\]\n]+?)\]\]/.exec(src);
if (m)
return { type: 'wikilink', raw: m[0], link: parseWikiInner(m[2], m[1] === '!', m[0]) };
},
renderer(token) {
const l = token.link as ReturnType<typeof parseWikiInner>;
const hit = ctx.resolver.resolve(l.target, ctx.from);
const label =
l.alias ?? (l.target ? noteName(l.target) : '') + (l.heading ? ` › ${l.heading}` : '');
if (l.embed && hit && IMAGE_EXT.has(extOf(hit))) {
const w =
l.alias && /^\d+(x\d+)?$/.test(l.alias) ? ` width="${l.alias.split('x')[0]}"` : '';
return `<img class="embed" src="${esc(ctx.fileHref(hit))}" alt="${esc(baseName(hit))}"${w} loading="lazy">`;
}
if (l.embed && hit && extOf(hit) !== 'md')
return `<a class="embed-file" href="${esc(ctx.fileHref(hit))}" target="_blank" rel="noopener">${esc(baseName(hit))}</a>`;
if (!hit) {
const guess = l.target
? l.target.endsWith('.md')
? l.target
: `${l.target}.md`
: ctx.from;
return `<a class="wikilink unresolved" href="${esc(ctx.noteHref(guess, l.heading))}" title="not created yet">${esc(label || l.raw)}</a>`;
}
return `<a class="wikilink${l.embed ? ' embed-note' : ''}" href="${esc(ctx.noteHref(hit, l.heading))}">${l.embed ? '↳ ' : ''}${esc(label || noteName(hit))}</a>`;
}
},
{
name: 'tag',
level: 'inline',
start: (src: string) => {
const m = /(^|[\s(])#[\p{L}\p{N}_\-/]*[\p{L}_\-/]/u.exec(src);
return m ? m.index + m[1].length : undefined;
},
tokenizer(src: string) {
const m = /^#([\p{L}\p{N}_\-/]*[\p{L}_\-/][\p{L}\p{N}_\-/]*)/u.exec(src);
if (m) return { type: 'tag', raw: m[0], tag: m[1] };
},
renderer: (token) =>
`<span class="tag" data-tag="${esc(String(token.tag).toLowerCase())}">#${esc(String(token.tag))}</span>`
},
{
name: 'highlight',
level: 'inline',
start: (src: string) => {
const i = src.indexOf('==');
return i < 0 ? undefined : i;
},
tokenizer(src: string) {
const m = /^==(?=\S)([^=\n]+?)==/.exec(src);
if (m)
return {
type: 'highlight',
raw: m[0],
text: m[1],
tokens: this.lexer.inlineTokens(m[1])
};
},
renderer(token) {
return `<mark>${this.parser.parseInline(token.tokens ?? [])}</mark>`;
}
}
]
});
// Obsidian %%comments%% are hidden in reading view.
const visible = body.replace(/%%[\s\S]*?%%/g, '');
return { html: marked.parse(visible, { async: false }) as string, properties: data };
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
cleanVaultPath,
extractLinks,
extractTags,
isIgnoredPath,
kindOf,
makeResolver,
sanitizeTitle,
splitFrontmatter
} from './shared';
import { renderNote } from './render';
describe('vault paths', () => {
it('ignores Obsidian, Syncthing and our own temp files', () => {
for (const p of [
'.obsidian/app.json',
'.trash/x.md',
'.stfolder',
'.stversions/a.md',
'a/.DS_Store',
'a/.syncthing.x.md.tmp',
'a/~syncthing~x.md.tmp',
'a/.notes-tmp-123',
'a/.x.md.swp'
])
expect(isIgnoredPath(p), p).toBe(true);
expect(isIgnoredPath('Cars/Toyota/Supra.md')).toBe(false);
expect(isIgnoredPath('notes/2026-08-03.md')).toBe(false);
});
it('classifies conflict copies, attachments and notes', () => {
expect(kindOf('2026-02-26.sync-conflict-20260305-155629-4COQNSA.md')).toBe('conflict');
expect(kindOf('Cars/Pasted image 1.png')).toBe('attachment');
expect(kindOf('a/b.md')).toBe('note');
});
it('rejects traversal, absolute and hidden paths', () => {
expect(cleanVaultPath('../etc/passwd')).toBeNull();
expect(cleanVaultPath('a/../../b.md')).toBeNull();
expect(cleanVaultPath('.obsidian/app.json')).toBeNull();
expect(cleanVaultPath('a\0b.md')).toBeNull();
expect(cleanVaultPath(42)).toBeNull();
expect(cleanVaultPath('/Quick Notes/x.md')).toBe('Quick Notes/x.md');
expect(sanitizeTitle('a/b: c?')).toBe('a b c');
});
});
describe('markdown parsing', () => {
it('keeps frontmatter raw and parses properties', () => {
const text = '---\ntags: [car, toyota]\naliases:\n - Supra\ntitle: "MK4"\n---\n# Hi #inline\n';
const fm = splitFrontmatter(text);
expect(fm.raw + fm.body).toBe(text);
expect(fm.data).toEqual({ tags: ['car', 'toyota'], aliases: ['Supra'], title: 'MK4' });
expect(extractTags(fm.body, fm.data)).toEqual(['car', 'inline', 'toyota']);
});
it('skips tags in code, headings and links', () => {
expect(extractTags('# Heading\n`#nope` [[Note#Sec]] #yes/sub #123 x#no')).toEqual(['yes/sub']);
});
it('extracts wikilinks, embeds and relative markdown links', () => {
const links = extractLinks(
'see [[Folder/Note#Part|alias]], ![[img.png|300]] and [x](Other%20Note.md) [w](https://x.y)\n```\n[[code]]\n```'
);
expect(links.map((l) => [l.target, l.heading, l.alias, l.embed])).toEqual([
['Folder/Note', 'Part', 'alias', false],
['img.png', null, '300', true],
['Other Note.md', null, null, false]
]);
});
it('resolves links like Obsidian (path, same folder, shortest path)', () => {
const r = makeResolver(['A/Note.md', 'B/C/Note.md', 'B/Other.md', 'Cars/pic.png', 'Top.md']);
expect(r.resolve('Note', 'B/Other.md')).toBe('A/Note.md');
expect(r.resolve('C/Note', 'Top.md')).toBe('B/C/Note.md');
expect(r.resolve('Note', 'B/C/x.md')).toBe('B/C/Note.md');
expect(r.resolve('pic.png', 'Top.md')).toBe('Cars/pic.png');
expect(r.resolve('top', 'A/Note.md')).toBe('Top.md');
expect(r.resolve('Missing', 'Top.md')).toBeNull();
});
it('renders wikilinks and embeds, escapes raw HTML and unsafe links', () => {
const r = makeResolver(['A/Note.md', 'pic.png']);
const { html } = renderNote(
'[[Note]] ![[pic.png]] <script>x</script> [bad](javascript:alert(1)) ==hi== #tag',
{
resolver: r,
from: 'Top.md',
noteHref: (p) => `/n?note=${encodeURIComponent(p)}`,
fileHref: (p) => `/f/${p}`
}
);
expect(html).toContain('href="/n?note=A%2FNote.md"');
expect(html).toContain('src="/f/pic.png"');
expect(html).not.toContain('<script>');
expect(html).not.toContain('javascript:');
expect(html).toContain('<mark>hi</mark>');
expect(html).toContain('class="tag"');
});
});

296
src/lib/notes/shared.ts Normal file
View File

@@ -0,0 +1,296 @@
// Pure helpers for the notes tracker (/trackers/notes), shared by the server indexer and the
// browser editor: vault path rules, frontmatter, tags, [[wikilinks]] and Obsidian-style link
// resolution. The Obsidian vault on disk is the source of truth; nothing here touches it.
export type NoteKind = 'note' | 'attachment' | 'conflict';
/** One vault file as the tree, search and link resolver see it (no content). */
export type VaultEntry = {
path: string;
kind: NoteKind;
title: string;
mtime: number;
size: number;
hash: string;
tags: string[];
};
export type WikiLink = {
/** Link target as written, without heading/block or alias (may be '' for [[#heading]]). */
target: string;
heading: string | null;
alias: string | null;
embed: boolean;
raw: string;
};
export const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp']);
const CONFLICT_RE = /\.sync-conflict-\d{8}-\d{6}-[A-Z0-9]{7}/;
export const extOf = (path: string) => {
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
};
export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1);
export const folderOf = (path: string) => {
const i = path.lastIndexOf('/');
return i < 0 ? '' : path.slice(0, i);
};
/** "Folder/My note.md" → "My note". */
export const noteName = (path: string) => baseName(path).replace(/\.md$/i, '');
export const isConflictName = (path: string) => CONFLICT_RE.test(baseName(path));
/**
* Which vault files the site ignores entirely: anything in or under a dot entry (.obsidian,
* .trash, .stfolder, .stversions, .DS_Store, Syncthing's .syncthing.*.tmp and our own
* .notes-tmp-* write temps), Syncthing ~syncthing~*.tmp, editor swap files.
*/
export function isIgnoredPath(path: string): boolean {
if (!path || path.includes('\0')) return true;
for (const seg of path.split('/')) {
if (!seg || seg.startsWith('.') || seg.startsWith('~syncthing~')) return true;
}
return /\.(tmp|swp|swx)$|~$/i.test(path);
}
export function kindOf(path: string): NoteKind {
if (isConflictName(path)) return 'conflict';
return extOf(path) === 'md' ? 'note' : 'attachment';
}
/**
* Validates a vault-relative path from a client: posix, no leading slash, no `..`, no
* hidden or ignored segments. Returns the normalised path or null.
*/
export function cleanVaultPath(input: unknown): string | null {
if (typeof input !== 'string') return null;
const path = input
.normalize('NFC')
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
if (!path || path.length > 1024) return null;
const segs = path.split('/');
// eslint-disable-next-line no-control-regex -- strip control characters from paths and titles
if (segs.some((s) => !s || s === '.' || s === '..' || s.length > 255 || /[\x00-\x1f]/.test(s)))
return null;
if (isIgnoredPath(path)) return null;
return path;
}
/** A safe file name for a new note title ("a/b: c?" → "a b c"). */
export function sanitizeTitle(title: string): string {
return (
title
.normalize('NFC')
// eslint-disable-next-line no-control-regex -- strip control characters from paths and titles
.replace(/[\\/:*?"<>|#^[\]\x00-\x1f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^\.+/, '')
.slice(0, 180)
);
}
// ---------------------------------------------------------------- frontmatter
export type Frontmatter = { raw: string; data: Record<string, unknown>; body: string };
/** Splits a leading `---` YAML block off; `raw` keeps it byte-for-byte (never rewritten). */
export function splitFrontmatter(text: string): Frontmatter {
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!m) return { raw: '', data: {}, body: text };
return { raw: m[0], data: parseYamlLite(m[1]), body: text.slice(m[0].length) };
}
const unquote = (v: string) => {
const s = v.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
return s.slice(1, -1);
return s;
};
function scalar(v: string): unknown {
const s = v.trim();
if (s === '') return null;
if (s.startsWith('[') && s.endsWith(']'))
return s
.slice(1, -1)
.split(',')
.map((x) => unquote(x))
.filter(Boolean);
if (/^(true|false)$/i.test(s)) return s.toLowerCase() === 'true';
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
return unquote(s);
}
/** Top-level keys only: scalars, [inline, lists] and "- block" lists. Enough for Obsidian properties. */
export function parseYamlLite(src: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
let listKey: string | null = null;
for (const line of src.split(/\r?\n/)) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const item = /^\s+-\s*(.*)$/.exec(line) ?? (listKey ? /^-\s+(.*)$/.exec(line) : null);
if (item && listKey) {
const arr = (out[listKey] as unknown[]) ?? [];
arr.push(unquote(item[1]));
out[listKey] = arr;
continue;
}
const kv = /^([^\s:#][^:]*):(?:\s+(.*))?$/.exec(line);
if (!kv) continue;
const key = kv[1].trim();
const value = kv[2] ?? '';
if (value.trim() === '') {
out[key] = [];
listKey = key;
} else {
out[key] = scalar(value);
listKey = null;
}
}
for (const [k, v] of Object.entries(out)) if (Array.isArray(v) && !v.length) out[k] = null;
return out;
}
// ---------------------------------------------------------------- tags and links
/** Markdown with fenced and inline code blanked out (tags/links inside code don't count). */
export function stripCode(md: string): string {
return md
.replace(/(^|\n)(```|~~~)[^\n]*\n[\s\S]*?(\n\2[^\n]*(?=\n|$)|$)/g, (m) =>
m.replace(/[^\n]/g, ' ')
)
.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
}
const TAG_RE = /(^|[\s(,;])#([\p{L}\p{N}_\-/]*[\p{L}_\-/][\p{L}\p{N}_\-/]*)/gu;
export function extractTags(text: string, fm?: Record<string, unknown>): string[] {
const tags = new Set<string>();
const fmTags = fm?.tags ?? fm?.tag;
const list = Array.isArray(fmTags)
? fmTags
: typeof fmTags === 'string'
? fmTags.split(/[,\s]+/)
: [];
for (const t of list) {
const s = String(t).replace(/^#/, '').trim();
if (s) tags.add(s.toLowerCase());
}
const clean = stripCode(text).replace(/\[\[[^\]]*\]\]|\]\([^)]*\)/g, ' ');
for (const m of clean.matchAll(TAG_RE)) tags.add(m[2].replace(/\/+$/, '').toLowerCase());
tags.delete('');
return [...tags].sort();
}
export const WIKILINK_RE = /(!?)\[\[([^\]\n]+?)\]\]/g;
export function parseWikiInner(inner: string, embed: boolean, raw: string): WikiLink {
let target = inner;
let alias: string | null = null;
const pipe = target.indexOf('|');
if (pipe >= 0) {
alias = target.slice(pipe + 1).trim() || null;
target = target.slice(0, pipe);
}
let heading: string | null = null;
const hash = target.search(/[#^]/);
if (hash >= 0) {
heading = target.slice(hash + 1).trim() || null;
target = target.slice(0, hash);
}
return { target: target.trim(), heading, alias, embed, raw };
}
export function extractLinks(text: string): WikiLink[] {
const out: WikiLink[] = [];
const clean = stripCode(text);
for (const m of clean.matchAll(WIKILINK_RE)) out.push(parseWikiInner(m[2], m[1] === '!', m[0]));
// Plain markdown links to local notes: [text](Some%20Note.md)
for (const m of clean.matchAll(/(!?)\[[^\]\n]*\]\(([^)\s]+)\)/g)) {
const href = m[2];
if (/^[a-z][a-z0-9+.-]*:|^#|^\//i.test(href)) continue;
let target = href;
try {
target = decodeURIComponent(href);
} catch {
/* keep raw */
}
out.push({ ...parseWikiInner(target, m[1] === '!', m[0]) });
}
return out;
}
/** Lowercased last path segment without .md: what a link is matched on first. */
export const linkKey = (target: string) => noteName(target).toLowerCase();
/** Obsidian shows the file name; a frontmatter `title` wins. */
export function titleOf(path: string, body: string, fm?: Record<string, unknown>): string {
if (typeof fm?.title === 'string' && fm.title.trim()) return fm.title.trim().slice(0, 300);
return noteName(path);
}
// ---------------------------------------------------------------- resolution
/** Lookup tables for Obsidian-style link resolution over the whole vault. */
export type Resolver = {
resolve(target: string, from: string): string | null;
};
export function makeResolver(paths: Iterable<string>): Resolver {
const exact = new Map<string, string>();
const byName = new Map<string, string[]>();
for (const p of paths) {
const lower = p.toLowerCase();
exact.set(lower, p);
const key = extOf(p) === 'md' ? noteName(p).toLowerCase() : baseName(p).toLowerCase();
const arr = byName.get(key);
if (arr) arr.push(p);
else byName.set(key, [p]);
}
for (const arr of byName.values())
arr.sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length);
return {
resolve(target, from) {
const t = target.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
if (!t) return from;
const lower = t.toLowerCase();
const hasExt = extOf(t) !== '';
const tries = hasExt ? [lower, `${lower}.md`] : [`${lower}.md`, lower];
const dir = folderOf(from).toLowerCase();
for (const c of tries) {
const hit = exact.get(c) ?? (dir ? exact.get(`${dir}/${c}`) : undefined);
if (hit) return hit;
}
const key = hasExt && extOf(t) !== 'md' ? baseName(lower) : noteName(lower);
const cands = byName.get(key);
if (!cands?.length) return null;
if (t.includes('/')) {
const suffix = hasExt ? lower : `${lower}.md`;
const m = cands.find((c) => c.toLowerCase().endsWith(`/${suffix}`));
if (m) return m;
}
const same = cands.find((c) => folderOf(c).toLowerCase() === dir);
return same ?? cands[0];
}
};
}
// ---------------------------------------------------------------- live messages
/** WebSocket /ws/notes server → client. Never carries note content, only paths and hashes. */
export type NotesEvent =
| { t: 'hello'; files: number }
| {
t: 'change';
path: string;
kind: NoteKind;
hash: string;
mtime: number;
size: number;
title: string;
tags: string[];
created: boolean;
}
| { t: 'delete'; path: string };

View File

@@ -379,6 +379,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false,
private: false
},
{
name: 'notes',
subtitle: 'my Obsidian vault: notes, wiki and daily notes, live-synced both ways',
href: '/trackers/notes',
external: false,
private: true
},
{
name: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations',

View File

@@ -32,3 +32,4 @@ export * from './ai';
export * from './movies';
export * from './worldtodo';
export * from './osu-goals';
export * from './notes';

View File

@@ -0,0 +1,70 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
primaryKey,
text
} from 'drizzle-orm/pg-core';
import { notesSchema } from './schemas';
import { timestamptz } from './helpers';
// Notes tracker (/trackers/notes, owner-only). The Obsidian vault (markdown files on the host,
// Syncthing-synced, bind-mounted at NOTES_ROOT) is the source of truth; these tables are only
// a search and link index that src/lib/server/notes/ rebuilds from the files (file watcher plus a
// periodic rescan). Dropping and rebuilding them loses nothing.
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
export const notesFiles = notesSchema.table(
'files',
{
/** Vault-relative posix path, e.g. "Cars/Toyota/Supra.md". */
path: text('path').primaryKey(),
/** note (.md), attachment (images, pdf, canvas…) or conflict (*.sync-conflict-*, never indexed as a note). */
kind: text('kind').notNull(),
folder: text('folder').notNull(),
title: text('title').notNull(),
ext: text('ext').notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
mtime: timestamptz('mtime').notNull(),
/** sha256 of the file bytes; the editor's save-conflict check compares against it. */
hash: text('hash').notNull(),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
frontmatter: jsonb('frontmatter').$type<Record<string, unknown>>(),
/** Note text without frontmatter (notes only), for snippets. */
body: text('body'),
search: tsvector('search'),
indexedAt: timestamptz('indexed_at').notNull().defaultNow()
},
(t) => [
index('files_search_idx').using('gin', t.search),
index('files_tags_idx').using('gin', t.tags),
index('files_folder_idx').on(t.folder),
index('files_kind_idx').on(t.kind)
]
);
export const notesLinks = notesSchema.table(
'links',
{
srcPath: text('src_path')
.notNull()
.references(() => notesFiles.path, { onDelete: 'cascade', onUpdate: 'cascade' }),
seq: integer('seq').notNull(),
/** Target as written ("Folder/Note", "image.png"), without #heading or |alias. */
target: text('target').notNull(),
/** Lowercased last segment without .md; backlinks match on it, then resolve exactly. */
targetKey: text('target_key').notNull(),
embed: boolean('embed').notNull().default(false)
},
(t) => [
primaryKey({ columns: [t.srcPath, t.seq] }),
index('links_target_key_idx').on(t.targetKey)
]
);

View File

@@ -28,3 +28,4 @@ export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { VaultError } from './vault';
/** VaultError → JSON response (409 carries the current hash); anything else rethrows. */
export function vaultFail(e: unknown) {
if (e instanceof VaultError) {
if (e.status === 409) return json({ error: e.message, ...e.extra }, { status: 409 });
if (e.status === 404) error(404, 'Not Found');
return json({ error: e.message }, { status: e.status });
}
throw e;
}
export const noStore = { 'cache-control': 'private, no-store' };

View File

@@ -0,0 +1,377 @@
import { watch, type FSWatcher } from 'node:fs';
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
import { client } from '#lib/server/db/index.js';
import {
baseName,
extOf,
extractLinks,
extractTags,
folderOf,
isIgnoredPath,
kindOf,
linkKey,
makeResolver,
noteName,
splitFrontmatter,
titleOf,
type NotesEvent,
type Resolver,
type VaultEntry
} from '#lib/notes/shared.js';
import { NOTES_ROOT, readBytes, sha256, vaultAvailable, walk } from './vault';
// Keeps notes.files / notes.links (search + link index only) in step with the vault files:
// - on start: load the index from Postgres, then a full rescan (stat walk; re-reads only files
// whose mtime or size changed, drops rows for files that are gone)
// - fs.watch (recursive, inotify) for near-instant updates, debounced per path
// - a full rescan every RESCAN_MS as a safety net (missed events, a folder moved in, e.g. the
// old ~/notes merged into <vault>/notes/)
// Changes are announced to listeners (the owner-only /ws/notes channel) as path + hash only.
const RESCAN_MS = 60_000;
const DEBOUNCE_MS = 250;
const MAX_HASH_BYTES = 64 * 1024 * 1024;
const MAX_BODY = 400_000;
const entries = new Map<string, VaultEntry>();
const listeners = new Set<(e: NotesEvent) => void>();
let resolver: Resolver | null = null;
let started = false;
let ready = false;
let watcher: FSWatcher | null = null;
let queue: Promise<unknown> = Promise.resolve();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
let rescanTimer: ReturnType<typeof setTimeout> | null = null;
let rescanning: Promise<void> | null = null;
const log = (...a: unknown[]) => console.log('[notes]', ...a);
function emit(e: NotesEvent) {
for (const l of listeners) {
try {
l(e);
} catch (err) {
console.error('[notes] listener', err);
}
}
}
export function onNotesEvent(fn: (e: NotesEvent) => void) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export const isReady = () => ready;
export const allEntries = () => [...entries.values()];
export const getEntry = (path: string) => entries.get(path);
export function getResolver() {
resolver ??= makeResolver(entries.keys());
return resolver;
}
function serial<T>(job: () => Promise<T>): Promise<T> {
const run = queue.then(job, job);
queue = run.catch(() => {});
return run;
}
async function loadFromDb() {
const rows = await client<
{
path: string;
kind: VaultEntry['kind'];
title: string;
mtime: number;
size: number;
hash: string;
tags: string[];
}[]
>`select path, kind, title, round(extract(epoch from mtime) * 1000)::float8 as mtime, size::float8 as size, hash, tags from notes.files`;
for (const r of rows)
entries.set(r.path, {
path: r.path,
kind: r.kind,
title: r.title,
mtime: Number(r.mtime),
size: Number(r.size),
hash: r.hash,
tags: r.tags ?? []
});
resolver = null;
}
async function indexFile(path: string, quiet = false) {
let file;
try {
file = await readBytes(path);
} catch {
return removeFile(path, quiet);
}
const kind = kindOf(path);
const prev = entries.get(path);
const hash = file.size > MAX_HASH_BYTES ? sha256(`${file.size}:${file.mtime}`) : file.hash;
let title = kind === 'note' ? noteName(path) : baseName(path);
let tags: string[] = [];
let fm: Record<string, unknown> | null = null;
let body: string | null = null;
let links: { target: string; targetKey: string; embed: boolean }[] = [];
if (kind === 'note') {
const text = file.buf.toString('utf8');
const split = splitFrontmatter(text);
fm = Object.keys(split.data).length ? split.data : null;
title = titleOf(path, split.body, split.data);
tags = extractTags(split.body, split.data);
body = split.body.slice(0, MAX_BODY);
const seen = new Set<string>();
for (const l of extractLinks(split.body)) {
if (!l.target) continue;
const k = `${l.target}\0${l.embed}`;
if (seen.has(k)) continue;
seen.add(k);
links.push({ target: l.target.slice(0, 1000), targetKey: linkKey(l.target), embed: l.embed });
}
links = links.slice(0, 2000);
}
const searchText = kind === 'note' ? `${split(path)} ${fm ? JSON.stringify(fm) : ''}` : '';
await client.begin(async (sql) => {
await sql`
insert into notes.files (path, kind, folder, title, ext, size, mtime, hash, tags, frontmatter, body, search, indexed_at)
values (
${path}, ${kind}, ${folderOf(path)}, ${title}, ${extOf(path)}, ${file.size},
${new Date(file.mtime).toISOString()}::timestamptz, ${hash}, ${tags}::text[], ${fm ? JSON.stringify(fm) : null}::jsonb,
${body},
${
kind === 'note'
? sql`setweight(to_tsvector('simple', ${title}), 'A') || setweight(to_tsvector('simple', ${tags.join(' ') + ' ' + searchText}), 'B') || setweight(to_tsvector('simple', ${body ?? ''}), 'C')`
: null
},
now()
)
on conflict (path) do update set
kind = excluded.kind, folder = excluded.folder, title = excluded.title, ext = excluded.ext,
size = excluded.size, mtime = excluded.mtime, hash = excluded.hash, tags = excluded.tags,
frontmatter = excluded.frontmatter, body = excluded.body, search = excluded.search,
indexed_at = now()`;
await sql`delete from notes.links where src_path = ${path}`;
if (links.length)
await sql`insert into notes.links ${sql(
links.map((l, i) => ({
src_path: path,
seq: i,
target: l.target,
target_key: l.targetKey,
embed: l.embed
})),
'src_path',
'seq',
'target',
'target_key',
'embed'
)}`;
});
const entry: VaultEntry = { path, kind, title, mtime: file.mtime, size: file.size, hash, tags };
entries.set(path, entry);
if (!prev) resolver = null;
if (!quiet && (!prev || prev.hash !== hash || prev.mtime !== file.mtime))
emit({ t: 'change', ...entry, created: !prev });
}
/** Path words for search ("Cars/Toyota/Supra.md" → "Cars Toyota Supra"). */
const split = (path: string) => path.replace(/\.md$/i, '').replace(/[/_-]+/g, ' ');
async function removeFile(path: string, quiet = false) {
const had = entries.delete(path);
await client`delete from notes.files where path = ${path}`;
if (had) {
resolver = null;
if (!quiet) emit({ t: 'delete', path });
}
}
async function removePrefix(prefix: string) {
for (const p of [...entries.keys()]) if (p.startsWith(`${prefix}/`)) await removeFile(p);
}
/** Full rescan: index new/changed files, drop rows for files that are gone. */
export function rescan(quiet = false): Promise<void> {
rescanning ??= serial(async () => {
const t0 = Date.now();
const files = await walk();
const onDisk = new Set<string>();
let changed = 0;
let failed = 0;
for (const f of files) {
onDisk.add(f.path);
const e = entries.get(f.path);
if (e && e.mtime === f.mtime && e.size === f.size && e.kind === kindOf(f.path)) continue;
changed++;
try {
await indexFile(f.path, quiet);
} catch (err) {
if (!failed++)
console.error('[notes] index failed:', err instanceof Error ? err.message : err);
}
}
let removed = 0;
for (const p of [...entries.keys()])
if (!onDisk.has(p)) {
removed++;
await removeFile(p, quiet);
}
if (changed || removed || !ready)
log(
`rescan: ${files.length} files, ${changed - failed} indexed, ${failed} failed, ${removed} removed in ${Date.now() - t0} ms`
);
ready = true;
}).finally(() => {
rescanning = null;
});
return rescanning;
}
function scheduleRescan(ms = 1000) {
if (rescanTimer) clearTimeout(rescanTimer);
rescanTimer = setTimeout(() => {
rescanTimer = null;
void rescan();
}, ms);
}
async function check(path: string) {
let s;
try {
s = await stat(join(NOTES_ROOT, path));
} catch {
await removeFile(path);
await removePrefix(path);
return;
}
if (s.isDirectory()) return scheduleRescan();
if (!s.isFile()) return;
const e = entries.get(path);
if (e && e.mtime === Math.floor(s.mtimeMs) && e.size === s.size) return;
await indexFile(path);
}
/** Re-index one path now (after the site itself wrote it), so the API answers with fresh data. */
export const touch = (path: string) => serial(() => check(path));
function onFsEvent(_ev: string, name: string | Buffer | null) {
if (!name) return scheduleRescan();
const path = String(name).normalize('NFC').replace(/\\/g, '/');
if (isIgnoredPath(path)) {
// A Syncthing temp renamed into place shows up as its final name too; only folders matter.
return;
}
const t = timers.get(path);
if (t) clearTimeout(t);
timers.set(
path,
setTimeout(() => {
timers.delete(path);
void serial(() => check(path)).catch((e) => console.error('[notes] check', path, e));
}, DEBOUNCE_MS)
);
}
function startWatcher() {
try {
watcher = watch(NOTES_ROOT, { recursive: true }, onFsEvent);
watcher.on('error', (e) => {
console.error('[notes] watcher error, restarting in 5 s', e);
watcher?.close();
watcher = null;
setTimeout(startWatcher, 5000);
});
} catch (e) {
console.error('[notes] watcher failed; relying on rescans', e);
}
}
/** Called once from hooks.server.ts init(). No-op when the vault isn't mounted (dev, tests). */
export async function startNotesIndexer() {
if (started) return;
started = true;
if (!(await vaultAvailable())) {
log(`no vault at ${NOTES_ROOT}; notes tracker disabled`);
return;
}
try {
await loadFromDb();
} catch (e) {
console.error('[notes] could not load the index (migration not run?)', e);
started = false;
return;
}
startWatcher();
await rescan(true).catch((e) => console.error('[notes] initial rescan', e));
setInterval(
() => void rescan().catch((e) => console.error('[notes] rescan', e)),
RESCAN_MS
).unref?.();
}
// ---------------------------------------------------------------- queries
export type SearchHit = {
path: string;
title: string;
folder: string;
snippet: string;
tags: string[];
};
/** Full-text search (prefix match on every word, `#tag` filters), best matches first. */
export async function search(q: string, limit = 40): Promise<SearchHit[]> {
const tagFilter = [...q.matchAll(/#([\p{L}\p{N}_\-/]+)/gu)].map((m) => m[1].toLowerCase());
const rest = q.replace(/#[\p{L}\p{N}_\-/]+/gu, ' ');
const words = [...rest.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => m[0].toLowerCase()).slice(0, 12);
if (!words.length && !tagFilter.length) return [];
const tsq = words.map((w) => `${w}:*`).join(' & ');
const like = `%${rest.trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const rows = await client<SearchHit[]>`
select path, title, folder, tags,
${
words.length
? client`ts_headline('simple', coalesce(body, ''), to_tsquery('simple', ${tsq}), 'MaxFragments=1, MaxWords=22, MinWords=8, StartSel=«, StopSel=», HighlightAll=false')`
: client`left(coalesce(body, ''), 160)`
} as snippet
from notes.files
where kind = 'note'
${tagFilter.length ? client`and tags @> ${tagFilter}::text[]` : client``}
${
words.length
? client`and (search @@ to_tsquery('simple', ${tsq}) or path ilike ${like})`
: client``
}
order by
${words.length ? client`(title ilike ${like}) desc, ts_rank(search, to_tsquery('simple', ${tsq})) desc,` : client``}
mtime desc
limit ${limit}`;
return rows.map((r) => ({ ...r, snippet: r.snippet.replace(/\s+/g, ' ').trim().slice(0, 300) }));
}
/** Notes linking to `path` (wikilinks, embeds and relative markdown links), resolved exactly. */
export async function backlinks(path: string) {
const key = extOf(path) === 'md' ? noteName(path).toLowerCase() : baseName(path).toLowerCase();
const rows = await client<{ src_path: string; target: string; embed: boolean }[]>`
select src_path, target, embed from notes.links where target_key = ${key}`;
const r = getResolver();
const out = new Map<string, { path: string; title: string; embed: boolean }>();
for (const row of rows) {
if (row.src_path === path || r.resolve(row.target, row.src_path) !== path) continue;
out.set(row.src_path, {
path: row.src_path,
title: entries.get(row.src_path)?.title ?? noteName(row.src_path),
embed: row.embed
});
}
return [...out.values()].sort((a, b) => a.title.localeCompare(b.title));
}
export async function tagCounts() {
return client<{ tag: string; n: number }[]>`
select tag, count(*)::int as n from notes.files, unnest(tags) as tag
where kind = 'note' group by tag order by n desc, tag limit 300`;
}

View File

@@ -0,0 +1,69 @@
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
import { auth } from '#lib/server/auth.js';
import { client } from '#lib/server/db/index.js';
import { isOwner } from '#lib/server/owner.js';
import type { NotesEvent } from '#lib/notes/shared.js';
import { allEntries, onNotesEvent } from './indexer';
// Realtime side of the notes tracker: wss://<host>/ws/notes. Owner-only: the upgrade request's
// session cookie must belong to the owner account, otherwise a plain 404 (like the pages).
// Messages only say which file changed (path, hash, mtime, title, tags); the page then fetches
// the content over the authenticated API. Clients never send anything but pings.
type Data = { channel: string; ip: string; userId: string; sessionId: string; checked: number };
type WS = Socket<Data>;
const sockets = new Set<WS>();
const MAX_SOCKETS = 20;
const RECHECK_MS = 5 * 60_000;
async function ownerSession(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session || !isOwner({ locals: { user: session.user } as App.Locals })) return null;
return session;
}
let registered = false;
export function registerNotesChannel() {
if (registered) return;
registered = true;
onNotesEvent((e: NotesEvent) => {
const text = JSON.stringify(e);
for (const ws of sockets) ws.send(text);
});
// Sessions can be revoked (logout elsewhere): re-validate open sockets now and then.
setInterval(async () => {
for (const ws of sockets) {
if (Date.now() - ws.data.checked < RECHECK_MS) continue;
ws.data.checked = Date.now();
try {
const rows = await client`
select 1 from auth.session where id = ${ws.data.sessionId} and expires_at > now()`;
if (!rows.length) ws.close(4401, 'session ended');
} catch (e) {
console.error('[ws notes] recheck', e);
}
}
}, 60_000).unref?.();
registerChannel('notes', {
async authorize(request) {
const s = await ownerSession(request);
if (!s) return new Response('Not Found', { status: 404 });
if (sockets.size >= MAX_SOCKETS) return new Response('Too Many Requests', { status: 429 });
return { userId: s.user.id, sessionId: s.session.id, checked: Date.now() };
},
open(raw) {
const ws = raw as unknown as WS;
sockets.add(ws);
ws.send(JSON.stringify({ t: 'hello', files: allEntries().length } satisfies NotesEvent));
},
message(raw, message) {
const ws = raw as unknown as WS;
if (message !== 'ping') return void ws.close(1008, 'unexpected');
ws.send('pong');
},
close(raw) {
sockets.delete(raw as unknown as WS);
}
});
}

View File

@@ -0,0 +1,276 @@
import { createHash, randomUUID } from 'node:crypto';
import {
chown,
mkdir,
open,
readFile,
readdir,
realpath,
rename,
stat,
unlink
} from 'node:fs/promises';
import { dirname, join, resolve, sep } from 'node:path';
import { baseName, cleanVaultPath, isIgnoredPath } from '#lib/notes/shared.js';
// File access to the Obsidian vault (bind-mounted read-write at NOTES_ROOT, default /notes).
// Every path from a client goes through cleanVaultPath + inside(), so nothing outside the vault,
// and nothing hidden (.obsidian, .trash, .stversions…), can be read or written.
// Writes are atomic: a hidden temp file in the same folder (.notes-tmp-*, ignored by the indexer,
// see the Syncthing ignore note in the README) is fsynced and then renamed over the target.
// The container runs as root, so new files and folders are chowned to the vault owner (the uid
// and gid of the vault root), which keeps Syncthing and Obsidian on Stan's devices happy.
export const NOTES_ROOT = resolve(process.env.NOTES_ROOT || '/notes');
export class VaultError extends Error {
constructor(
public status: number,
message: string,
public extra?: Record<string, unknown>
) {
super(message);
}
}
export const sha256 = (buf: Uint8Array | string) => createHash('sha256').update(buf).digest('hex');
let rootReal: string | null = null;
let owner: { uid: number; gid: number } | null = null;
export async function vaultAvailable(): Promise<boolean> {
try {
const s = await stat(NOTES_ROOT);
if (!s.isDirectory()) return false;
rootReal ??= await realpath(NOTES_ROOT);
owner ??= { uid: s.uid, gid: s.gid };
return true;
} catch {
return false;
}
}
async function root() {
if (!(await vaultAvailable())) throw new VaultError(503, 'Vault not mounted');
return rootReal!;
}
/** Absolute path for a clean vault path, refusing anything that escapes (incl. via symlinks). */
async function inside(path: string): Promise<string> {
const clean = cleanVaultPath(path);
if (!clean) throw new VaultError(400, 'Bad path');
const base = await root();
const abs = resolve(base, clean);
if (!abs.startsWith(base + sep)) throw new VaultError(400, 'Bad path');
// The deepest existing ancestor must resolve inside the vault too.
let probe = dirname(abs);
for (;;) {
try {
const real = await realpath(probe);
if (real !== base && !real.startsWith(base + sep)) throw new VaultError(400, 'Bad path');
break;
} catch (e) {
if (e instanceof VaultError) throw e;
const up = dirname(probe);
if (up === probe) throw new VaultError(400, 'Bad path');
probe = up;
}
}
return abs;
}
async function fixOwner(abs: string) {
if (!owner || process.getuid?.() !== 0) return;
await chown(abs, owner.uid, owner.gid).catch(() => {});
}
async function ensureDir(absDir: string) {
const base = await root();
const missing: string[] = [];
let d = absDir;
while (d.startsWith(base + sep)) {
try {
const s = await stat(d);
if (!s.isDirectory()) throw new VaultError(409, 'A file is in the way of that folder');
break;
} catch (e) {
if (e instanceof VaultError) throw e;
missing.unshift(d);
d = dirname(d);
}
}
for (const m of missing) {
await mkdir(m).catch((e) => {
if (e?.code !== 'EEXIST') throw e;
});
await fixOwner(m);
}
}
export type FileStat = { path: string; size: number; mtime: number };
/** Every non-ignored file in the vault (stat only), for the indexer's rescans. */
export async function walk(): Promise<FileStat[]> {
const base = await root();
const out: FileStat[] = [];
async function go(rel: string) {
let entries;
try {
entries = await readdir(rel ? join(base, rel) : base, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const name = e.name.normalize('NFC');
const p = rel ? `${rel}/${name}` : name;
if (isIgnoredPath(p)) continue;
if (e.isDirectory()) await go(p);
else if (e.isFile()) {
try {
const s = await stat(join(base, rel, e.name));
out.push({ path: p, size: s.size, mtime: Math.floor(s.mtimeMs) });
} catch {
/* vanished mid-walk */
}
}
}
}
await go('');
return out;
}
/** Folders (for the new-note picker), sorted. */
export async function folders(): Promise<string[]> {
const base = await root();
const out: string[] = [];
async function go(rel: string) {
let entries;
try {
entries = await readdir(rel ? join(base, rel) : base, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (!e.isDirectory()) continue;
const p = rel ? `${rel}/${e.name.normalize('NFC')}` : e.name.normalize('NFC');
if (isIgnoredPath(p)) continue;
out.push(p);
await go(p);
}
}
await go('');
return out.sort((a, b) => a.localeCompare(b));
}
export async function readBytes(path: string) {
const abs = await inside(path);
try {
const [buf, s] = await Promise.all([readFile(abs), stat(abs)]);
return { buf, hash: sha256(buf), mtime: Math.floor(s.mtimeMs), size: s.size, abs };
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') throw new VaultError(404, 'Not found');
throw e;
}
}
export async function readNote(path: string) {
const r = await readBytes(path);
return { text: r.buf.toString('utf8'), hash: r.hash, mtime: r.mtime, size: r.size };
}
async function currentHash(abs: string): Promise<string | null> {
try {
return sha256(await readFile(abs));
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
throw e;
}
}
/**
* Atomically writes a file. `baseHash` is the hash the editor last loaded: a string means "only if
* the file still has this hash", null means "only if it does not exist yet" (create), and
* undefined means overwrite unconditionally (forced after the user saw the conflict).
* A mismatch throws 409 with the current hash, so a newer file is never silently overwritten.
*/
export async function writeFileAtomic(
path: string,
data: Uint8Array | string,
baseHash?: string | null
) {
const abs = await inside(path);
await ensureDir(dirname(abs));
const tmp = join(dirname(abs), `.notes-tmp-${randomUUID()}`);
const fh = await open(tmp, 'wx', 0o644);
try {
await fh.writeFile(data);
await fh.sync();
} finally {
await fh.close();
}
try {
await fixOwner(tmp);
if (baseHash !== undefined) {
const now = await currentHash(abs);
if (now !== baseHash)
throw new VaultError(
409,
baseHash === null ? 'A file with that name already exists' : 'Changed elsewhere',
{
currentHash: now
}
);
}
await rename(tmp, abs);
} catch (e) {
await unlink(tmp).catch(() => {});
throw e;
}
const s = await stat(abs);
const buf = typeof data === 'string' ? Buffer.from(data) : data;
return { hash: sha256(buf), mtime: Math.floor(s.mtimeMs), size: s.size };
}
/**
* "Deletes" like Obsidian's default: moves the file into <vault>/.trash (kept on the server,
* not synced, since the vault's .stignore ignores .trash). Refuses if the file changed since
* the editor loaded it.
*/
export async function trashFile(path: string, baseHash?: string) {
const abs = await inside(path);
if (baseHash !== undefined) {
const now = await currentHash(abs);
if (now === null) throw new VaultError(404, 'Not found');
if (now !== baseHash) throw new VaultError(409, 'Changed elsewhere', { currentHash: now });
}
const base = await root();
const trash = join(base, '.trash');
await mkdir(trash).catch((e) => {
if (e?.code !== 'EEXIST') throw e;
});
await fixOwner(trash);
const name = baseName(path);
let dest = join(trash, name);
if (
await stat(dest).then(
() => true,
() => false
)
) {
const dot = name.lastIndexOf('.');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
dest = join(
trash,
dot > 0 ? `${name.slice(0, dot)} ${stamp}${name.slice(dot)}` : `${name} ${stamp}`
);
}
await rename(abs, dest);
}
export async function fileExists(path: string) {
try {
return (await stat(await inside(path))).isFile();
} catch {
return false;
}
}

View File

@@ -27,6 +27,8 @@ export const ownerOnlyPrefixes = [
'/trackers/houses',
'/trackers/data-holders',
'/trackers/books/files',
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
'/trackers/notes',
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
'/trackers/ai/bots',
'/trackers/ai/usage',
@@ -62,6 +64,7 @@ export const ownerWritePrefixes = [
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc',
'/trackers/notes',
'/trackers/ai',
'/trackers/movies'
] as const;

View File

@@ -96,6 +96,18 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M20 3v4M22 5h-4M4 17v2M5 18H3'
]
},
notes: {
from: '#fde68a',
to: '#f59e0b',
ink: '#422006',
// notebook with a pen line
paths: [
'M6 3h11a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6z',
'M6 3v18',
'M4 7h3M4 12h3M4 17h3',
'M10 8h6M10 12h6M10 16h3'
]
},
health: {
from: '#f472b6',
to: '#db2777',