notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
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:
22
README.md
22
README.md
@@ -342,6 +342,28 @@ registered callback URL. The integration follows the installed Better Auth APIs;
|
||||
see [SvelteKit integration](https://better-auth.com/docs/integrations/svelte-kit)
|
||||
and [Generic OAuth](https://better-auth.com/docs/plugins/generic-oauth).
|
||||
|
||||
## Notes
|
||||
|
||||
`/trackers/notes` (owner-only, always: every page, API, attachment and the `/ws/notes` socket 404s
|
||||
for anyone else) is a web editor for Stan's Obsidian vault, two-way and near-real-time.
|
||||
|
||||
- **Source of truth:** the markdown files in `~/obsidian-vault` on the server (Syncthing folder
|
||||
`obsidian-vault`, synced to his devices), bind-mounted read-write at `/notes` by
|
||||
`scripts/deploy-remote.sh` (`NOTES_HOST_DIR`). The site reads and writes `.md` files directly:
|
||||
atomic writes (hidden `.notes-tmp-*` file in the same folder, fsync, rename), chowned to the
|
||||
vault owner, frontmatter and everything else kept byte-for-byte. Deleting moves the file to
|
||||
`<vault>/.trash` like Obsidian does.
|
||||
- **Index only:** `notes.files` / `notes.links` (full-text search, tags, wikilinks, backlinks) are
|
||||
rebuilt from the files by `src/lib/server/notes/indexer.ts`: recursive `fs.watch` plus a full
|
||||
rescan every minute. Dropping the tables loses nothing.
|
||||
- **Live:** file changes (from Obsidian via Syncthing, or the site) are pushed to open pages over
|
||||
`wss://stanrunge.dev/ws/notes` (paths and hashes only, never content). Saves carry the hash the
|
||||
editor loaded; if the file changed in between the save is refused (409) and the page offers
|
||||
load theirs / overwrite / save a copy.
|
||||
- **Ignored:** anything under a dot entry (`.obsidian`, `.trash`, `.stfolder`, `.stversions`,
|
||||
`.DS_Store`, Syncthing temps). `*.sync-conflict-*` files are listed as warnings, not indexed.
|
||||
- Syncthing should ignore the site's write temps: `(?d).notes-tmp-*` in the vault's `.stignore`.
|
||||
|
||||
## Development
|
||||
|
||||
There are two environments: local dev and production. Prod is the source of truth
|
||||
|
||||
@@ -34,7 +34,8 @@ export default defineConfig({
|
||||
'stats',
|
||||
'ai',
|
||||
'movies',
|
||||
'worldtodo'
|
||||
'worldtodo',
|
||||
'notes'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
47
drizzle/20261026012817_notes/migration.sql
Normal file
47
drizzle/20261026012817_notes/migration.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE SCHEMA "notes";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notes"."files" (
|
||||
"path" text PRIMARY KEY,
|
||||
"kind" text NOT NULL,
|
||||
"folder" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"ext" text NOT NULL,
|
||||
"size" bigint NOT NULL,
|
||||
"mtime" timestamp with time zone NOT NULL,
|
||||
"hash" text NOT NULL,
|
||||
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"frontmatter" jsonb,
|
||||
"body" text,
|
||||
"search" tsvector,
|
||||
"indexed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notes"."links" (
|
||||
"src_path" text,
|
||||
"seq" integer,
|
||||
"target" text NOT NULL,
|
||||
"target_key" text NOT NULL,
|
||||
"embed" boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT "links_pkey" PRIMARY KEY("src_path","seq")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "files_search_idx" ON "notes"."files" USING gin ("search");--> statement-breakpoint
|
||||
CREATE INDEX "files_tags_idx" ON "notes"."files" USING gin ("tags");--> statement-breakpoint
|
||||
CREATE INDEX "files_folder_idx" ON "notes"."files" ("folder");--> statement-breakpoint
|
||||
CREATE INDEX "files_kind_idx" ON "notes"."files" ("kind");--> statement-breakpoint
|
||||
CREATE INDEX "links_target_key_idx" ON "notes"."links" ("target_key");--> statement-breakpoint
|
||||
ALTER TABLE "notes"."links" ADD CONSTRAINT "links_src_path_files_path_fkey" FOREIGN KEY ("src_path") REFERENCES "notes"."files"("path") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
|
||||
-- Privacy: notes are always private. Like tasks and health, only the app role can read or write
|
||||
-- (no personal_ro); the owner-only checks live in the app. The tables are only a search/link
|
||||
-- index rebuilt from the Obsidian vault files. Grants only run on prod (migrations as `personal`).
|
||||
REVOKE ALL ON SCHEMA notes FROM PUBLIC;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT USAGE ON SCHEMA notes TO personal_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA notes TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA notes
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
END IF;
|
||||
END $$;
|
||||
41215
drizzle/20261026012817_notes/snapshot.json
Normal file
41215
drizzle/20261026012817_notes/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -262,6 +262,30 @@ try {
|
||||
const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`;
|
||||
if (worldtodoTasks < 68)
|
||||
throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`);
|
||||
// Notes (always private; only a search/link index of the Obsidian vault): app role read/write,
|
||||
// personal_ro none. 2 = files, links.
|
||||
const NOTES_TABLES = 2;
|
||||
const [notes] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
count(*) filter (
|
||||
where exists (
|
||||
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||
where r.rolname = 'personal_ro'
|
||||
)
|
||||
) as ro_read,
|
||||
count(*) as tables,
|
||||
has_schema_privilege('public', 'notes', 'usage') as public_usage
|
||||
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'notes' and c.relkind = 'r'
|
||||
`;
|
||||
if (
|
||||
Number(notes.tables) !== NOTES_TABLES ||
|
||||
Number(notes.app_rw) !== NOTES_TABLES ||
|
||||
Number(notes.ro_read) !== 0 ||
|
||||
notes.public_usage
|
||||
)
|
||||
throw new Error(`notes schema tables/grants are wrong: ${JSON.stringify(notes)}`);
|
||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||
const [energy] = await client`
|
||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||
@@ -271,7 +295,7 @@ try {
|
||||
if (!Number(energy.hours))
|
||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||
console.log(
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
);
|
||||
const [catalog] = await client`
|
||||
select
|
||||
|
||||
@@ -108,6 +108,30 @@ YAML
|
||||
else
|
||||
echo "No $pubg_tiles_host_dir/erangel on this host; the PUBG map will have no tiles" >&2
|
||||
fi
|
||||
# Notes tracker: the Obsidian vault (Syncthing folder "obsidian-vault") is bind-mounted READ-WRITE
|
||||
# at /notes. The files are the source of truth; the site edits them in place (atomic temp+rename,
|
||||
# chowned to the vault owner) and keeps notes.* as a search/link index. Owner-only in the app.
|
||||
notes_host_dir=${NOTES_HOST_DIR:-/home/stan/obsidian-vault}
|
||||
case "$notes_host_dir" in /*) ;; *) echo 'NOTES_HOST_DIR must be absolute' >&2; exit 2 ;; esac
|
||||
if sudo docker run --rm --entrypoint sh \
|
||||
--mount "type=bind,source=$notes_host_dir,target=/notes,readonly" "$image" \
|
||||
-c 'test -d /notes/.obsidian' 2>/dev/null; then
|
||||
notes_override="$HOME/compose.notes.yml"
|
||||
rm -f "$notes_override"
|
||||
cat >"$notes_override" <<YAML
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NOTES_ROOT: /notes
|
||||
volumes:
|
||||
- type: bind
|
||||
source: $notes_host_dir
|
||||
target: /notes
|
||||
YAML
|
||||
compose_files+=(-f "$notes_override")
|
||||
else
|
||||
echo "No Obsidian vault at $notes_host_dir on this host; the notes tracker will be empty" >&2
|
||||
fi
|
||||
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||
switched=0
|
||||
rollback() {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { startGamesPoller } from '#lib/server/games/poller.js';
|
||||
import { startOsuGoalsPoller } from '#lib/server/osu-goals/poller.js';
|
||||
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
|
||||
import { registerWorldtodoChannels } from '#lib/server/worldtodo/live.js';
|
||||
import { registerNotesChannel } from '#lib/server/notes/live.js';
|
||||
import { startNotesIndexer } from '#lib/server/notes/indexer.js';
|
||||
import { building } from '$app/env';
|
||||
import { auth } from '#lib/server/auth.js';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||
@@ -13,7 +15,8 @@ const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||
|
||||
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
|
||||
// the CS2 / PUBG / R6 match history poller and the osu! goals refresh. Also the WebSocket channels (served by
|
||||
// server.js on the same Bun server): the PUBG drop map rooms and the worldwide todo list.
|
||||
// server.js on the same Bun server): the PUBG drop map rooms, the worldwide todo list and the
|
||||
// notes live updates.
|
||||
export const init: ServerInit = () => {
|
||||
if (!building) {
|
||||
startMusicPoller();
|
||||
@@ -21,6 +24,9 @@ export const init: ServerInit = () => {
|
||||
startOsuGoalsPoller();
|
||||
registerPubgMapChannel();
|
||||
registerWorldtodoChannels();
|
||||
// Notes: index the Obsidian vault (NOTES_ROOT) and push file changes to /ws/notes (owner only).
|
||||
registerNotesChannel();
|
||||
void startNotesIndexer().catch((e) => console.error('[notes] indexer failed to start', e));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
175
src/lib/notes/render.ts
Normal file
175
src/lib/notes/render.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };
|
||||
}
|
||||
95
src/lib/notes/shared.test.ts
Normal file
95
src/lib/notes/shared.test.ts
Normal 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
296
src/lib/notes/shared.ts
Normal 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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from './ai';
|
||||
export * from './movies';
|
||||
export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
|
||||
70
src/lib/server/db/schema/notes.ts
Normal file
70
src/lib/server/db/schema/notes.ts
Normal 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)
|
||||
]
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
14
src/lib/server/notes/http.ts
Normal file
14
src/lib/server/notes/http.ts
Normal 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' };
|
||||
377
src/lib/server/notes/indexer.ts
Normal file
377
src/lib/server/notes/indexer.ts
Normal 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`;
|
||||
}
|
||||
69
src/lib/server/notes/live.ts
Normal file
69
src/lib/server/notes/live.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
276
src/lib/server/notes/vault.ts
Normal file
276
src/lib/server/notes/vault.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
22
src/routes/trackers/notes/+layout.server.ts
Normal file
22
src/routes/trackers/notes/+layout.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { allEntries, isReady } from '#lib/server/notes/indexer.js';
|
||||
import { vaultAvailable } from '#lib/server/notes/vault.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// Owner only, always: everyone else (logged out, or any other account) gets a plain 404, and
|
||||
// hooks.server.ts blocks the whole /trackers/notes prefix (pages, API, files) as a backstop.
|
||||
// The tree comes from the in-memory index; it doesn't depend on the URL, so switching notes
|
||||
// (?note=…) only reruns the page load.
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const available = await vaultAvailable();
|
||||
return {
|
||||
available,
|
||||
ready: isReady(),
|
||||
/** [path, kind (n/a/c), mtime] per file: compact, the tree and link resolver need no more. */
|
||||
tree: allEntries().map(
|
||||
(e) => [e.path, e.kind === 'note' ? 'n' : e.kind === 'conflict' ? 'c' : 'a', e.mtime] as const
|
||||
)
|
||||
};
|
||||
};
|
||||
23
src/routes/trackers/notes/+page.server.ts
Normal file
23
src/routes/trackers/notes/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { backlinks } from '#lib/server/notes/indexer.js';
|
||||
import { readNote, VaultError } from '#lib/server/notes/vault.js';
|
||||
import { cleanVaultPath, kindOf } from '#lib/notes/shared.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireOwner(event);
|
||||
const path = cleanVaultPath(event.url.searchParams.get('note'));
|
||||
if (!path || kindOf(path) === 'attachment')
|
||||
return { note: null, missing: !!event.url.searchParams.get('note') };
|
||||
try {
|
||||
const note = await readNote(path);
|
||||
return {
|
||||
note: { path, ...note, backlinks: await backlinks(path).catch(() => []) },
|
||||
missing: false
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof VaultError && (e.status === 404 || e.status === 400))
|
||||
return { note: null, missing: true };
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
1643
src/routes/trackers/notes/+page.svelte
Normal file
1643
src/routes/trackers/notes/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
70
src/routes/trackers/notes/api/note/+server.ts
Normal file
70
src/routes/trackers/notes/api/note/+server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { backlinks, touch } from '#lib/server/notes/indexer.js';
|
||||
import { readNote, trashFile, writeFileAtomic } from '#lib/server/notes/vault.js';
|
||||
import { noStore, vaultFail } from '#lib/server/notes/http.js';
|
||||
import { cleanVaultPath, extOf, kindOf } from '#lib/notes/shared.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Read, save and delete one note. Saves carry the hash the editor loaded (baseHash): if the file
|
||||
// changed on disk since (Obsidian on another device, Syncthing), the save is refused with 409
|
||||
// and the current hash, never silently overwriting the newer file.
|
||||
const MAX_NOTE = 5 * 1024 * 1024;
|
||||
|
||||
function pathOf(v: unknown, { notesOnly = true } = {}) {
|
||||
const path = cleanVaultPath(v);
|
||||
if (!path) error(400, 'Bad path');
|
||||
if (notesOnly && extOf(path) !== 'md') error(400, 'Only .md notes');
|
||||
return path;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const path = pathOf(event.url.searchParams.get('path'), { notesOnly: false });
|
||||
if (kindOf(path) === 'attachment') error(400, 'Not a note');
|
||||
try {
|
||||
const note = await readNote(path);
|
||||
const withLinks = event.url.searchParams.get('backlinks') === '1';
|
||||
return json(
|
||||
{ path, ...note, ...(withLinks ? { backlinks: await backlinks(path) } : {}) },
|
||||
{ headers: noStore }
|
||||
);
|
||||
} catch (e) {
|
||||
return vaultFail(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const body = (await event.request.json().catch(() => null)) as {
|
||||
path?: string;
|
||||
text?: string;
|
||||
baseHash?: string | null;
|
||||
force?: boolean;
|
||||
} | null;
|
||||
if (!body || typeof body.text !== 'string') error(400, 'Bad request');
|
||||
const path = pathOf(body.path);
|
||||
if (Buffer.byteLength(body.text) > MAX_NOTE) error(413, 'Note too large');
|
||||
const base = body.force ? undefined : body.baseHash === null ? null : body.baseHash;
|
||||
if (!body.force && base !== null && typeof base !== 'string') error(400, 'baseHash required');
|
||||
try {
|
||||
const r = await writeFileAtomic(path, body.text, base);
|
||||
await touch(path);
|
||||
return json({ path, ...r }, { headers: noStore });
|
||||
} catch (e) {
|
||||
return vaultFail(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const path = pathOf(event.url.searchParams.get('path'), { notesOnly: false });
|
||||
const hash = event.url.searchParams.get('hash') ?? undefined;
|
||||
try {
|
||||
await trashFile(path, hash);
|
||||
await touch(path);
|
||||
return json({ ok: true }, { headers: noStore });
|
||||
} catch (e) {
|
||||
return vaultFail(e);
|
||||
}
|
||||
};
|
||||
13
src/routes/trackers/notes/api/search/+server.ts
Normal file
13
src/routes/trackers/notes/api/search/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { search, tagCounts } from '#lib/server/notes/indexer.js';
|
||||
import { noStore } from '#lib/server/notes/http.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
if (event.url.searchParams.has('tags'))
|
||||
return json({ tags: await tagCounts() }, { headers: noStore });
|
||||
const q = (event.url.searchParams.get('q') ?? '').slice(0, 200);
|
||||
return json({ hits: await search(q) }, { headers: noStore });
|
||||
};
|
||||
14
src/routes/trackers/notes/api/tree/+server.ts
Normal file
14
src/routes/trackers/notes/api/tree/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { allEntries } from '#lib/server/notes/indexer.js';
|
||||
import { noStore } from '#lib/server/notes/http.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// The whole tree again (the page uses it after a WebSocket reconnect, in case it missed events).
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const tree = allEntries().map(
|
||||
(e) => [e.path, e.kind === 'note' ? 'n' : e.kind === 'conflict' ? 'c' : 'a', e.mtime] as const
|
||||
);
|
||||
return json({ tree }, { headers: noStore });
|
||||
};
|
||||
39
src/routes/trackers/notes/api/upload/+server.ts
Normal file
39
src/routes/trackers/notes/api/upload/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { touch } from '#lib/server/notes/indexer.js';
|
||||
import { fileExists, writeFileAtomic } from '#lib/server/notes/vault.js';
|
||||
import { noStore, vaultFail } from '#lib/server/notes/http.js';
|
||||
import { IMAGE_EXT, cleanVaultPath, extOf, folderOf } from '#lib/notes/shared.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Pasted/dropped images, saved like Obsidian does with its default settings (no attachment
|
||||
// folder configured): "Pasted image YYYYMMDDHHmmss.png" in the vault root.
|
||||
const MAX = 20 * 1024 * 1024;
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData().catch(() => null);
|
||||
const file = form?.get('file');
|
||||
if (!(file instanceof File)) error(400, 'No file');
|
||||
if (file.size > MAX) error(413, 'Too large');
|
||||
const ext = (extOf(file.name) || file.type.split('/')[1] || '')
|
||||
.toLowerCase()
|
||||
.replace('jpeg', 'jpg');
|
||||
if (!IMAGE_EXT.has(ext) && ext !== 'pdf') error(415, 'Images or PDFs only');
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
const folder = cleanVaultPath(form?.get('folder')) ?? '';
|
||||
let name = `Pasted image ${stamp}.${ext}`;
|
||||
for (let i = 1; await fileExists(folder ? `${folder}/${name}` : name); i++)
|
||||
name = `Pasted image ${stamp} ${i}.${ext}`;
|
||||
const path = folder ? `${folder}/${name}` : name;
|
||||
if (folderOf(path) !== folder) error(400, 'Bad path');
|
||||
try {
|
||||
const r = await writeFileAtomic(path, new Uint8Array(await file.arrayBuffer()), null);
|
||||
await touch(path);
|
||||
return json({ path, name, ...r }, { headers: noStore });
|
||||
} catch (e) {
|
||||
return vaultFail(e);
|
||||
}
|
||||
};
|
||||
48
src/routes/trackers/notes/file/[...path]/+server.ts
Normal file
48
src/routes/trackers/notes/file/[...path]/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { readBytes, VaultError } from '#lib/server/notes/vault.js';
|
||||
import { cleanVaultPath, extOf } from '#lib/notes/shared.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Attachments (images embedded with ![[…]], PDFs) straight from the vault, owner only.
|
||||
const TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm'
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireOwner(event);
|
||||
const path = cleanVaultPath(event.params.path);
|
||||
if (!path) error(404, 'Not Found');
|
||||
try {
|
||||
const f = await readBytes(path);
|
||||
const etag = `"${f.hash.slice(0, 32)}"`;
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': TYPES[extOf(path)] ?? 'application/octet-stream',
|
||||
'cache-control': 'private, max-age=300',
|
||||
etag,
|
||||
'x-content-type-options': 'nosniff',
|
||||
// SVGs and anything else are never allowed to run script in the site's origin.
|
||||
'content-security-policy':
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
|
||||
'content-disposition': TYPES[extOf(path)] ? 'inline' : 'attachment'
|
||||
};
|
||||
if (event.request.headers.get('if-none-match') === etag)
|
||||
return new Response(null, { status: 304, headers });
|
||||
return new Response(f.buf, { headers });
|
||||
} catch (e) {
|
||||
if (e instanceof VaultError) error(404, 'Not Found');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user