forked from mirror/crumbs-original
Compare commits
@@ -59,7 +59,7 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.30.1
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
# Note Linking — Design Spec
|
||||
|
||||
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
|
||||
**Date**: 2026-08-03
|
||||
|
||||
## Overview
|
||||
|
||||
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
|
||||
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
|
||||
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
|
||||
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
|
||||
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
|
||||
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
|
||||
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
|
||||
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
|
||||
|
||||
## Architecture
|
||||
|
||||
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
|
||||
|
||||
```typescript
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
|
||||
|
||||
```
|
||||
[Grocery List](crumb-note://<note-id>)
|
||||
```
|
||||
|
||||
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
|
||||
|
||||
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
|
||||
|
||||
## Components
|
||||
|
||||
### Client
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
|
||||
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
|
||||
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
|
||||
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
|
||||
|
||||
### Server
|
||||
|
||||
| Function | Location | Description |
|
||||
|---|---|---|
|
||||
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
|
||||
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
|
||||
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
|
||||
|
||||
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
|
||||
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
|
||||
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
|
||||
4. **Click** — opens the target note in the editor overlay.
|
||||
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
|
||||
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
|
||||
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
|
||||
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
|
||||
- **Self-links** — the currently-open note is filtered out of its own picker results.
|
||||
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
|
||||
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest)**
|
||||
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
|
||||
- `syncNoteLinks` — clears + re-inserts correctly
|
||||
- `fetchBacklinksForNote` — returns only notes the requesting user can access
|
||||
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
|
||||
|
||||
**E2E (Playwright)**
|
||||
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
|
||||
- Renaming the linked note updates the displayed link text next time it's viewed
|
||||
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
|
||||
- The linked-to note shows a "Referenced by" backlink to the note that links to it
|
||||
- A public share page never renders a note-link as clickable, even if the target is also shared
|
||||
- The note-picker excludes the currently-open note from its own search results
|
||||
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
|
||||
- Note graph / visual map view
|
||||
- Ranking or sorting backlinks beyond simple list order
|
||||
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
|
||||
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
|
||||
- [ ] Clicking a note-link opens the target note in the editor overlay
|
||||
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
|
||||
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
|
||||
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
|
||||
- [ ] Public share page never renders a note-link as clickable
|
||||
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
|
||||
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
|
||||
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `note_links` (
|
||||
`source_note_id` text NOT NULL,
|
||||
`target_note_id` text NOT NULL,
|
||||
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1774037379518,
|
||||
"tag": "0003_uneven_tomorrow_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1785824271578,
|
||||
"tag": "0004_violet_nicolaos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+41
-41
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.31.0",
|
||||
"version": "0.32.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
@@ -34,64 +34,64 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"markdown-it": "^14.3.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.63.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.5",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.6",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.2.7",
|
||||
"workbox-window": "^7.4.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -102,7 +102,7 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"cookie": ">=0.7.0 <1",
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
|
||||
Generated
+1372
-1936
File diff suppressed because it is too large
Load Diff
@@ -33,15 +33,19 @@
|
||||
interface Props {
|
||||
tick?: number;
|
||||
editor: Editor | undefined;
|
||||
currentNoteId?: string | null;
|
||||
}
|
||||
|
||||
let { editor, tick }: Props = $props();
|
||||
let { editor, tick, currentNoteId = null }: Props = $props();
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
||||
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
||||
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
||||
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
||||
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
@@ -49,6 +53,40 @@
|
||||
let linkInput: HTMLInputElement | undefined = $state();
|
||||
let ignorePositionInvalidationUntil = 0;
|
||||
|
||||
let noteSearchResults: { id: string; title: string }[] = $state([]);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function searchNotesForLink(query: string) {
|
||||
const requestId = ++noteSearchRequestId;
|
||||
if (!query.trim()) {
|
||||
noteSearchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (requestId !== noteSearchRequestId) return;
|
||||
if (!res.ok) return;
|
||||
const results: { id: string; title: string }[] = await res.json();
|
||||
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
|
||||
} catch {
|
||||
// Search failed silently
|
||||
}
|
||||
}
|
||||
|
||||
let noteSearchRequestId = 0;
|
||||
|
||||
function handleLinkInput() {
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
|
||||
}
|
||||
|
||||
function insertNoteLink(noteId: string) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
|
||||
noteSearchResults = [];
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
const dropdownGap = 4;
|
||||
const viewportMargin = 10;
|
||||
|
||||
@@ -81,6 +119,8 @@
|
||||
function closeDropdowns() {
|
||||
openDropdown = null;
|
||||
dropdownAnchorEl = null;
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchResults = [];
|
||||
}
|
||||
|
||||
function closeDropdownsAfterPositionChange() {
|
||||
@@ -434,6 +474,7 @@
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
bind:value={linkUrl}
|
||||
oninput={handleLinkInput}
|
||||
type="url"
|
||||
placeholder="Paste a link..."
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
@@ -472,6 +513,21 @@
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{#if noteSearchResults.length > 0}
|
||||
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
|
||||
{#each noteSearchResults as result (result.id)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => insertNoteLink(result.id)}
|
||||
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="format-link-note-result"
|
||||
>
|
||||
{result.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openDropdown === 'table'}
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pushState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
@@ -19,7 +19,7 @@
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
@@ -43,9 +43,10 @@
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -164,8 +165,22 @@
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
|
||||
// component (rather than reusing the instance) whenever the user switches to a
|
||||
// different note while the editor is open. That teardown skips the debounced
|
||||
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
|
||||
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
|
||||
// could otherwise be silently dropped.
|
||||
onDestroy(() => {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
|
||||
|
||||
let viewportHeight = $state('100dvh');
|
||||
let viewportTop = $state('0px');
|
||||
@@ -325,15 +340,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
|
||||
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
|
||||
// component on every note-switch (rather than reusing the instance), a fetch that
|
||||
// resolves after teardown would otherwise write to $state on an already-destroyed
|
||||
// instance racing the newly-mounted one.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (destroyed) return;
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
|
||||
// note.backlinks is only ever attached server-side by getNote(), which nothing
|
||||
// else in the client calls when opening a note (the bulk notes store and
|
||||
// IndexedDB never carry it), so this mirrors the attachments effect above to
|
||||
// fill it in on open. Same post-teardown guard as above.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}`)
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data: Note | null) => {
|
||||
if (destroyed) return;
|
||||
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -597,6 +642,7 @@
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
{onOpenNote}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
@@ -616,10 +662,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Backlinks -->
|
||||
{#if backlinksList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each backlinksList as backlink (backlink.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onOpenNote(backlink.id)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
|
||||
data-testid="backlink-chip"
|
||||
>
|
||||
{backlink.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { getNote as getIdbNote } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -43,6 +44,20 @@
|
||||
replaceState(location.pathname, {});
|
||||
}
|
||||
|
||||
// Reads from the local IndexedDB cache rather than fetching over the network —
|
||||
// this keeps note-link navigation instant and offline-safe (matching how the
|
||||
// rest of the app opens notes), and backlinks are filled in progressively by
|
||||
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
|
||||
// the same pattern already used there for attachments.
|
||||
let openNoteRequestId = 0;
|
||||
|
||||
async function openNoteById(noteId: string) {
|
||||
const requestId = ++openNoteRequestId;
|
||||
const note = await getIdbNote(noteId);
|
||||
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
|
||||
if (note) openEditor(note);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
@@ -137,9 +152,17 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
|
||||
while the editor is already open) instead of reusing the same instance
|
||||
with a new `note` prop — a persisting instance's own back-navigation
|
||||
effect misreads `openEditor`'s replaceState call as "browser back was
|
||||
pressed" and closes the editor, and its once-only local state never
|
||||
resyncs to the new note otherwise. -->
|
||||
{#key editingNote.id}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -13,20 +13,25 @@
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { NoteLink } from './tiptap/NoteLink.js';
|
||||
import { getAllNotes } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onUpdate: (markdown: string) => void;
|
||||
onEditor?: (editor: Editor) => void;
|
||||
onTransaction?: () => void;
|
||||
onOpenNote?: (noteId: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
const titleIndexRef = { index: new Map<string, string>() };
|
||||
|
||||
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof Element) {
|
||||
const checkbox = target.matches('input[type="checkbox"]')
|
||||
@@ -76,43 +81,66 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
let destroyed = false;
|
||||
let editorInstance: Editor | undefined;
|
||||
|
||||
(async () => {
|
||||
const allNotes = await getAllNotes();
|
||||
if (destroyed) return;
|
||||
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
|
||||
|
||||
editorInstance = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false, underline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Trigger Svelte reactivity for active state checks
|
||||
editor = editor;
|
||||
onTransaction?.();
|
||||
TaskItem.configure({ nested: true }),
|
||||
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (destroyed) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Removing this component's DOM node (e.g. a {#key}-driven remount
|
||||
// in NotesView.svelte when switching to a different note) fires a
|
||||
// native blur on the still-focused contenteditable, which
|
||||
// ProseMirror's blur plugin turns into one last transaction —
|
||||
// synchronously, from inside Svelte's own render/effect pass that's
|
||||
// doing the removal. Mutating $state right there crashes with
|
||||
// state_unsafe_mutation; the `destroyed` check alone isn't enough
|
||||
// since this can fire before onMount's own cleanup runs. Defer to a
|
||||
// microtask so the write lands after Svelte's current pass finishes.
|
||||
queueMicrotask(() => {
|
||||
if (destroyed) return;
|
||||
editor = editorInstance;
|
||||
onTransaction?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
editor = editorInstance;
|
||||
|
||||
if (element && !destroyed) {
|
||||
(element as any).__tiptapEditor = editorInstance;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose editor on DOM element for e2e testing
|
||||
if (element) {
|
||||
(element as any).__tiptapEditor = editor;
|
||||
}
|
||||
|
||||
onEditor?.(editor);
|
||||
onEditor?.(editorInstance);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
destroyed = true;
|
||||
editorInstance?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
export interface NoteLinkOptions {
|
||||
titleIndexRef: { index: Map<string, string> };
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const NoteLink = Node.create<NoteLinkOptions>({
|
||||
name: 'noteLink',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
titleIndexRef: { index: new Map<string, string>() },
|
||||
onOpenNote: () => {}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
noteId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-note-id'),
|
||||
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[href^="crumb-note://"]',
|
||||
priority: 100,
|
||||
getAttrs: (element) => {
|
||||
const href = (element as HTMLElement).getAttribute('href') ?? '';
|
||||
const noteId = parseNoteLinkHref(href);
|
||||
return noteId ? { noteId } : false;
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
|
||||
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
const noteId = node.attrs.noteId as string;
|
||||
const title = this.options.titleIndexRef.index.get(noteId);
|
||||
|
||||
const dom = document.createElement('span');
|
||||
dom.setAttribute('data-note-id', noteId);
|
||||
dom.className =
|
||||
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
|
||||
|
||||
const label = document.createElement('span');
|
||||
if (title !== undefined) {
|
||||
label.textContent = title;
|
||||
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
|
||||
label.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.options.onOpenNote(noteId);
|
||||
});
|
||||
} else {
|
||||
label.textContent = 'Note not found';
|
||||
label.className = 'text-[var(--text-muted)] opacity-60';
|
||||
}
|
||||
dom.appendChild(label);
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = '×';
|
||||
removeButton.setAttribute('aria-label', 'Remove note link');
|
||||
removeButton.className =
|
||||
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
|
||||
removeButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
|
||||
});
|
||||
dom.appendChild(removeButton);
|
||||
|
||||
return { dom };
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
describe('parseNoteLinkHref', () => {
|
||||
it('should extract the note ID from a crumb-note:// href', () => {
|
||||
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('should return null for a non-crumb-note href', () => {
|
||||
expect(parseNoteLinkHref('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for an empty string', () => {
|
||||
expect(parseNoteLinkHref('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeNoteLinkMarkdown', () => {
|
||||
it('should produce a markdown link with the title from the index', () => {
|
||||
const titleIndex = new Map([['abc-123', 'Grocery List']]);
|
||||
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
|
||||
});
|
||||
|
||||
it('should fall back to Untitled when the ID is not in the index', () => {
|
||||
const titleIndex = new Map<string, string>();
|
||||
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
|
||||
});
|
||||
|
||||
it('should escape markdown-special characters in the title', () => {
|
||||
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
|
||||
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const NOTE_LINK_SCHEME = 'crumb-note://';
|
||||
|
||||
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
|
||||
export function parseNoteLinkHref(href: string): string | null {
|
||||
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
|
||||
return href.slice(NOTE_LINK_SCHEME.length) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes markdown-special characters the same way a plain link's visible
|
||||
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
|
||||
* the surrounding markdown syntax.
|
||||
*/
|
||||
function escapeMarkdownText(text: string): string {
|
||||
return text.replace(/[[\]\\*_`]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
|
||||
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
|
||||
const title = titleIndex.get(noteId) ?? 'Untitled';
|
||||
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
|
||||
}
|
||||
@@ -226,6 +226,13 @@ sqlite.exec(`
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteLinks, users } from './schema.js';
|
||||
|
||||
describe('noteLinks schema', () => {
|
||||
it('should store a source/target link row', () => {
|
||||
const { db } = createTestDb();
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
|
||||
});
|
||||
|
||||
it('should cascade-delete link rows when the source note is deleted', () => {
|
||||
const { db, sqlite } = createTestDb();
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
db.delete(notes).where(eq(notes.id, 'source-note')).run();
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,22 @@ export const noteTags = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
|
||||
@@ -68,6 +68,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
|
||||
const MAX_IMAGE_REDIRECTS = 5;
|
||||
|
||||
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
|
||||
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
|
||||
// upload_image can't be used to reach internal network services.
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
if (address.includes(':')) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fe80:')) return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
|
||||
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
|
||||
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
|
||||
return false;
|
||||
}
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
|
||||
// (not just the first), so a validated hostname can't have a private record hiding
|
||||
// behind a public one in the same DNS answer.
|
||||
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
const records = await lookup(hostname, { all: true });
|
||||
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
|
||||
for (const record of records) {
|
||||
if (isPrivateOrReservedIp(record.address)) {
|
||||
throw new Error('URL resolves to a private or reserved address');
|
||||
}
|
||||
}
|
||||
return records[0];
|
||||
}
|
||||
|
||||
interface PinnedResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(name: string): string | null };
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
// Issues the request against the exact address validated by resolveValidatedAddress,
|
||||
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
|
||||
// could serve a public address to the validator and a private one to the real connect.
|
||||
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
servername: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
|
||||
callback(null, address, family);
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
req.destroy(new Error('Image too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const status = res.statusCode ?? 0;
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const value = res.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
},
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
|
||||
});
|
||||
});
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
|
||||
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
|
||||
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
|
||||
let currentUrl = new URL(imageUrl);
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
|
||||
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
}
|
||||
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
|
||||
const response = await requestPinnedAddress(currentUrl, address, family);
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response;
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const response = await fetchPublicImage(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (contentLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const OTHER_USER_ID = 2;
|
||||
|
||||
function seedUser(id: number, email: string) {
|
||||
db.insert(users)
|
||||
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string, userId: number, title = '') {
|
||||
db.insert(notes)
|
||||
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser(OWNER_ID, 'owner@test.com');
|
||||
seedUser(OTHER_USER_ID, 'other@test.com');
|
||||
});
|
||||
|
||||
describe('syncNoteLinks', () => {
|
||||
it('should create link rows for each target', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old links and add new ones on re-sync', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', ['target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
|
||||
});
|
||||
|
||||
it('should handle an empty target list (removes all links)', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', []);
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchBacklinksForNote', () => {
|
||||
it('should return notes that link to the given note', () => {
|
||||
seedNote('source', OWNER_ID, 'Source Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
|
||||
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
|
||||
});
|
||||
|
||||
it('should return an empty array when nothing links to the note', () => {
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude a backlink from a source note the requesting user cannot access', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Other Users Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include a backlink from a source note shared with the requesting user', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Shared Source');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteLinks, notes } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
|
||||
/**
|
||||
* Sync link rows for a note: removes old associations, inserts the current set.
|
||||
* Mirrors syncNoteTags — called right after a note's content is saved.
|
||||
*/
|
||||
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
|
||||
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes that link to the given note, scoped to what the requesting user
|
||||
* can access — this both respects normal sharing boundaries and, as a side
|
||||
* effect, prevents an unrelated note from appearing in someone's backlinks
|
||||
* just because it happens to reference a note ID it was never shared to see.
|
||||
*/
|
||||
export function fetchBacklinksForNote(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
userId: number
|
||||
): { id: string; title: string }[] {
|
||||
const linkRows = db
|
||||
.select({ sourceNoteId: noteLinks.sourceNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.targetNoteId, noteId))
|
||||
.all();
|
||||
|
||||
if (linkRows.length === 0) return [];
|
||||
|
||||
const sourceIds = linkRows.map((r) => r.sourceNoteId);
|
||||
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
|
||||
if (accessibleIds.length === 0) return [];
|
||||
|
||||
const sourceNotes = db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(notes)
|
||||
.where(inArray(notes.id, accessibleIds))
|
||||
.all();
|
||||
|
||||
return sourceNotes;
|
||||
}
|
||||
@@ -374,3 +374,45 @@ describe('reorderNotes', () => {
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note links', () => {
|
||||
it('should sync note-links when a note is created with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should sync note-links when a note is updated with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty backlinks array for a note nothing links to', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
|
||||
const reopened = getNote(db, OWNER_ID, note.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/sc
|
||||
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { extractNoteLinks } from '$lib/utils/note-links.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { fetchSharesForNotes } from './shares-service.js';
|
||||
@@ -155,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) {
|
||||
}
|
||||
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
const hydrated = result[0];
|
||||
if (!hydrated) return null;
|
||||
|
||||
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -197,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
});
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
@@ -326,6 +334,8 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface Note {
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
shareToken?: string;
|
||||
backlinks?: NoteBacklink[];
|
||||
}
|
||||
|
||||
export interface NoteBacklink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
|
||||
@@ -85,3 +85,16 @@ describe('stripMarkdown', () => {
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdown note-link handling', () => {
|
||||
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
|
||||
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
|
||||
expect(html).not.toContain('<a');
|
||||
expect(html).toContain('Grocery List');
|
||||
});
|
||||
|
||||
it('should still render a normal external link as a real anchor', () => {
|
||||
const html = renderMarkdown('See [Example](https://example.com) for details');
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,25 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
}
|
||||
});
|
||||
|
||||
md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== 'inline' || !token.children) continue;
|
||||
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
|
||||
children.splice(closeIndex, 1);
|
||||
children.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractNoteLinks } from './note-links.js';
|
||||
|
||||
describe('extractNoteLinks', () => {
|
||||
it('should extract a single note-link reference', () => {
|
||||
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
|
||||
});
|
||||
|
||||
it('should extract multiple distinct references', () => {
|
||||
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate repeated references', () => {
|
||||
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1']);
|
||||
});
|
||||
|
||||
it('should return an empty array for content with no note-links', () => {
|
||||
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty content', () => {
|
||||
expect(extractNoteLinks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside a code block', () => {
|
||||
const content = '```\n[A](crumb-note://id-1)\n```';
|
||||
expect(extractNoteLinks(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside inline code', () => {
|
||||
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Extract crumb-note://<id> references from markdown content.
|
||||
* Mirrors extractTags' code-block/inline-code exclusion so a reference
|
||||
* mentioned as an example inside a code sample isn't treated as a real link.
|
||||
*/
|
||||
export function extractNoteLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
|
||||
|
||||
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const ids = matches.map((m) => m.replace('crumb-note://', ''));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
if (body.role !== 'admin' && body.role !== 'user') {
|
||||
throw error(400, 'Invalid role');
|
||||
}
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own role');
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.27.1",
|
||||
"version": "0.31.0",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
|
||||
@@ -13,6 +13,14 @@ async function runTiptapCommand(page: Page, commandFn: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTiptapEditor(page: Page) {
|
||||
await page.getByTestId('tiptap-editor').waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('[data-testid="tiptap-editor"]');
|
||||
return el && (el as any).__tiptapEditor != null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `checked` attribute of the first task item from the live ProseMirror
|
||||
* document. This reflects the editor's document state, not just the native
|
||||
@@ -57,6 +65,7 @@ test.describe('Rich text formatting', () => {
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
|
||||
// When the user selects "world" and applies bold formatting
|
||||
await waitForTiptapEditor(page);
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleBold().run()');
|
||||
|
||||
// Then the selected text appears bold in the editor
|
||||
@@ -99,6 +108,7 @@ test.describe('Rich text formatting', () => {
|
||||
// Given the user is editing a note with selected text
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await typeViaMarkdown(page, 'visit example');
|
||||
await waitForTiptapEditor(page);
|
||||
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:14}).run()');
|
||||
|
||||
// When the user opens the link popover and enters a URL
|
||||
|
||||
@@ -13,9 +13,18 @@ const COLLAB_EMAIL = 'collab@test.com';
|
||||
const COLLAB_PASSWORD = collabPassword;
|
||||
const COLLAB_NAME = 'Collaborator User';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
/**
|
||||
* Find a specific note card by its exact title text. Matches only the
|
||||
* card's <h3> title, not its full text content — with note-linking, a
|
||||
* card's content preview can render another note's title as plain text
|
||||
* (e.g. a stripped note-link), so a substring match against the whole
|
||||
* card can ambiguously resolve to more than one card.
|
||||
*/
|
||||
export function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { hasText: title });
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return page.locator('[data-testid="note-card"]').filter({
|
||||
has: page.locator('h3', { hasText: new RegExp(`^${escaped}$`) })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, createNote, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Note Linking', () => {
|
||||
test('Scenario: Linking to a note renders a live chip and clicking it opens that note', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Target Note', 'target content');
|
||||
await createNote(page, 'Source Note', 'source content');
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Target');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Target Note' }).click();
|
||||
|
||||
await expect(page.getByTestId('tiptap-editor').locator('[data-note-id]')).toBeVisible();
|
||||
await page.getByTestId('tiptap-editor').locator('[data-note-id]').click();
|
||||
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Target Note');
|
||||
});
|
||||
|
||||
test('Scenario: Renaming a linked note updates the displayed link text', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Original Title', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Original');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Original Title' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Original Title').click();
|
||||
await page.getByTestId('note-title-input').fill('Renamed Title');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Linker Note').click();
|
||||
await expect(editor.getByText('Renamed Title')).toBeVisible();
|
||||
await expect(editor.getByText('Original Title')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: A link to a trashed note renders inert and does not navigate', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Doomed Note', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Linker Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Doomed');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Doomed Note' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Doomed Note').click();
|
||||
await page.getByTestId('overflow-menu-btn').click();
|
||||
await page.getByTestId('trash-note-btn').click();
|
||||
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
|
||||
|
||||
await noteCard(page, 'Linker Note').click();
|
||||
await expect(editor.getByText('Note not found')).toBeVisible();
|
||||
await editor.getByText('Note not found').click();
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Linker Note');
|
||||
});
|
||||
|
||||
test('Scenario: The linked-to note shows a Referenced by backlink', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Popular Note', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Referencing Note');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Popular');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Popular Note' }).click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
await noteCard(page, 'Popular Note').click();
|
||||
|
||||
await expect(page.getByTestId('backlink-chip').filter({ hasText: 'Referencing Note' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: The note-picker excludes the currently-open note from its own search results', async ({ authenticatedPage: page }) => {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Self Reference Test');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Self Reference');
|
||||
|
||||
await expect(page.getByTestId('format-link-note-result').filter({ hasText: 'Self Reference Test' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Scenario: Hovering a note-link chip reveals a remove button that deletes it without navigating', async ({ authenticatedPage: page }) => {
|
||||
await createNote(page, 'Removable Target', '');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Has A Link');
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await page.getByTestId('format-link').click();
|
||||
await page.getByTestId('format-link-input').fill('Removable');
|
||||
await page.getByTestId('format-link-note-result').filter({ hasText: 'Removable Target' }).click();
|
||||
|
||||
const chip = page.getByTestId('tiptap-editor').locator('[data-note-id]');
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.hover();
|
||||
await page.getByRole('button', { name: 'Remove note link' }).click();
|
||||
|
||||
await expect(chip).toHaveCount(0);
|
||||
await expect(page.getByTestId('note-title-input').first()).toHaveValue('Has A Link');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -292,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.31.0</span>
|
||||
<span class="footer-version">v0.32.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user