Compare commits

...
82 Commits
Author SHA1 Message Date
github-actions[bot] 30ec9856c2 chore: bump version to 0.21.2 2026-03-12 09:28:39 +00:00
Frederic CabassutandClaude Opus 4.6 c24b0f7d86 fix: prevent ghost click tap-through on mobile editor toolbar
On mobile, tapping a note card could trigger action buttons in the
editor that rendered at the same coordinates (ghost click). Suppress
pointer events on both toolbars for 150ms after mount — matching the
pop-in animation — so the synthetic click passes harmlessly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:10:47 +01:00
github-actions[bot] 00c9fd43e8 chore: pin docker-compose to v0.21.1 [skip ci] 2026-03-12 08:49:35 +00:00
github-actions[bot] f27489463c chore: bump version to 0.21.1 2026-03-12 08:45:06 +00:00
Frederic CabassutandClaude Opus 4.6 990639d804 fix: resolve flaky offline sync e2e test in CI
Playwright's setOffline(false) doesn't reliably emit the browser's
'online' DOM event in headless/CI environments. Manually dispatch it
as a safety net so the sync client always triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:31:11 +01:00
Claude 8108e62cf1 fix: remove stopPropagation on editor card that blocked toolbar dropdown close
The editor card's onpointerdown stopPropagation prevented the
FormattingToolbar's document-level pointerdown listener from
detecting clicks outside the dropdown. Since both overlay handlers
already use e.target === e.currentTarget to ignore bubbled events,
no stopPropagation is needed on the card.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude b0a3a1fcb8 fix: use pointer events for backdrop close instead of timeout guard
Pointer events are never synthetically re-dispatched from touch
(unlike mouse events), so the tap that opened the editor cannot
ghost-click the overlay. This eliminates the mobile first-load bug
without relying on an arbitrary delay.

Both pointerdown and pointerup must target the overlay itself to
trigger close, which also handles the text-selection-outside-editor
edge case.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude c411975bb9 fix: use timestamp guard instead of rAF for mobile backdrop click
On first page load on mobile, the synthetic mousedown from a tap can
arrive ~300ms after the touch event — well after a single
requestAnimationFrame (~16ms). Replace the rAF-based overlayReady
guard with a 400ms timestamp check from mount time to reliably
ignore stale synthetic events on slow first loads.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
github-actions[bot] e08a4883f1 chore: pin docker-compose to v0.21.0 [skip ci] 2026-03-11 20:22:43 +00:00
github-actions[bot] 0cf34e631b chore: bump version to 0.21.0 2026-03-11 20:18:05 +00:00
copilot-swe-agent[bot]andfredicious 9f8fe85c44 fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5ac7de7f78 chore: exclude package-lock.json from git (project uses pnpm)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5f6149e282 feat: add email notifications for user, account, and collaboration events
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 633727b58e fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:02:00 +01:00
github-actions[bot] 3ab7c1fb53 chore: pin docker-compose to v0.20.0 [skip ci] 2026-03-11 19:45:32 +00:00
github-actions[bot] d8c5a41fed chore: bump version to 0.20.0 2026-03-11 19:41:24 +00:00
Claude 0b1416908e feat: add email notifications when a note is shared
Send an email to collaborators when they are added to a shared note.
Uses nodemailer with optional SMTP configuration (auto-enabled when
SMTP_HOST is set). Users can opt out via the notifyOnShare preference.

https://claude.ai/code/session_01P4HSMr7QvKw9TMNikUJD9e
2026-03-11 20:33:48 +01:00
copilot-swe-agent[bot]andfredicious 07ea482bad fix: address code review feedback on NoteHistory component
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
copilot-swe-agent[bot]andfredicious 0b34654736 feat: add note version history and restore (#note-history)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
Claude 8ed915bc69 fix: add vertical padding to checklist add-item button
https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
Claude 917aef1865 fix: increase checklist item padding and fix text overflow in editor
Add more vertical padding (py-1 → py-1.5) to checklist items for better
readability. Add min-w-0 to the text input so long text is constrained
within the flex container instead of overflowing out of the note.

https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
github-actions[bot] e8eec85d57 chore: pin docker-compose to v0.19.3 [skip ci] 2026-03-11 19:00:59 +00:00
github-actions[bot] b36326e65a chore: bump version to 0.19.3 2026-03-11 18:56:19 +00:00
Claude 26eff09d7d fix: resolve race condition in offline sync e2e test
Set up waitForResponse before calling setOffline(false) so the sync
response listener is registered before the online event fires.

https://claude.ai/code/session_014yTnHzjkCJLBcYcPTSLVkE
2026-03-11 19:53:47 +01:00
Claude 53aa32e07f fix: improve checklist note card text wrapping, spacing, and padding
- Replace `truncate` with `break-words min-w-0` so long checklist text
  wraps visibly instead of being clipped
- Increase vertical spacing between items (space-y-1 → space-y-2)
- Add bottom margin (mb-6) to prevent action buttons overlapping content
- Align checkboxes to top of multi-line items (items-start + mt-0.5)

https://claude.ai/code/session_01UVNA5wucVEjRQzwRxMhTZ9
2026-03-11 19:43:13 +01:00
Claude 51bfda3aec fix: prevent modal backdrop from catching the originating tap on mobile
On mobile, tapping a NoteCard to open the editor produces synthetic mouse
events (mousedown → mouseup → click) at the original touch coordinates.
If those coordinates land on the overlay backdrop rather than the editor
card, the mousedown sets mousedownOnOverlay = true and the click
immediately closes the editor — causing a brief flicker.

Guard against this by deferring overlay readiness to the next animation
frame after mount, so any synthetic events from the originating tap are
ignored.

https://claude.ai/code/session_011C8bfgShjDSmDr3rUCb2gw
2026-03-11 19:24:06 +01:00
github-actions[bot] 617c121221 chore: pin docker-compose to v0.19.2 [skip ci] 2026-03-10 22:47:34 +00:00
github-actions[bot] 5a9c6d2a36 chore: bump version to 0.19.2 2026-03-10 22:43:26 +00:00
Frederic CabassutandClaude Opus 4.6 7c2dad47cc ci: use Playwright container image instead of downloading browsers
Run E2E tests inside mcr.microsoft.com/playwright container which has
Chromium pre-installed, eliminating the ~30s browser download on each run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 370b0bc815 fix: eliminate e2e test flakiness in settings preference and API key tests
Three sources of flakiness fixed:

1. **Preferences sync race** — page.goto() triggers a full reload which
   re-runs initPreferences() + syncPreferencesFromServer(). On slow CI,
   the async server sync can return stale data from parallel test workers
   that share the same user's server-side preferences. Fixed with
   gotoWithStablePrefs() which blocks the one GET /api/preferences sync
   request, so initPreferences() falls back to localStorage (always correct).

2. **localStorage not reset** — resetPreferences() only reset the server.
   After a full page reload, loadFromLocalStorage() could return stale
   values from previous test runs. Now resets both server and localStorage.

3. **API key name collision** — parallel test runs created keys with the
   same name ("Settings Test Key"), causing Playwright strict-mode
   violations when multiple elements matched. Fixed with Date.now()
   unique suffixes.

Verified: 167/167 settings tests pass (20x repeat), 173/173 full suite (5x).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 fc391d675c docs: add E2E BDD testing best practices and improve test suite
- Add docs/TESTING.md with 10 BDD testing best practices
- Add createNote() helper to fixtures to DRY up state setup
- Add createChecklistNote() helper in checklist tests
- Rename scenario titles to describe outcomes, not actions
- Replace imperative Given steps with createNote() helper
- Fix fragile conditional in tag filtering test (silent pass)
- Remove implementation details from Gherkin comments
- Improve Given/When/Then rhythm consistency across all tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
github-actions[bot] 2995479b1d chore: pin docker-compose to v0.19.0 [skip ci] 2026-03-10 16:07:11 +00:00
github-actions[bot] 924493c8bd chore: bump version to 0.19.1 2026-03-10 15:16:11 +00:00
Frederic Cabassut c3c41db915 fix: isolate preference e2e tests with API reset before each test
Each preference test now resets to default preferences via API at the
start, ensuring parallel tests don't pollute each other's state.
Also await PUT response before reload in persist test to prevent
race condition.
2026-03-10 16:13:12 +01:00
github-actions[bot] 76431b7bc3 chore: bump version to 0.19.0 2026-03-10 14:55:19 +00:00
Frederic CabassutandClaude Opus 4.6 871d17e55f feat: add About settings page with version and project info
Add /settings/about page showing version, project description,
Bretzel ecosystem info, open source details, and tech stack.
Move version display from sidebar to About page and align
Settings nav item styling with other sidebar items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:54:40 +01:00
Frederic Cabassut fa60c65337 fix: prevent e2e test pollution from preferences shared state
Settings tests set defaultNoteMode to 'markdown' without resetting,
causing parallel sharing test to timeout when TiptapEditor wasn't
rendered (textarea shown instead). Two fixes:
- Reset preferences in settings tests after changing them
- Create note via API in sharing test (tests sharing, not note creation)
2026-03-10 15:54:40 +01:00
github-actions[bot] 0a6bb051bc chore: bump version to 0.18.0 2026-03-10 14:11:18 +00:00
Frederic CabassutandClaude Opus 4.6 02a6a3e6c6 fix: redirect /settings to /settings/preferences
Move Profile page to /settings/profile and add a 302 redirect from
/settings to /settings/preferences so the first settings tab is
Preferences, not Profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:10:31 +01:00
Frederic CabassutandClaude Opus 4.6 93a632e78a feat: add user preferences system
Implement 4 user preferences (default note mode, default note color,
hide footer, sidebar default state) with offline-first localStorage
backed by server-side key-value storage.

- Add user_preferences table (EAV pattern with unique user_id+key index)
- Create server service with get/upsert operations
- Add GET/PUT /api/preferences endpoints
- Build Svelte 5 runes-based client store (.svelte.ts)
- Wire preferences into app layout (footer toggle, sidebar state)
- Wire default note mode/color into NoteEditor for new notes
- Add Preferences tab as first item in Settings navigation
- Add 7 unit tests and 4 E2E tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:07:19 +01:00
Frederic CabassutandClaude Opus 4.6 664db9eb8d chore: remove plan docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:54:20 +01:00
Frederic CabassutandClaude Opus 4.6 e6cfda471a docs: add user preferences implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:33:44 +01:00
Frederic CabassutandClaude Opus 4.6 9de57da79d docs: add user preferences design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 8d5f27cd12 fix: prevent note editor from closing when text selection ends outside overlay
Track mousedown origin so click-outside-to-close only fires when both
mousedown and mouseup occur on the overlay backdrop, not when dragging a
text selection from inside the editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:16:51 +01:00
Frederic CabassutandClaude Opus 4.6 ad6068b3ad test: consolidate E2E tests — remove duplicates, fix timeouts, parallelize sharing
- Remove duplicate bold formatting test from notes-crud.spec.ts
- Trim formatting.spec.ts from 9 to 5 tests (remove italic, inline code,
  toolbar bold, toolbar visibility — same mechanism as bold shortcut)
- Remove hardcoded waitForTimeout(500) from search.spec.ts (Playwright
  assertions already retry with built-in timeouts)
- Restructure sharing.spec.ts: 9 serial tests → 5 parallel self-contained
  tests using collabPage fixture and API-based setup
- Add collabPage fixture to helpers/fixtures.ts for sharing tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 e1484b0af9 test: add 52 unit tests for notes-service, api-utils, rate-limit, tags, collaborators
- notes-service.test.ts (24 tests): listNotes, getNote, createNote, updateNote,
  deleteNote, searchNotes, reorderNotes with owner/collaborator patterns
- api-utils.test.ts (10 tests): canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId
- rate-limit.test.ts (8 tests): checkRateLimit, recordLoginAttempt, clearOldAttempts
- tags.test.ts (6 tests): syncNoteTags, fetchTagsForNotes
- collaborators.test.ts (4 tests): fetchCollaboratorsForNotes, addCollaborator, removeCollaborator

Also fixes test-helpers.ts missing thumbnail_path/featured columns
and rate-limit.ts raw SQL Date binding issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 8795a69b48 refactor: add mandatory db parameter to all service functions for testability
Make db a required first parameter on all service functions (notes-service,
api-utils, tags, collaborators, attachments, rate-limit, sync/server).
This enables unit testing with in-memory databases via createTestDb().

Also removes the NODE_ENV=test bypass from rate-limit and adds optional
now parameter for time control in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
github-actions[bot] 79f1e383c3 chore: pin docker-compose to v0.17.0 [skip ci] 2026-03-10 08:57:49 +00:00
github-actions[bot] 1809020501 chore: bump version to 0.17.0 2026-03-10 08:53:40 +00:00
Frederic CabassutandClaude Opus 4.6 bd4016c998 feat: add coverage summary to GitHub Actions job summary
Write a markdown coverage table to $GITHUB_STEP_SUMMARY for both unit
and E2E test jobs. Uses a small bash script that parses json-summary
output from the coverage reporters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 4ef5dbf20b feat: add code coverage with artifact upload for unit and E2E tests
Add V8 coverage for unit tests via @vitest/coverage-v8 and Istanbul
instrumentation for E2E tests via vite-plugin-istanbul. Coverage data
is collected from browser pages after each Playwright test and merged
with nyc. CI uploads both coverage reports as GitHub Actions artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 00c748a17b chore: set e2e retries to 0 — fix flaky tests, don't mask them
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:50:33 +01:00
Frederic CabassutandClaude Opus 4.6 f63cb6db1d perf: speed up e2e tests in CI
- Pre-build before Playwright instead of building inside webServer
- Reduce retries from 2 to 1 (fix flaky tests instead of masking them)
- Set CI workers to 4 for more parallelism
- Reduce artifact retention from 30 to 7 days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:46:22 +01:00
github-actions[bot] 329061fe7e chore: pin docker-compose to v0.16.1 [skip ci] 2026-03-10 06:40:49 +00:00
github-actions[bot] c638dce753 chore: bump version to 0.16.1 2026-03-10 06:36:44 +00:00
Frederic CabassutandClaude Opus 4.6 7918c1ecc2 fix: update e2e test for renamed API settings tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:36:03 +01:00
github-actions[bot] 5a32563e3b chore: bump version to 0.16.0 2026-03-09 23:11:31 +00:00
Frederic CabassutandClaude Opus 4.6 1eb10b41bb chore: rename Client Configuration to MCP Client Configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
Frederic CabassutandClaude Opus 4.6 d6eb6d3ad2 feat: extend API key auth to all API endpoints
Bearer token authentication now works on all /api/* routes, not just
/api/mcp. If a Bearer token is present it's validated first; otherwise
session cookie auth is used as fallback. Updates settings UI to reflect
the broader scope (tab renamed to "API", REST API curl example added)
and updates the landing page feature card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
github-actions[bot] f1e18ab968 chore: pin docker-compose to v0.15.0 [skip ci] 2026-03-09 23:02:47 +00:00
github-actions[bot] 707b80ad19 chore: bump version to 0.15.0 2026-03-09 22:57:53 +00:00
Frederic CabassutandClaude Opus 4.6 b42dedfd53 feat: add task list button to rich text editor toolbar
Register TaskList and TaskItem TipTap extensions, add "Task list" option
to the List dropdown in the formatting toolbar, and consolidate task list
CSS between the editor and markdown-it preview into shared styles in
app.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:57:29 +01:00
github-actions[bot] fcbdbd58f8 chore: pin docker-compose to v0.14.0 [skip ci] 2026-03-09 22:42:53 +00:00
github-actions[bot] 103c24c975 chore: bump version to 0.14.0 2026-03-09 22:38:22 +00:00
Frederic CabassutandClaude Opus 4.6 2c9ba1f03d fix: toolbar buttons now reactively show active formatting state
All isActive() and getAttributes() calls in the template were not
reading the tick prop, so Svelte 5 never re-evaluated them when the
editor state changed. Extracted isActive() and getAttrs() helpers
that consume tick to establish the reactive dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:37:38 +01:00
Frederic CabassutandClaude Opus 4.6 a069a2df2b refactor: make toolbar wrap instead of scroll, fix overflow
Use flex-wrap instead of overflow-x-auto for the formatting toolbar —
scrolling clips absolutely-positioned dropdown popovers (CSS forces
both axes to auto). All buttons are now always visible, wrapping to a
second line on narrow screens. Remove the More dropdown entirely since
all items are top-level. Add overflow-hidden to the editor card to
prevent horizontal overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:30:21 +01:00
Frederic CabassutandClaude Opus 4.6 2e302463fb refactor: promote all formatting buttons to top-level on desktop
Move undo/redo to the start of the toolbar, promote inline code,
blockquote, code block, and divider to top-level desktop buttons.
The More dropdown is now mobile-only since all items are directly
accessible on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:24:08 +01:00
Frederic CabassutandClaude Opus 4.6 00b2d54eac refactor: expand toolbar on desktop with alignment dropdown
- Widen editor modal on desktop (max-w-xl → md:max-w-2xl)
- Extract text alignment from More into its own dropdown with contextual
  icon reflecting the active alignment
- Promote undo/redo to top-level buttons on desktop (hidden on mobile,
  still available in More dropdown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:22:01 +01:00
Frederic CabassutandClaude Opus 4.6 dbe040322c feat: add row/column management to table toolbar
Replace single "Insert table" button with a Table dropdown that shows
contextual row/column operations when the cursor is inside a table:
add row, add column, delete row, delete column, delete table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:19:14 +01:00
Frederic CabassutandClaude Opus 4.6 c1e2cf3c8b feat: add table support to TipTap editor
Install @tiptap/extension-table packages, register Table/TableRow/
TableHeader/TableCell extensions, add Table button to the More dropdown
(inserts a 3×3 grid), and style tables to match the parchment theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:15:17 +01:00
Frederic CabassutandClaude Opus 4.6 3df2630fc2 docs: remove tables from FEATURES.md — not implemented
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:05:27 +01:00
github-actions[bot] f45406a76b chore: pin docker-compose to v0.13.1 [skip ci] 2026-03-09 21:47:39 +00:00
github-actions[bot] 435b5a74ff chore: bump version to 0.13.1 2026-03-09 21:43:19 +00:00
Frederic CabassutandClaude Opus 4.6 8b07e41132 fix: anchor collaborator popover to right edge to prevent overflow
The popover is inside a top-right positioned indicator, so anchoring
to right-0 keeps it on screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:39:32 +01:00
github-actions[bot] 5b32a46ca8 chore: pin docker-compose to v0.13.0 [skip ci] 2026-03-09 21:36:34 +00:00
github-actions[bot] 35c4206d75 chore: bump version to 0.13.0 2026-03-09 21:32:25 +00:00
Frederic CabassutandClaude Opus 4.6 da14b7678b feat: pressing Enter in note title moves cursor to content
Prevents default newline and focuses the content area (textarea or
TipTap editor) when pressing Enter in the title input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:29:44 +01:00
Frederic CabassutandClaude Opus 4.6 b3b24b2012 fix: remove tag chips from note cards in list view
Tags are still visible as hashtags in the note content and usable
via the tag filter bar. Reduces card clutter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:28:47 +01:00
Frederic CabassutandClaude Opus 4.6 e3568670eb fix: increase touch drag delay to 400ms to prevent scroll conflicts
The default 80ms delay was too short, causing drag to activate
during normal scrolling on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:15:05 +01:00
github-actions[bot] 904c1f5385 chore: pin docker-compose to v0.12.1 [skip ci] 2026-03-09 21:11:35 +00:00
github-actions[bot] 93b6ffee2a chore: bump version to 0.12.1 2026-03-09 21:07:31 +00:00
Frederic CabassutandClaude Opus 4.6 e70e404061 test: update sorting tests for hybrid sort order
Remove tests for sort selector UI (removed). Replace with a single
test verifying most recently updated note appears first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:06:39 +01:00
94 changed files with 19003 additions and 798 deletions
+8
View File
@@ -22,3 +22,11 @@ NODE_ENV=development
# AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO
# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) ---
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=Crumbs <noreply@example.com>
+38 -16
View File
@@ -46,11 +46,24 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit
- run: pnpm test:unit:coverage
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-coverage
path: coverage/unit/
retention-days: 14
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
steps:
- uses: actions/checkout@v4
@@ -65,24 +78,26 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright System Dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build (with coverage instrumentation)
run: pnpm build
env:
DATABASE_URL: ./data/test-crumbs.db
NODE_ENV: test
VITE_COVERAGE: 'true'
- run: pnpm test:e2e
env:
CI: true
VITE_COVERAGE: 'true'
HOME: /root
- name: Merge E2E coverage
if: always()
run: npx nyc report
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
@@ -91,7 +106,14 @@ jobs:
path: |
playwright-report/
test-results/
retention-days: 30
retention-days: 7
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-coverage
path: coverage/e2e/
retention-days: 14
docker-build:
name: Docker Build
+5
View File
@@ -32,6 +32,10 @@ vite.config.ts.timestamp-*
/test-results
/playwright-report
# Coverage
/coverage
.nyc_output
# Claude Code
/.claude/
@@ -40,3 +44,4 @@ todo.md
# Worktrees
.worktrees/
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"report-dir": "coverage/e2e",
"temp-dir": "coverage/e2e/raw",
"reporter": ["text", "html", "lcov", "json-summary"],
"include": ["src/**"],
"exclude": ["node_modules", "tests"]
}
+5
View File
@@ -98,6 +98,10 @@ tests/
unit/ # Vitest unit tests
e2e/ # Playwright e2e tests (Gherkin-style)
helpers/fixtures.ts # Shared authenticatedPage fixture
website/ # Static landing page (crumbs.bretzel.app)
index.html # Landing page with feature cards, screenshots, deploy section
styles.css # Landing page styles (retro parchment theme)
assets/ # Favicon, screenshots
```
## Commands
@@ -176,6 +180,7 @@ Write tests first or alongside features. Tests serve as living documentation of
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
### Gherkin-style Given/When/Then
+8 -1
View File
@@ -1,4 +1,5 @@
.PHONY: help dev build preview check test test-unit test-e2e lint \
.PHONY: help dev build preview check test test-unit test-e2e \
test-unit-coverage test-e2e-coverage lint \
db-push db-generate db-migrate db-studio docs-api \
install docker-build docker-up docker-down docker-logs clean \
release release-patch release-minor release-major
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
test-e2e: ## Run e2e tests only
pnpm test:e2e
test-unit-coverage: ## Run unit tests with coverage
pnpm test:unit:coverage
test-e2e-coverage: ## Run e2e tests with coverage
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
lint: ## Run linter
pnpm lint
+6 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.11.1
image: ghcr.io/bretzel-app/crumbs:0.21.1
container_name: crumbs
ports:
- "3000:3000"
@@ -11,6 +11,11 @@ services:
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=${ORIGIN:-http://localhost:3000}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
restart: unless-stopped
volumes:
+14
View File
@@ -114,6 +114,20 @@ node build
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
### Email Notifications (optional)
Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set.
| Variable | Default | Description |
|----------|---------|-------------|
| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) |
| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) |
| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_FROM` | `Crumbs <noreply@localhost>` | Sender address |
Users can opt out of share notifications in **Settings > Preferences**.
## Health Check
The Docker image includes a health check that pings `/login`:
+2 -1
View File
@@ -25,8 +25,8 @@
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Line breaks
@@ -91,6 +91,7 @@
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
- Email notifications when a note is shared with you (requires SMTP config, opt-out in preferences)
### Full-Text Search
- Search across note titles, content, and tags
+142
View File
@@ -0,0 +1,142 @@
# E2E BDD Testing Best Practices
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
## 1. Declarative scenarios, not imperative scripts
Describe **what** the system does, not **how** the user clicks through the UI.
```
// Good: "When the user creates a note titled 'Shopping List'"
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
```
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
## 2. Given describes state, not navigation
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
```
// Good: "Given a note titled 'Old Note' exists in the trash"
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
```
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
## 3. One behavior per scenario
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
```ts
// Good: Two focused scenarios
test('Trashed note disappears from main view', ...)
test('Trashed note appears in trash view', ...)
// Bad: One bloated scenario testing everything
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
```
## 4. Scenario names describe outcomes, not actions
Name tests by the **observable result**, not the user action that triggers it.
```ts
// Good: test('Archived note no longer appears in the main view')
// Bad: test('User archives a note')
```
This makes test reports read like a behavior specification.
## 5. Isolate test state completely
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
```ts
// Use API to create/reset state before each test
test.beforeEach(async ({ authenticatedPage: page }) => {
await page.request.put('/api/preferences', { data: { ... } });
});
```
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
## 6. Use `test.describe.serial` only when truly sequential
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
## 7. Abstract repeated UI actions into helpers
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
```ts
// helpers/fixtures.ts
export async function createNote(page, title, content?) { ... }
// In tests — reads like BDD
await createNote(page, 'My Note', 'Hello');
```
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
## 8. Apply the resilience test to every comment
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
- If **yes** — rewrite to remove implementation details
- If **no** — it's at the right abstraction level
```
// Fails (mentions hover + button):
// "When the user hovers the card and clicks the 3-dot menu"
// Passes:
// "When the user opens the note actions menu"
```
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
```ts
// Good: Waits intelligently for the element
await expect(page.getByText('Note saved')).toBeVisible();
// Bad: Arbitrary delay
await page.waitForTimeout(2000);
```
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
## 10. Structure tests in the Given/When/Then rhythm
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
```ts
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
// Given a note titled "Important" exists
await createNote(page, 'Important');
// When the user pins the note
await pinNote(page, 'Important');
// Then it appears before unpinned notes
const firstCard = page.locator('[data-testid="note-card"]').first();
await expect(firstCard).toContainText('Important');
});
```
## Quick reference
| Practice | Category | Goal |
|----------|----------|------|
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
| 2. Given = state | Language | Decouple setup from UI flow |
| 3. One behavior per test | Structure | Focused, debuggable tests |
| 4. Outcome-based names | Language | Test reports read as specs |
| 5. Isolate state | Reliability | No cross-test pollution |
| 6. Avoid serial | Reliability | Parallel-safe by default |
| 7. Extract helpers | Structure | DRY, intent-level code |
| 8. Resilience test | Language | UI-change-proof comments |
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
| 10. Given/When/Then | Structure | Scannable three-act tests |
+114
View File
@@ -0,0 +1,114 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`key_prefix` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `note_collaborators` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`added_by` integer NOT NULL,
`added_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
CREATE TABLE `note_user_state` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_attachments` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`featured` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
DROP TABLE `attachments`;--> statement-breakpoint
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_notes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
DROP TABLE `notes`;--> statement-breakpoint
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `__new_sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
DROP TABLE `sync_log`;--> statement-breakpoint
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `__new_tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
DROP TABLE `tags`;--> statement-breakpoint
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1773150888301,
"tag": "0002_deep_black_widow",
"breakpoints": true
}
]
}
+13070
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.12.0",
"version": "0.21.2",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -19,7 +19,10 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
"test": "pnpm test:unit && pnpm test:e2e",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
@@ -34,6 +37,10 @@
"@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",
@@ -47,6 +54,7 @@
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
@@ -61,16 +69,20 @@
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-istanbul": "^7.2.1",
"vitest": "^3.2.4",
"yaml": "^2.8.2"
},
+3 -2
View File
@@ -4,7 +4,8 @@ export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
retries: 0,
workers: process.env.CI ? 4 : undefined,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
@@ -27,7 +28,7 @@ export default defineConfig({
}
],
webServer: {
command: 'pnpm build && pnpm preview --port 4173',
command: 'pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+994
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
set -euo pipefail
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
if [ ! -f "$JSON" ]; then
echo "Coverage summary not found: $JSON" >&2
exit 0
fi
# Resolve to absolute path for require()
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
# Extract totals with node (available in CI, no jq dependency)
read -r stmts branches funcs lines < <(node -e "
const s = require('$ABS_JSON').total;
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
")
TABLE="## $TITLE
| Metric | Coverage |
|--------|----------|
| Statements | ${stmts}% |
| Branches | ${branches}% |
| Functions | ${funcs}% |
| Lines | ${lines}% |
"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
else
echo "$TABLE"
fi
+104
View File
@@ -72,3 +72,107 @@ body::before {
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: var(--primary-hover);
}
/* Table styles for TipTap editor */
.tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
.tiptap th,
.tiptap td {
border: 1px solid var(--border-subtle);
padding: 0.375rem 0.625rem;
text-align: left;
vertical-align: top;
min-width: 4rem;
}
.tiptap th {
font-weight: 600;
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
}
.tiptap td p,
.tiptap th p {
margin: 0;
}
.tiptap .selectedCell {
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* Task list styles — shared between TipTap editor and markdown-it preview */
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
.tiptap ul[data-type="taskList"],
.prose ul.task-list {
list-style: none;
padding-left: 0;
margin: 0.25rem 0;
}
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
padding: 0;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
}
/* TipTap: checkbox is inside a label */
.tiptap ul[data-type="taskList"] li label {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* Shared checkbox styles */
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
flex-shrink: 0;
}
/* TipTap: text content is in a div>p */
.tiptap ul[data-type="taskList"] li > div {
flex: 1;
min-width: 0;
}
.tiptap ul[data-type="taskList"] li > div p {
margin: 0;
}
/* Checked item styling */
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--text-muted);
}
/* Override prose spacing for task lists */
.prose ul[data-type="taskList"],
.prose ul.task-list {
padding-left: 0;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose ul[data-type="taskList"] li,
.prose ul.task-list li {
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
.prose ul.task-list li p {
margin: 0;
}
+19 -24
View File
@@ -22,36 +22,31 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// MCP endpoint: authenticate via Bearer token (API key)
if (pathname.startsWith('/api/mcp')) {
// API routes: authenticate via Bearer token if present
if (pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
if (token) {
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
event.locals.user = user;
return resolve(event);
}
event.locals.user = user;
return resolve(event);
}
// Check if setup is complete
+4 -4
View File
@@ -121,7 +121,7 @@
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
@@ -137,7 +137,7 @@
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, item.id)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
@@ -155,7 +155,7 @@
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-add"
>
<Plus class="h-4 w-4" />
@@ -176,7 +176,7 @@
{#if doneExpanded}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1">
<div class="group flex items-center gap-2 py-1.5">
<input
type="checkbox"
checked={item.checked}
@@ -47,7 +47,7 @@
{#if showPopover}
<div
class="absolute left-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="collaborator-popover"
>
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
+179 -82
View File
@@ -6,6 +6,7 @@
Heading,
List,
ListOrdered,
ListChecks,
TextQuote,
CodeXml,
Bold,
@@ -23,7 +24,10 @@
AlignJustify,
Minus,
ChevronDown,
Ellipsis
Table2,
BetweenHorizontalEnd,
BetweenVerticalEnd,
RemoveFormatting
} from 'lucide-svelte';
interface Props {
@@ -35,6 +39,9 @@
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return 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) ?? {}; }
let openDropdown: string | null = $state(null);
let linkUrl: string = $state('');
@@ -110,13 +117,35 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- Undo/Redo -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
data-testid="format-undo"
>
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
data-testid="format-redo"
>
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
@@ -124,7 +153,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
@@ -132,7 +161,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
@@ -140,7 +169,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
@@ -153,7 +182,7 @@
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
>
@@ -164,7 +193,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
@@ -172,7 +201,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
@@ -180,7 +209,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
@@ -194,7 +223,7 @@
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
>
@@ -205,7 +234,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
@@ -213,12 +242,20 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
@@ -227,7 +264,7 @@
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(editor?.isActive('link') ?? false)}
class={btnClass(isActive('link'))}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
@@ -259,7 +296,7 @@
<button
type="button"
onclick={openLink}
disabled={!editor?.getAttributes('link').href}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
@@ -269,7 +306,7 @@
<button
type="button"
onclick={removeLink}
disabled={!editor?.isActive('link')}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
@@ -281,99 +318,123 @@
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- More dropdown -->
<div class="relative" data-dropdown="more">
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<button
onclick={() => toggleDropdown('more')}
class={dropdownBtnClass(false)}
title="More options"
data-testid="format-more"
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
>
<Ellipsis size={iconSize} />
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'more'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
disabled={!canUndo()}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-undo"
data-testid="format-table-insert"
>
<Undo2 size={iconSize} />
<span>Undo</span>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
<button
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
disabled={!canRedo()}
class={dropdownItemClass()}
data-testid="format-redo"
>
<Redo2 size={iconSize} />
<span>Redo</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('code') ?? false)}
data-testid="format-code"
>
<Code size={iconSize} />
<span>Inline code</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
<span>Blockquote</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
<span>Code block</span>
</button>
<button
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-hr"
>
<Minus size={iconSize} />
<span>Divider</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Alignment dropdown -->
<div class="relative" data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
>
{#if isActive({ textAlign: 'center' })}
<AlignCenter size={iconSize} />
{:else if isActive({ textAlign: 'right' })}
<AlignRight size={iconSize} />
{:else if isActive({ textAlign: 'justify' })}
<AlignJustify size={iconSize} />
{:else}
<AlignLeft size={iconSize} />
{/if}
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Align left</span>
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Align center</span>
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Align right</span>
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
@@ -382,4 +443,40 @@
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Block-level formatting -->
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Divider"
data-testid="format-hr"
>
<Minus size={iconSize} />
</button>
</div>
+3 -9
View File
@@ -6,10 +6,9 @@
interface Props {
open: boolean;
onClose?: () => void;
appVersion?: string;
}
let { open, onClose, appVersion }: Props = $props();
let { open, onClose }: Props = $props();
function closeMobile() {
if (window.matchMedia('(max-width: 1023px)').matches) {
@@ -75,17 +74,12 @@
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
<a
href="/settings"
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
onclick={closeMobile}
data-testid="settings-link"
>
<Settings size={16} />
<Settings size={20} />
Settings
</a>
{#if appVersion}
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
v{appVersion}
</div>
{/if}
</div>
</aside>
+4 -34
View File
@@ -2,7 +2,6 @@
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import type { Note } from '$lib/types/index.js';
@@ -139,17 +138,17 @@
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-1" data-testid="note-checklist-preview">
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input
type="checkbox"
checked={item.checked}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
data-testid="card-checklist-checkbox"
/>
<span class="truncate">{item.text}</span>
<span class="break-words min-w-0">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
@@ -162,14 +161,6 @@
</div>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<TagChip {tag} />
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
@@ -246,24 +237,3 @@
{/if}
</article>
<style>
article :global(.prose li:has(.task-checkbox)) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
article :global(.prose li:has(.task-checkbox))::before {
display: none;
}
article :global(.prose li:has(.task-checkbox))::marker {
content: none;
}
article :global(.task-checkbox) {
margin-top: 0.15rem;
flex-shrink: 0;
}
</style>
+100 -7
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.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 Palette from 'lucide-svelte/icons/palette';
@@ -18,6 +21,7 @@
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
import Users from 'lucide-svelte/icons/users';
import History from 'lucide-svelte/icons/history';
interface Props {
note: Note | null;
@@ -26,19 +30,20 @@
}
const { note, isNew = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? 'default');
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note?.checklistMode ?? false);
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
@@ -52,7 +57,17 @@
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => clearTimeout(timer);
});
let showShareDialog = $state(false);
let showHistory = $state(false);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
const isOwner = $derived(note?.isOwner !== false);
@@ -82,6 +97,37 @@
showShareDialog = !showShareDialog;
}
async function toggleHistory() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showHistory = !showHistory;
}
async function handleHistoryRestored() {
if (!noteId) return;
// Reload the note content from the server after a restore
try {
const res = await fetch(`/api/notes/${noteId}`);
if (res.ok) {
const updated = await res.json();
title = updated.title ?? '';
content = updated.content ?? '';
color = updated.color ?? 'default';
checklistMode = updated.checklistMode ?? false;
// Bump editor tick to force TiptapEditor to re-render
editorTick++;
// Update the store too
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
);
}
} catch {
// failed silently
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
@@ -134,6 +180,21 @@
bgStyle = `background-color: ${colors.bg}`;
});
// Use pointer events to detect backdrop clicks. Unlike mouse events,
// pointer events are never synthetically re-dispatched from touch, so
// the tap that opened the editor on mobile cannot ghost-click the overlay.
// Both pointerdown and pointerup must target the overlay itself to close.
let pointerdownOnOverlay = false;
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
}
async function saveAndClose() {
if (!title.trim() && !content.trim()) {
onClose();
@@ -195,15 +256,15 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={saveAndClose}
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
@@ -214,6 +275,13 @@
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Content -->
@@ -258,11 +326,16 @@
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
@@ -339,6 +412,18 @@
<Users class="h-5 w-5" />
</span>
{/if}
<!-- History button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={toggleHistory}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
>
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
</button>
{/if}
</div>
<button
@@ -361,3 +446,11 @@
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
+1 -1
View File
@@ -43,7 +43,7 @@
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
data-testid="note-grid"
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
+224
View File
@@ -0,0 +1,224 @@
<script lang="ts">
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
import X from 'lucide-svelte/icons/x';
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
import Clock from 'lucide-svelte/icons/clock';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
onClose: () => void;
onRestored: () => void;
}
const { noteId, onClose, onRestored }: Props = $props();
let versions = $state<NoteVersionSummary[]>([]);
let selectedVersion = $state<NoteVersion | null>(null);
let isLoading = $state(true);
let isLoadingVersion = $state(false);
let isRestoring = $state(false);
let showConfirm = $state(false);
$effect(() => {
// Track noteId explicitly so this only re-runs when the note changes
noteId;
void loadVersions();
});
async function loadVersions() {
isLoading = true;
try {
const res = await fetch(`/api/notes/${noteId}/versions`);
if (res.ok) {
versions = await res.json();
}
} catch {
// failed silently
} finally {
isLoading = false;
}
}
async function selectVersion(versionId: string) {
isLoadingVersion = true;
selectedVersion = null;
try {
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
if (res.ok) {
selectedVersion = await res.json();
}
} catch {
// failed silently
} finally {
isLoadingVersion = false;
}
}
async function restoreVersion() {
if (!selectedVersion) return;
isRestoring = true;
try {
const res = await fetch(
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
{ method: 'POST' }
);
if (res.ok) {
showToast('Version restored successfully', 'success');
onRestored();
onClose();
} else {
showToast('Failed to restore version', 'error');
}
} catch {
showToast('Failed to restore version', 'error');
} finally {
isRestoring = false;
showConfirm = false;
}
}
function formatDate(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
data-testid="history-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="history-panel"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<div class="flex items-center gap-2">
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close history"
data-testid="close-history-btn"
>
<X class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
<div class="flex min-h-0 max-h-[60vh] flex-1">
<!-- Version list -->
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if versions.length === 0}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
No history yet
</div>
{:else}
<ul class="divide-y divide-[var(--border-subtle)]">
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
<div class="flex items-center justify-between gap-1">
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
</div>
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
{#if v.title}
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
{/if}
{#if v.contentPreview}
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Version preview -->
<div class="flex min-w-0 flex-1 flex-col">
{#if isLoadingVersion}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if selectedVersion}
<div class="flex flex-1 flex-col overflow-y-auto">
<div class="flex-1 overflow-y-auto px-4 py-3">
{#if selectedVersion.title}
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
{/if}
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
</div>
<!-- Restore footer -->
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
{#if showConfirm}
<div class="flex flex-col gap-2">
<p class="text-xs text-[var(--text-muted)]">
This will replace the current note. The current version will be saved as a snapshot.
</p>
<div class="flex gap-2">
<button
onclick={restoreVersion}
disabled={isRestoring}
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
data-testid="confirm-restore-btn"
>
<RotateCcw class="h-3.5 w-3.5" />
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
>
Cancel
</button>
</div>
</div>
{:else}
<button
onclick={() => (showConfirm = true)}
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
data-testid="restore-btn"
>
<RotateCcw class="h-3.5 w-3.5" />
Restore this version
</button>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Select a version to preview
</div>
{/if}
</div>
</div>
</div>
</div>
+12
View File
@@ -6,6 +6,12 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
interface Props {
@@ -30,6 +36,12 @@
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
const STRANGER_ID = 3;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
function shareNote(noteId: string) {
db.insert(noteCollaborators)
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('canAccessNote', () => {
it('should allow owner to access their note', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', OWNER_ID);
expect(result).toEqual({ canAccess: true, isOwner: true });
});
it('should allow collaborator to access shared note', () => {
seedNote('n1');
shareNote('n1');
const result = canAccessNote(db, 'n1', COLLAB_ID);
expect(result).toEqual({ canAccess: true, isOwner: false });
});
it('should deny non-collaborator access', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', STRANGER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
it('should return false for non-existent note', () => {
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
});
describe('requireNoteAccess', () => {
it('should throw 404 for unauthorized user', () => {
seedNote('n1');
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
});
it('should return isOwner for authorized owner', () => {
seedNote('n1');
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
});
});
describe('requireNoteOwnership', () => {
it('should throw 404 for collaborator', () => {
seedNote('n1');
shareNote('n1');
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
});
it('should not throw for owner', () => {
seedNote('n1');
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
});
});
describe('getUserId', () => {
it('should throw 401 when no user in locals', () => {
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
});
it('should return user id from locals', () => {
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
expect(id).toBe(42);
});
});
+5 -5
View File
@@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { notes, noteCollaborators } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
@@ -21,7 +21,7 @@ export function requireAdmin(event: EventWithLocals) {
/**
* Check if a user can access a note (as owner or collaborator).
*/
export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note) return { canAccess: false, isOwner: false };
if (note.userId === userId) return { canAccess: true, isOwner: true };
@@ -37,8 +37,8 @@ export function canAccessNote(noteId: string, userId: number): { canAccess: bool
/**
* Require that a user can access a note. Throws 404 if no access.
*/
export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(noteId, userId);
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
if (!canAccess) throw error(404, 'Not found');
return { isOwner };
}
@@ -46,7 +46,7 @@ export function requireNoteAccess(noteId: string, userId: number): { isOwner: bo
/**
* Require that a user is the owner of a note. Throws 404 if not owner.
*/
export function requireNoteOwnership(noteId: string, userId: number): void {
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note || note.userId !== userId) throw error(404, 'Not found');
}
+8 -7
View File
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
@@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
}
export async function saveAttachment(
db: Db,
noteId: string,
file: File,
userId: number,
@@ -59,7 +60,7 @@ export async function saveAttachment(
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -80,7 +81,7 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
return map;
}
export async function getAttachment(id: string, noteId: string) {
export async function getAttachment(db: Db, id: string, noteId: string) {
return db
.select()
.from(attachments)
@@ -88,11 +89,11 @@ export async function getAttachment(id: string, noteId: string) {
.get();
}
export async function getAttachmentsByNote(noteId: string) {
export async function getAttachmentsByNote(db: Db, noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
@@ -101,8 +102,8 @@ export async function updateAttachment(id: string, noteId: string, data: { featu
return updated ?? null;
}
export async function deleteAttachment(id: string, noteId: string) {
const attachment = await getAttachment(id, noteId);
export async function deleteAttachment(db: Db, id: string, noteId: string) {
const attachment = await getAttachment(db, id, noteId);
if (attachment) {
try {
await unlink(attachment.path);
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('fetchCollaboratorsForNotes', () => {
it('should return Map with user details for shared notes', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const map = fetchCollaboratorsForNotes(db, ['n1']);
const collabs = map.get('n1');
expect(collabs).toHaveLength(1);
expect(collabs![0].userId).toBe(COLLAB_ID);
expect(collabs![0].displayName).toBe('Collab');
expect(collabs![0].email).toBe('collab@test.com');
});
it('should return empty Map for notes with no collaborators', () => {
seedNote('n1');
const map = fetchCollaboratorsForNotes(db, ['n1']);
expect(map.size).toBe(0);
});
});
describe('addCollaborator', () => {
it('should insert collaborator record', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const rows = db.select().from(noteCollaborators).all();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(COLLAB_ID);
expect(rows[0].addedBy).toBe(OWNER_ID);
});
});
describe('removeCollaborator', () => {
it('should clean up noteUserState on removal', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
.run();
removeCollaborator(db, 'n1', COLLAB_ID);
const collabs = db.select().from(noteCollaborators).all();
expect(collabs).toHaveLength(0);
const states = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
.all();
expect(states).toHaveLength(0);
});
});
+5 -5
View File
@@ -1,4 +1,4 @@
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Collaborator } from '$lib/types/index.js';
@@ -7,7 +7,7 @@ import type { Collaborator } from '$lib/types/index.js';
* Batch fetch collaborators for multiple notes.
* Returns a Map of noteId → Collaborator[].
*/
export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Collaborator[]> {
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -44,7 +44,7 @@ export function fetchCollaboratorsForNotes(noteIds: string[]): Map<string, Colla
/**
* Add a collaborator to a note.
*/
export function addCollaborator(noteId: string, userId: number, addedBy: number): void {
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
db.insert(noteCollaborators)
.values({ noteId, userId, addedBy, addedAt: new Date() })
.run();
@@ -53,7 +53,7 @@ export function addCollaborator(noteId: string, userId: number, addedBy: number)
/**
* Remove a collaborator from a note. Also cleans up their per-user state.
*/
export function removeCollaborator(noteId: string, userId: number): void {
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.run();
@@ -65,7 +65,7 @@ export function removeCollaborator(noteId: string, userId: number): void {
/**
* Get all collaborator user IDs for a note.
*/
export function getCollaboratorIds(noteId: string): number[] {
export function getCollaboratorIds(db: Db, noteId: string): number[] {
return db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
+24
View File
@@ -115,6 +115,26 @@ sqlite.exec(`
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
checklist_mode INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT 'default',
created_at INTEGER NOT NULL,
UNIQUE(note_id, version)
);
`);
// Migration: detect old schema (no email column on users) and add columns
@@ -186,6 +206,9 @@ sqlite.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE INDEX IF NOT EXISTS note_versions_note_id_idx ON note_versions(note_id);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
@@ -247,4 +270,5 @@ if (pwCol && pwCol.notnull) {
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
export { sqlite };
+34
View File
@@ -175,3 +175,37 @@ export const loginAttempts = sqliteTable(
},
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
);
export const userPreferences = sqliteTable(
'user_preferences',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
key: text('key').notNull(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
},
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
);
export const noteVersions = sqliteTable(
'note_versions',
{
id: text('id').primaryKey(),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
version: integer('version').notNull(),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).notNull().default(false),
color: text('color').notNull().default('default'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
},
(table) => [
index('note_versions_note_id_idx').on(table.noteId),
uniqueIndex('note_versions_note_id_version_unique').on(table.noteId, table.version)
]
);
+24
View File
@@ -94,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) {
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
@@ -125,6 +127,28 @@ export function createTestDb(options?: { seedUser?: boolean }) {
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE TABLE note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
checklist_mode INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT 'default',
created_at INTEGER NOT NULL,
UNIQUE(note_id, version)
);
CREATE INDEX note_versions_note_id_idx ON note_versions(note_id);
`);
const db = drizzle(sqlite, { schema });
+353
View File
@@ -0,0 +1,353 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
let transporter: Transporter | null = null;
/**
* Check if SMTP email is configured via environment variables.
*/
export function isEmailConfigured(): boolean {
return !!process.env.SMTP_HOST;
}
/**
* Get or create the nodemailer transport (lazy singleton).
*/
function getTransporter(): Transporter {
if (!transporter) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_PORT === '465',
auth:
process.env.SMTP_USER
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS || ''
}
: undefined
});
}
return transporter;
}
/**
* Send an email. Best-effort: logs errors but does not throw.
*/
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
try {
const transport = getTransporter();
await transport.sendMail({
from: process.env.SMTP_FROM || 'Crumbs <noreply@localhost>',
to,
subject,
html
});
} catch (err) {
console.error('[email] Failed to send:', err);
}
}
/**
* Send a share notification email to a collaborator.
*/
export async function sendShareNotification(
toEmail: string,
toName: string,
fromName: string,
noteTitle: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = `${fromName} shared a note with you`;
const html = buildShareEmailHtml(toName, fromName, noteTitle, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a welcome email to a newly created user.
*/
export async function sendWelcomeEmail(
toEmail: string,
toName: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Welcome to Crumbs';
const html = buildWelcomeEmailHtml(toName, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a password reset notification to a user (triggered by admin reset).
*/
export async function sendPasswordResetEmail(
toEmail: string,
toName: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs password has been reset';
const html = buildPasswordResetEmailHtml(toName, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send an account deletion confirmation email.
*/
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs account has been deleted';
const html = buildAccountDeletedEmailHtml(toName);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to the old email when the account email address changes.
*/
export async function sendEmailChangedEmail(
oldEmail: string,
toName: string,
newEmail: string
): Promise<void> {
if (!oldEmail) return;
const subject = 'Your Crumbs email address has been changed';
const html = buildEmailChangedEmailHtml(toName, newEmail);
await sendEmail(oldEmail, subject, html);
}
/**
* Send a role change notification to a user.
*/
export async function sendRoleChangedEmail(
toEmail: string,
toName: string,
newRole: 'admin' | 'user',
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs account role has been updated';
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to a collaborator that they have been removed from a note.
*/
export async function sendCollaboratorRemovedEmail(
toEmail: string,
toName: string,
ownerName: string,
noteTitle: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = `You've been removed from a shared note`;
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to collaborators that a shared note has been permanently deleted.
*/
export async function sendNotePermanentlyDeletedEmail(
toEmail: string,
toName: string,
noteTitle: string
): Promise<void> {
if (!toEmail) return;
const subject = `A shared note has been deleted`;
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
await sendEmail(toEmail, subject, html);
}
/**
* Send a security alert to a user when their account is locked due to too many failed logins.
*/
export async function sendAccountLockedEmail(
toEmail: string,
toName: string,
retryAfterMinutes: number
): Promise<void> {
if (!toEmail) return;
const subject = 'Crumbs account temporarily locked';
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
await sendEmail(toEmail, subject, html);
}
function buildShareEmailHtml(
toName: string,
fromName: string,
noteTitle: string,
appUrl: string
): string {
const displayName = toName || 'there';
const escapedTitle = escapeHtml(noteTitle);
const escapedFrom = escapeHtml(fromName || 'Someone');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${escapeHtml(displayName)},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
<strong>${escapedFrom}</strong> shared a note with you: <strong>&ldquo;${escapedTitle}&rdquo;</strong>
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because someone shared a note with you on Crumbs.');
}
function buildEmailLayout(content: string, footer: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background-color:#f0e6d3;font-family:'JetBrains Mono',monospace;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0e6d3;padding:40px 20px;">
<tr><td align="center">
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
<tr><td style="padding:32px;">
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
${content}
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
</td></tr>
</table>`;
}
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because an admin created an account for you on Crumbs.');
}
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
An admin has reset your Crumbs password. Please log in and change it to something only you know.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because an admin reset your password on Crumbs. If you didn&rsquo;t expect this, please contact your admin.');
}
function buildAccountDeletedEmailHtml(toName: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account and all associated data have been permanently deleted.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this as confirmation that your Crumbs account was deleted.');
}
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this security notice because your Crumbs account email was updated.');
}
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
const detail = newRole === 'admin'
? 'You now have admin privileges, including the ability to manage users and settings.'
: 'Your account no longer has admin privileges.';
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because your account role was changed on Crumbs.');
}
function buildCollaboratorRemovedEmailHtml(
toName: string,
ownerName: string,
noteTitle: string,
appUrl: string
): string {
const displayName = escapeHtml(toName || 'there');
const escapedOwner = escapeHtml(ownerName || 'Someone');
const escapedTitle = escapeHtml(noteTitle);
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>&ldquo;${escapedTitle}&rdquo;</strong>. You no longer have access to this note.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because you were removed from a shared note on Crumbs.');
}
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
const displayName = escapeHtml(toName || 'there');
const escapedTitle = escapeHtml(noteTitle);
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
The shared note <strong>&ldquo;${escapedTitle}&rdquo;</strong> has been permanently deleted by its owner. You no longer have access to it.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because a shared note you had access to was deleted on Crumbs.');
}
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn&rsquo;t you, please change your password once you regain access.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+17 -16
View File
@@ -12,6 +12,7 @@ import {
reorderNotes
} from '../notes-service.js';
import { saveAttachment } from '../attachments.js';
import { db } from '../db/index.js';
const NOTE_COLORS = [
'default',
@@ -45,7 +46,7 @@ export function createMcpServer(userId: number): McpServer {
tag: z.string().optional().describe('Filter by tag name')
},
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
let result = listNotes(userId, filter as NoteFilter);
let result = listNotes(db, userId, filter as NoteFilter);
if (tag) {
result = result.filter((n) =>
@@ -64,7 +65,7 @@ export function createMcpServer(userId: number): McpServer {
'Get a single note by ID with its tags and attachments',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const note = getNote(userId, id);
const note = getNote(db, userId, id);
if (!note) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -88,7 +89,7 @@ export function createMcpServer(userId: number): McpServer {
checklistMode: z.boolean().optional().describe('Enable checklist mode')
},
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
const note = createNote(userId, { title, content, color, checklistMode });
const note = createNote(db, userId, { title, content, color, checklistMode });
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
@@ -107,7 +108,7 @@ export function createMcpServer(userId: number): McpServer {
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
},
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
const result = updateNote(userId, id, updates);
const result = updateNote(db, userId, id, updates);
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -126,7 +127,7 @@ export function createMcpServer(userId: number): McpServer {
'Move a note to trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: true });
const result = updateNote(db, userId, id, { trashed: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -144,7 +145,7 @@ export function createMcpServer(userId: number): McpServer {
'Restore a note from trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: false });
const result = updateNote(db, userId, id, { trashed: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -162,7 +163,7 @@ export function createMcpServer(userId: number): McpServer {
'Archive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: true });
const result = updateNote(db, userId, id, { archived: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -180,7 +181,7 @@ export function createMcpServer(userId: number): McpServer {
'Unarchive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: false });
const result = updateNote(db, userId, id, { archived: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -198,7 +199,7 @@ export function createMcpServer(userId: number): McpServer {
'Permanently delete a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const existing = getNote(userId, id);
const existing = getNote(db, userId, id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -206,7 +207,7 @@ export function createMcpServer(userId: number): McpServer {
};
}
deleteNote(userId, id);
deleteNote(db, userId, id);
return {
content: [
{
@@ -223,7 +224,7 @@ export function createMcpServer(userId: number): McpServer {
'Search notes by title, content, or tag name',
{ query: z.string().describe('Search query') },
async ({ query }: { query: string }) => {
const results = searchNotes(userId, query);
const results = searchNotes(db, userId, query);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
};
@@ -231,7 +232,7 @@ export function createMcpServer(userId: number): McpServer {
);
server.tool('list_tags', 'List all tags', {}, async () => {
const allTags = listAllTags(userId);
const allTags = listAllTags(db, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
};
@@ -245,7 +246,7 @@ export function createMcpServer(userId: number): McpServer {
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
},
async ({ id, pinned }: { id: string; pinned: boolean }) => {
const result = updateNote(userId, id, { pinned });
const result = updateNote(db, userId, id, { pinned });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -277,7 +278,7 @@ export function createMcpServer(userId: number): McpServer {
.describe('Array of note IDs with their new sort orders')
},
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
reorderNotes(userId, orders);
reorderNotes(db, userId, orders);
return {
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
};
@@ -292,7 +293,7 @@ export function createMcpServer(userId: number): McpServer {
imageUrl: z.string().url().describe('URL of the image to fetch')
},
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
const existing = getNote(userId, noteId);
const existing = getNote(db, userId, noteId);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -320,7 +321,7 @@ export function createMcpServer(userId: number): McpServer {
const filename = `upload_${Date.now()}.${ext}`;
const file = new File([buffer], filename, { type: contentType });
const attachment = await saveAttachment(noteId, file, userId);
const attachment = await saveAttachment(db, noteId, file, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
+336
View File
@@ -0,0 +1,336 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import {
listNotes,
getNote,
createNote,
updateNote,
deleteNote,
searchNotes,
reorderNotes
} from './notes-service.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
},
{
email: 'collab@test.com',
displayName: 'Collab',
role: 'user',
authProvider: 'password',
createdAt: new Date()
}
])
.run();
}
function seedNote(
id: string,
overrides: Partial<typeof notes.$inferInsert> = {}
) {
const now = new Date();
db.insert(notes)
.values({
id,
userId: OWNER_ID,
title: '',
content: '',
createdAt: now,
updatedAt: now,
...overrides
})
.run();
return id;
}
function shareNote(noteId: string, userId = COLLAB_ID) {
db.insert(noteCollaborators)
.values({
noteId,
userId,
addedBy: OWNER_ID,
addedAt: new Date()
})
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('listNotes', () => {
it('should return only active owned notes (excludes archived/trashed)', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('trashed', { title: 'Trashed', trashed: true });
const result = listNotes(db, OWNER_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Active');
});
it('should include shared notes with per-user state overlay', () => {
seedNote('shared', { title: 'Shared Note' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = listNotes(db, COLLAB_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Shared Note');
expect(result[0].pinned).toBeTruthy();
expect(result[0].sortOrder).toBe(5);
});
it('should sort pinned first, then by updatedAt desc', () => {
const old = new Date('2024-01-01');
const recent = new Date('2024-01-02');
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
const result = listNotes(db, OWNER_ID);
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
});
it('should return only archived notes with archived filter', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('shared', { title: 'Shared Archived' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
.run();
// Owner sees their archived note
const ownerResult = listNotes(db, OWNER_ID, 'archived');
expect(ownerResult).toHaveLength(1);
expect(ownerResult[0].title).toBe('Archived');
// Collaborator sees shared note they archived
const collabResult = listNotes(db, COLLAB_ID, 'archived');
expect(collabResult).toHaveLength(1);
expect(collabResult[0].title).toBe('Shared Archived');
});
it('should exclude shared notes from trashed filter', () => {
seedNote('trashed', { title: 'Trashed', trashed: true });
seedNote('shared', { title: 'Shared' });
shareNote('shared');
const result = listNotes(db, COLLAB_ID, 'trashed');
expect(result).toHaveLength(0);
});
});
describe('getNote', () => {
it('should return null for non-existent note', () => {
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
});
it('should return null if user has no access', () => {
seedNote('private', { title: 'Private' });
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
});
it('should overlay collaborator per-user state', () => {
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
.run();
const note = getNote(db, COLLAB_ID, 'shared');
expect(note).not.toBeNull();
expect(note!.pinned).toBeFalsy();
expect(note!.archived).toBeTruthy();
expect(note!.sortOrder).toBe(5);
});
});
describe('createNote', () => {
it('should generate UUID and set version=1', () => {
const note = createNote(db, OWNER_ID, { title: 'Test' });
expect(note.id).toBeDefined();
expect(note.id.length).toBe(36); // UUID format
expect(note.version).toBe(1);
});
it('should use provided ID when given', () => {
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
expect(note.id).toBe('custom-id');
});
it('should extract tags from content', () => {
const note = createNote(db, OWNER_ID, {
title: 'My #todo note',
content: 'Some #important content'
});
expect(note.tags).toContain('todo');
expect(note.tags).toContain('important');
});
});
describe('updateNote', () => {
it('should allow owner to update shared fields directly', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
expect(updated).not.toBeNull();
expect(updated!.title).toBe('Updated');
});
it('should allow collaborator to edit content', () => {
seedNote('n1', { title: 'Original', content: 'Old' });
shareNote('n1');
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
expect(updated).not.toBeNull();
expect(updated!.content).toBe('New content');
});
it('should route collaborator per-user fields to noteUserState', () => {
seedNote('n1', { title: 'Note', pinned: false });
shareNote('n1');
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
// Owner's note pinned should be unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.pinned).toBeFalsy();
// Collaborator sees their per-user state
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.pinned).toBeTruthy();
expect(collabView!.sortOrder).toBe(7);
});
it('should return null when collaborator tries to trash', () => {
seedNote('n1', { title: 'Note' });
shareNote('n1');
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
expect(result).toBeNull();
});
it('should increment version on update', () => {
seedNote('n1', { title: 'V1' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
expect(updated!.version).toBe(2);
});
it('should create a snapshot when content changes', () => {
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(1);
});
it('should not create a snapshot when no content field actually changed', () => {
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
// Send the same values — nothing actually changes
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
});
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
seedNote('n1', { title: 'Note', pinned: false });
updateNote(db, OWNER_ID, 'n1', { pinned: true });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
});
it('should extract and sync tags on title/content change', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
expect(updated!.tags).toContain('newtag');
});
});
describe('deleteNote', () => {
it('should allow owner to permanently delete', () => {
seedNote('n1', { title: 'Delete me' });
const result = deleteNote(db, OWNER_ID, 'n1');
expect(result).toBe(true);
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
});
it('should return false for non-existent or unauthorized', () => {
seedNote('n1', { title: 'Private' });
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
});
});
describe('searchNotes', () => {
it('should match title and content', () => {
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
const results = searchNotes(db, OWNER_ID, 'budget');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should include shared notes in results', () => {
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
shareNote('shared');
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('shared');
});
it('should return empty array for empty query', () => {
seedNote('n1', { title: 'Test' });
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
});
});
describe('reorderNotes', () => {
it('should update owner sortOrder in notes table', () => {
seedNote('n1', { title: 'A' });
seedNote('n2', { title: 'B' });
reorderNotes(db, OWNER_ID, [
{ id: 'n1', sortOrder: 2 },
{ id: 'n2', sortOrder: 1 }
]);
const n1 = getNote(db, OWNER_ID, 'n1');
const n2 = getNote(db, OWNER_ID, 'n2');
expect(n1!.sortOrder).toBe(2);
expect(n2!.sortOrder).toBe(1);
});
it('should update collaborator sortOrder in noteUserState', () => {
seedNote('n1', { title: 'Note', sortOrder: 0 });
shareNote('n1');
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
// Owner's sortOrder unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.sortOrder).toBe(0);
// Collaborator sees their sortOrder
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.sortOrder).toBe(5);
});
});
+45 -26
View File
@@ -1,4 +1,4 @@
import { db as defaultDb } from './db/index.js';
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
@@ -7,10 +7,9 @@ import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot } from './versions-service.js';
import type { NoteFilter } from '$lib/types/index.js';
const db = defaultDb;
interface NoteRow {
id: string;
userId: number;
@@ -28,11 +27,11 @@ interface NoteRow {
version: number;
}
function hydrateNotes(noteRows: NoteRow[], userId: number) {
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
const noteIds = noteRows.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const collabMap = fetchCollaboratorsForNotes(noteIds);
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
return noteRows.map((note) => {
const collaborators = collabMap.get(note.id) ?? [];
@@ -52,7 +51,7 @@ function hydrateNotes(noteRows: NoteRow[], userId: number) {
/**
* Get shared notes for a user with per-user state overlay.
*/
function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
const rows = db
.select({
id: notes.id,
@@ -90,7 +89,7 @@ function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] {
return rows as NoteRow[];
}
export function listNotes(userId: number, filter: NoteFilter = 'all') {
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
// Owned notes
let conditions;
switch (filter) {
@@ -114,7 +113,7 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
// Shared notes (not for trashed filter — collaborators don't see trash)
let sharedNotes: NoteRow[] = [];
if (filter !== 'trashed') {
sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all');
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
}
const combined = [...ownedNotes, ...sharedNotes];
@@ -124,11 +123,11 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') {
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return hydrateNotes(combined, userId);
return hydrateNotes(db, combined, userId);
}
export function getNote(userId: number, id: string) {
const { canAccess, isOwner } = canAccessNote(id, userId);
export function getNote(db: Db, userId: number, id: string) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const note = db.select().from(notes).where(eq(notes.id, id)).get();
@@ -151,7 +150,7 @@ export function getNote(userId: number, id: string) {
};
}
const result = hydrateNotes([effectiveNote], userId);
const result = hydrateNotes(db, [effectiveNote], userId);
return result[0] ?? null;
}
@@ -165,7 +164,7 @@ export interface CreateNoteInput {
sortOrder?: number;
}
export function createNote(userId: number, input: CreateNoteInput) {
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
const now = new Date();
const id = input.id || uuidv4();
@@ -192,7 +191,7 @@ export function createNote(userId: number, input: CreateNoteInput) {
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags, userId);
syncNoteTags(db, id, extractedTags, userId);
return { ...newNote, tags: extractedTags };
}
@@ -211,8 +210,8 @@ export interface UpdateNoteInput {
/** Per-user fields that go to noteUserState for collaborators */
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
const { canAccess, isOwner } = canAccessNote(id, userId);
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
@@ -291,14 +290,34 @@ export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
if (updated) {
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(id, extractedTags, existing.userId);
syncNoteTags(db, id, extractedTags, existing.userId);
}
}
return getNote(userId, id);
// Create a version snapshot only when at least one content field actually changed
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(input.content !== undefined && input.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
if (saved) {
createSnapshot(db, id, {
version: saved.version,
title: saved.title,
content: saved.content,
checklistMode: saved.checklistMode,
color: saved.color
});
}
}
return getNote(db, userId, id);
}
export function deleteNote(userId: number, id: string): boolean {
export function deleteNote(db: Db, userId: number, id: string): boolean {
const existing = db
.select()
.from(notes)
@@ -312,7 +331,7 @@ export function deleteNote(userId: number, id: string): boolean {
return true;
}
export function searchNotes(userId: number, query: string) {
export function searchNotes(db: Db, userId: number, query: string) {
if (!query) return [];
const pattern = `%${query}%`;
@@ -386,17 +405,17 @@ export function searchNotes(userId: number, query: string) {
}
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
return hydrateNotes(combined, userId);
return hydrateNotes(db, combined, userId);
}
export function listAllTags(userId: number) {
export function listAllTags(db: Db, userId: number) {
return db.select().from(tags).where(eq(tags.userId, userId)).all();
}
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
const now = new Date();
for (const { id, sortOrder } of orders) {
const { canAccess, isOwner } = canAccessNote(id, userId);
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) continue;
if (isOwner) {
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import type { Db } from './db/index.js';
import { getPreferences, upsertPreferences } from './preferences.js';
let db: Db;
const USER_ID = 1;
beforeEach(() => {
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
});
describe('getPreferences', () => {
it('should return empty object for user with no preferences', () => {
const prefs = getPreferences(db, USER_ID);
expect(prefs).toEqual({});
});
});
describe('upsertPreferences', () => {
it('should insert new preferences', () => {
const result = upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
});
it('should update existing preferences', () => {
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
expect(result.defaultNoteMode).toBe('richtext');
});
it('should handle partial updates without affecting other keys', () => {
upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'false'
});
});
it('should keep preferences isolated between users', () => {
// Create a second user
db.run(
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
);
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { Db } from './db/index.js';
import { userPreferences } from './db/schema.js';
import { eq } from 'drizzle-orm';
/**
* Get all preferences for a user as a key-value record.
*/
export function getPreferences(db: Db, userId: number): Record<string, string> {
const rows = db
.select({ key: userPreferences.key, value: userPreferences.value })
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
const result: Record<string, string> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
/**
* Upsert multiple preferences for a user.
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
*/
export function upsertPreferences(
db: Db,
userId: number,
prefs: Record<string, string>
): Record<string, string> {
const now = new Date();
for (const [key, value] of Object.entries(prefs)) {
db.insert(userPreferences)
.values({ userId, key, value, updatedAt: now })
.onConflictDoUpdate({
target: [userPreferences.userId, userPreferences.key],
set: { value, updatedAt: now }
})
.run();
}
return getPreferences(db, userId);
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { loginAttempts } from './db/schema.js';
import type { Db } from './db/index.js';
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
let db: Db;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
const IP = '192.168.1.1';
const EMAIL = 'test@test.com';
function addFailedAttempts(count: number, now: Date) {
for (let i = 0; i < count; i++) {
recordLoginAttempt(db, IP, EMAIL, false, now);
}
}
describe('checkRateLimit', () => {
it('should allow first attempt', () => {
const result = checkRateLimit(db, IP, EMAIL);
expect(result.allowed).toBe(true);
});
it('should allow up to 4 failed attempts', () => {
const now = new Date();
addFailedAttempts(4, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(true);
});
it('should block after 5 failed attempts within window', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeDefined();
expect(result.retryAfter!).toBeGreaterThan(0);
});
it('should return correct retryAfter value', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
// Lockout is 15 minutes (900 seconds)
expect(result.retryAfter).toBe(900);
});
it('should allow again after lockout expires', () => {
const attemptTime = new Date('2024-01-01T00:00:00Z');
addFailedAttempts(5, attemptTime);
// Check at attempt time — should be blocked
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
expect(blocked.allowed).toBe(false);
// Check 16 minutes later — should be allowed
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
const allowed = checkRateLimit(db, IP, EMAIL, later);
expect(allowed.allowed).toBe(true);
});
});
describe('recordLoginAttempt', () => {
it('should insert attempt record', () => {
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
expect(rows[0].ip).toBe(IP);
expect(rows[0].email).toBe(EMAIL);
});
it('should record success/failure flag correctly', () => {
recordLoginAttempt(db, IP, EMAIL, true);
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows[0].success).toBe(true);
expect(rows[1].success).toBe(false);
});
});
describe('clearOldAttempts', () => {
it('should remove attempts older than 2x lockout', () => {
const old = new Date('2024-01-01T00:00:00Z');
const recent = new Date('2024-01-01T01:00:00Z');
recordLoginAttempt(db, IP, EMAIL, false, old);
recordLoginAttempt(db, IP, EMAIL, false, recent);
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
clearOldAttempts(db, clearTime);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
});
});
+13 -15
View File
@@ -1,21 +1,19 @@
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { loginAttempts } from './db/schema.js';
import { and, eq, gte, sql } from 'drizzle-orm';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 1000; // 1 minute
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
export function checkRateLimit(
db: Db,
ip: string,
email: string
email: string,
now?: Date
): { allowed: boolean; retryAfter?: number } {
// Skip rate limiting in test environment
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
return { allowed: true };
}
const windowStart = new Date(Date.now() - LOCKOUT_MS);
const currentTime = now ?? new Date();
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
// Count failed attempts from this IP in the lockout window
const result = db
@@ -50,7 +48,7 @@ export function checkRateLimit(
if (latest) {
const retryAfter = Math.ceil(
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
);
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
}
@@ -60,20 +58,20 @@ export function checkRateLimit(
return { allowed: true };
}
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
db.insert(loginAttempts)
.values({
ip,
email,
success,
timestamp: new Date()
timestamp: now ?? new Date()
})
.run();
}
export function clearOldAttempts(): void {
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
export function clearOldAttempts(db: Db, now?: Date): void {
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
db.delete(loginAttempts)
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
.where(lt(loginAttempts.timestamp, cutoff))
.run();
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, tags, noteTags, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
let db: Db;
const USER_ID = 1;
function seedUser() {
db.insert(users)
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser();
});
describe('syncNoteTags', () => {
it('should create new tags and associations', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(2);
const associations = db.select().from(noteTags).all();
expect(associations).toHaveLength(2);
});
it('should remove old associations and add new ones', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
const map = fetchTagsForNotes(db, ['n1']);
expect(map.get('n1')).toEqual(['new-tag']);
});
it('should handle empty tag list (removes all)', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
syncNoteTags(db, 'n1', [], USER_ID);
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
expect(associations).toHaveLength(0);
});
it('should deduplicate existing tags', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
// Should reuse the same tag row
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(1);
});
});
describe('fetchTagsForNotes', () => {
it('should return Map of noteId to tag names', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
const map = fetchTagsForNotes(db, ['n1', 'n2']);
expect(map.get('n1')).toHaveLength(2);
expect(map.get('n1')).toContain('alpha');
expect(map.get('n1')).toContain('beta');
expect(map.get('n2')).toEqual(['gamma']);
});
it('should return empty map for empty noteIds', () => {
const map = fetchTagsForNotes(db, []);
expect(map.size).toBe(0);
});
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { noteTags, tags } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
@@ -6,7 +6,7 @@ import { eq, and, inArray } from 'drizzle-orm';
* Batch fetch tags for multiple notes in a single query.
* Returns a Map of noteId → tag names.
*/
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -32,7 +32,7 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
* Should be called within a transaction for atomicity.
*/
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) {
+207
View File
@@ -0,0 +1,207 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { users, notes, noteVersions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
let db: Db;
const OWNER_ID = 1;
const NOTE_ID = 'note-history-test';
function seedUser() {
db.insert(users)
.values({
id: OWNER_ID,
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
})
.run();
}
function seedNote(overrides: Partial<typeof notes.$inferInsert> = {}) {
const now = new Date();
db.insert(notes)
.values({
id: NOTE_ID,
userId: OWNER_ID,
title: 'Test Note',
content: 'Test content',
createdAt: now,
updatedAt: now,
version: 1,
...overrides
})
.run();
}
beforeEach(() => {
({ db } = createTestDb());
seedUser();
});
describe('createSnapshot', () => {
it('should store a snapshot for a note', () => {
seedNote();
createSnapshot(db, NOTE_ID, {
version: 1,
title: 'Test Note',
content: 'Test content',
checklistMode: false,
color: 'default'
});
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe(1);
expect(rows[0].title).toBe('Test Note');
expect(rows[0].content).toBe('Test content');
expect(rows[0].checklistMode).toBe(false);
expect(rows[0].color).toBe('default');
});
it('should not duplicate snapshots for the same version (onConflictDoNothing)', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'A', content: '', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 1, title: 'B', content: '', checklistMode: false, color: 'default' });
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].title).toBe('A'); // first one wins
});
it('should prune to at most 50 snapshots', () => {
seedNote();
for (let i = 1; i <= 55; i++) {
createSnapshot(db, NOTE_ID, {
version: i,
title: `Version ${i}`,
content: '',
checklistMode: false,
color: 'default'
});
}
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(50);
// Oldest (v1–v5) should be pruned; newest (v6–v55) should remain
const versions = rows.map((r) => r.version);
expect(Math.min(...versions)).toBe(6);
expect(Math.max(...versions)).toBe(55);
});
});
describe('listVersions', () => {
it('should return an empty array when there are no versions', () => {
seedNote();
expect(listVersions(db, NOTE_ID)).toEqual([]);
});
it('should return summaries sorted newest first', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'First', content: 'Hello world', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 2, title: 'Second', content: 'Updated content here', checklistMode: false, color: 'mint' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries).toHaveLength(2);
expect(summaries[0].version).toBe(2); // newest first
expect(summaries[1].version).toBe(1);
});
it('should truncate contentPreview to 80 characters', () => {
seedNote();
const longContent = 'a'.repeat(200);
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: longContent, checklistMode: false, color: 'default' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries[0].contentPreview).toHaveLength(80);
});
it('should include correct summary fields', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 3, title: 'My Title', content: 'My content', checklistMode: false, color: 'coral' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries[0]).toMatchObject({
noteId: NOTE_ID,
version: 3,
title: 'My Title',
contentPreview: 'My content'
});
expect(summaries[0].id).toBeTruthy();
expect(summaries[0].createdAt).toBeInstanceOf(Date);
});
});
describe('getVersion', () => {
it('should return null for a non-existent version', () => {
seedNote();
expect(getVersion(db, NOTE_ID, 'non-existent-id')).toBeNull();
});
it('should return the full version content', () => {
seedNote();
createSnapshot(db, NOTE_ID, {
version: 2,
title: 'Full Title',
content: '# Heading\n\nSome content',
checklistMode: true,
color: 'mint'
});
const summaries = listVersions(db, NOTE_ID);
const versionId = summaries[0].id;
const version = getVersion(db, NOTE_ID, versionId);
expect(version).not.toBeNull();
expect(version!.version).toBe(2);
expect(version!.title).toBe('Full Title');
expect(version!.content).toBe('# Heading\n\nSome content');
expect(version!.checklistMode).toBe(true);
expect(version!.color).toBe('mint');
});
it('should not return a version from a different note', () => {
const OTHER_NOTE_ID = 'other-note';
seedNote();
db.insert(notes).values({
id: OTHER_NOTE_ID,
userId: OWNER_ID,
title: 'Other',
content: '',
createdAt: new Date(),
updatedAt: new Date(),
version: 1
}).run();
createSnapshot(db, OTHER_NOTE_ID, { version: 1, title: 'Other', content: 'Other content', checklistMode: false, color: 'default' });
const summaries = listVersions(db, OTHER_NOTE_ID);
const versionId = summaries[0].id;
// Requesting version from wrong noteId returns null
expect(getVersion(db, NOTE_ID, versionId)).toBeNull();
});
});
describe('snapshotCurrentNote', () => {
it('should return false for a non-existent note', () => {
expect(snapshotCurrentNote(db, 'non-existent')).toBe(false);
});
it('should snapshot the current note state', () => {
seedNote({ title: 'Current Title', content: 'Current content', version: 5 });
const result = snapshotCurrentNote(db, NOTE_ID);
expect(result).toBe(true);
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe(5);
expect(rows[0].title).toBe('Current Title');
expect(rows[0].content).toBe('Current content');
});
});
+118
View File
@@ -0,0 +1,118 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
const MAX_VERSIONS_PER_NOTE = 50;
const CONTENT_PREVIEW_LENGTH = 80;
/**
* Create a snapshot of a note's current state.
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
*/
export function createSnapshot(
db: Db,
noteId: string,
snapshot: {
version: number;
title: string;
content: string;
checklistMode: boolean;
color: string;
}
): void {
const now = new Date();
db.insert(noteVersions)
.values({
id: uuidv4(),
noteId,
version: snapshot.version,
title: snapshot.title,
content: snapshot.content,
checklistMode: snapshot.checklistMode,
color: snapshot.color,
createdAt: now
})
.onConflictDoNothing()
.run();
// Prune: keep only the most recent MAX_VERSIONS_PER_NOTE snapshots
const allVersions = db
.select({ id: noteVersions.id })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.all();
if (allVersions.length > MAX_VERSIONS_PER_NOTE) {
const toDelete = allVersions.slice(MAX_VERSIONS_PER_NOTE).map((v) => v.id);
for (const id of toDelete) {
db.delete(noteVersions).where(eq(noteVersions.id, id)).run();
}
}
}
/**
* List version summaries for a note, newest first.
*/
export function listVersions(db: Db, noteId: string): NoteVersionSummary[] {
const rows = db
.select()
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.all();
return rows.map((row) => ({
id: row.id,
noteId: row.noteId,
version: row.version,
title: row.title,
contentPreview: row.content.slice(0, CONTENT_PREVIEW_LENGTH),
createdAt: row.createdAt
}));
}
/**
* Get the full content of a specific version.
*/
export function getVersion(db: Db, noteId: string, versionId: string): NoteVersion | null {
const row = db
.select()
.from(noteVersions)
.where(and(eq(noteVersions.id, versionId), eq(noteVersions.noteId, noteId)))
.get();
if (!row) return null;
return {
id: row.id,
noteId: row.noteId,
version: row.version,
title: row.title,
content: row.content,
checklistMode: row.checklistMode,
color: row.color as NoteColor,
createdAt: row.createdAt
};
}
/**
* Snapshot the current state of a note (used before a restore).
*/
export function snapshotCurrentNote(db: Db, noteId: string): boolean {
const currentNote = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (!currentNote) return false;
createSnapshot(db, noteId, {
version: currentNote.version,
title: currentNote.title,
content: currentNote.content,
checklistMode: currentNote.checklistMode,
color: currentNote.color
});
return true;
}
+114
View File
@@ -0,0 +1,114 @@
import { browser } from '$app/environment';
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
import type { UserPreferences } from '$lib/types/preferences.js';
const STORAGE_KEY = 'crumbs-preferences';
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
function loadFromLocalStorage(): UserPreferences {
if (!browser) return { ...DEFAULT_PREFERENCES };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return { ...DEFAULT_PREFERENCES, ...parsed };
}
} catch {
// Corrupted localStorage — use defaults
}
return { ...DEFAULT_PREFERENCES };
}
function saveToLocalStorage(prefs: UserPreferences) {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage full or unavailable
}
}
/** Convert server key-value record to typed UserPreferences */
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
const result: Partial<UserPreferences> = {};
for (const [key, value] of Object.entries(record)) {
if (key in DEFAULT_PREFERENCES) {
const k = key as keyof UserPreferences;
if (BOOLEAN_PREF_KEYS.has(k)) {
(result as Record<string, unknown>)[k] = value === 'true';
} else {
(result as Record<string, unknown>)[k] = value;
}
}
}
return result;
}
/** Convert UserPreferences to server key-value record */
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(prefs)) {
result[key] = String(value);
}
return result;
}
/**
* Initialize preferences — load from localStorage, then sync from server.
*/
export function initPreferences() {
preferences = loadFromLocalStorage();
syncPreferencesFromServer();
}
/**
* Get the reactive preferences object.
*/
export function getPreferences(): UserPreferences {
return preferences;
}
/**
* Update a single preference. Saves to localStorage and pushes to server.
*/
export function updatePreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) {
preferences = { ...preferences, [key]: value };
saveToLocalStorage(preferences);
pushToServer({ [key]: value });
}
/**
* Pull preferences from server and merge into local state.
*/
export async function syncPreferencesFromServer() {
if (!browser) return;
try {
const res = await fetch('/api/preferences');
if (res.ok) {
const serverPrefs: Record<string, string> = await res.json();
const parsed = fromServerRecord(serverPrefs);
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
saveToLocalStorage(preferences);
}
} catch {
// Offline — local state stands
}
}
/** Push preference changes to server */
async function pushToServer(partial: Partial<UserPreferences>) {
if (!browser) return;
try {
await fetch('/api/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toServerRecord(partial))
});
} catch {
// Offline — localStorage already updated
}
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
describe('preferences defaults', () => {
it('should have correct default values', () => {
expect(DEFAULT_PREFERENCES).toEqual({
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
});
});
it('should identify boolean preference keys', () => {
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
});
});
+19 -19
View File
@@ -51,7 +51,7 @@ describe('getChangesSince — shared notes', () => {
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
expect(changes[0].id).toBe('n1');
@@ -65,7 +65,7 @@ describe('getChangesSince — shared notes', () => {
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t2.getTime(), COLLAB_ID);
expect(changes).toHaveLength(0);
});
@@ -86,7 +86,7 @@ describe('getChangesSince — shared notes', () => {
sortOrder: 5
}).run();
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
// Should reflect the collaborator's per-user state, not the owner's
@@ -102,7 +102,7 @@ describe('getChangesSince — shared notes', () => {
// Owner has pinned=true, sortOrder=10
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
@@ -116,7 +116,7 @@ describe('getChangesSince — shared notes', () => {
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(0);
});
@@ -133,7 +133,7 @@ describe('getChangesSince — shared notes', () => {
.run();
// Collaborator syncs since t1
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
expect(changes[0].content).toBe('Updated by owner');
@@ -157,7 +157,7 @@ describe('getChangesSince — shared notes', () => {
// Note shared with COLLAB_ID (owned by OWNER_ID)
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db);
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(2);
const ids = changes.map((c) => c.id).sort();
@@ -171,12 +171,12 @@ describe('processSyncPush — shared notes', () => {
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated by collab', content: 'New content' }
}], COLLAB_ID, db);
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Updated by collab');
@@ -189,12 +189,12 @@ describe('processSyncPush — shared notes', () => {
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { pinned: true, sortOrder: 3 }
}], COLLAB_ID, db);
}], COLLAB_ID);
// Per-user fields should be in noteUserState, not in notes
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
@@ -213,12 +213,12 @@ describe('processSyncPush — shared notes', () => {
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { trashed: true }
}], COLLAB_ID, db);
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.trashed).toBe(false);
@@ -230,12 +230,12 @@ describe('processSyncPush — shared notes', () => {
// Collaborator's change has a timestamp BEFORE the server's updatedAt
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1,
data: { title: 'Stale edit' }
}], COLLAB_ID, db);
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Shared Note'); // Unchanged
@@ -263,12 +263,12 @@ describe('processSyncPush — shared notes', () => {
}).run();
const t2 = t1.getTime() + 1000;
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Hacked' }
}], 3, db); // User 3 is not owner or collaborator
}], 3); // User 3 is not owner or collaborator
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Private'); // Unchanged
@@ -279,12 +279,12 @@ describe('processSyncPush — shared notes', () => {
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush([{
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated' }
}], COLLAB_ID, db);
}], COLLAB_ID);
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
expect(logs).toHaveLength(1);
+4 -9
View File
@@ -1,18 +1,14 @@
import { db as defaultDb } from '$lib/server/db/index.js';
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import type { SyncQueueItem } from './idb.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<any>;
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise<void> {
const db = injectedDb ?? defaultDb;
export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId: number): Promise<void> {
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
@@ -140,7 +136,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content), note.userId);
syncNoteTags(db, noteId, extractTags(content), note.userId);
}
}
}
@@ -151,8 +147,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
* from noteUserState so collaborators get their own view.
*/
export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) {
const db = injectedDb ?? defaultDb;
export async function getChangesSince(db: Db, sinceTimestamp: number, userId: number) {
const since = new Date(sinceTimestamp);
// Owned notes
+23
View File
@@ -97,3 +97,26 @@ export type NoteColor =
export type NoteFilter = 'all' | 'archived' | 'trashed';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
export interface NoteVersionSummary {
id: string;
noteId: string;
version: number;
title: string;
contentPreview: string;
createdAt: Date;
}
export interface NoteVersion {
id: string;
noteId: string;
version: number;
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
createdAt: Date;
}
export type { UserPreferences } from './preferences.js';
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
+28
View File
@@ -0,0 +1,28 @@
import type { NoteColor } from './index.js';
export interface UserPreferences {
defaultNoteMode: 'richtext' | 'markdown';
defaultNoteColor: NoteColor;
hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed';
notifyOnShare: boolean;
notifyOnCollabRemoved: boolean;
notifyOnNoteDeleted: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
};
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
'hideFooter',
'notifyOnShare',
'notifyOnCollabRemoved',
'notifyOnNoteDeleted'
]);
+11
View File
@@ -24,6 +24,17 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
const token = new state.Token('html_inline', '', 0);
token.content = checkbox;
tokens[i].children!.unshift(token);
// Add task-list class to parent <ul> (once)
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'bullet_list_open') {
const cls = tokens[j].attrGet('class') ?? '';
if (!cls.includes('task-list')) {
tokens[j].attrJoin('class', 'task-list');
}
break;
}
}
}
}
});
+15 -5
View File
@@ -5,13 +5,21 @@
import { loadNotes } from '$lib/stores/notes.js';
import { startSync, stopSync } from '$lib/sync/client.js';
import { initDb } from '$lib/sync/idb.js';
import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js';
import { onMount, onDestroy } from 'svelte';
let { data, children } = $props();
let sidebarOpen = $state(false);
const prefs = $derived(getPreferences());
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
initPreferences();
const prefState = getPreferences();
if (prefState.sidebarDefaultState === 'collapsed') {
sidebarOpen = false;
} else {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
}
if (data.user) {
initDb(data.user.id);
}
@@ -26,7 +34,7 @@
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
<div class="mx-auto max-w-7xl px-4">
@@ -34,9 +42,11 @@
</div>
</main>
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> &mdash; made with 🥨 in Strasbourg
</footer>
{#if !prefs.hideFooter}
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}" data-testid="app-footer">
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> &mdash; made with 🥨 in Strasbourg
</footer>
{/if}
<Toast />
</div>
+23 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
import { User, Cpu, ShieldCheck, SlidersHorizontal, Info } from 'lucide-svelte';
let { data, children } = $props();
let pathname = $derived(page.url.pathname);
@@ -19,8 +19,18 @@
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
<li>
<a
href="/settings"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
href="/settings/preferences"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
data-testid="settings-nav-preferences"
>
<SlidersHorizontal size={16} />
Preferences
</a>
</li>
<li>
<a
href="/settings/profile"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<User size={16} />
Profile
@@ -32,7 +42,7 @@
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Cpu size={16} />
MCP Server
API
</a>
</li>
{#if data.user?.role === 'admin'}
@@ -46,6 +56,15 @@
</a>
</li>
{/if}
<li>
<a
href="/settings/about"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Info size={16} />
About
</a>
</li>
</ul>
</nav>
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(302, '/settings/preferences');
}
@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/stores';
let appVersion = $derived($page.data.appVersion);
</script>
<div class="space-y-8">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-[var(--text)]">About Crumbs</h2>
<span
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
>
v{appVersion}
</span>
</div>
<section
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
>
<h3 class="text-sm font-bold text-[var(--text)]">What is Crumbs?</h3>
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
Crumbs is a self-hostable note-taking app. Like breadcrumbs left along a trail, Crumbs
captures the small scattered thoughts, ideas, and notes you leave behind as you go — so you
can always find your way back to them.
</p>
</section>
<section
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
>
<h3 class="text-sm font-bold text-[var(--text)]">Bretzel App Universe</h3>
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
Crumbs is part of the
<a
href="https://bretzel.app"
target="_blank"
rel="noopener noreferrer"
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
> family of apps.
</p>
</section>
<section
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
>
<h3 class="text-sm font-bold text-[var(--text)]">Open Source</h3>
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
Crumbs is licensed under the MIT License. Source code is available on
<a
href="https://github.com/bretzel-app/crumbs/"
target="_blank"
rel="noopener noreferrer"
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub</a
>.
</p>
</section>
<section
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
>
<h3 class="text-sm font-bold text-[var(--text)]">Feedback</h3>
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
Feedback is welcome! Please open an issue on
<a
href="https://github.com/bretzel-app/crumbs/issues"
target="_blank"
rel="noopener noreferrer"
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">GitHub Issues</a
>.
</p>
</section>
<section
class="space-y-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-4 shadow-[var(--card-shadow)]"
>
<h3 class="text-sm font-bold text-[var(--text)]">Tech Stack</h3>
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
Built with SvelteKit, SQLite, and Drizzle ORM. Offline-first PWA with CRDT-based sync.
</p>
</section>
</div>
+12 -2
View File
@@ -122,7 +122,7 @@ ${mcpJsonConfig}`);
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
</div>
<p class="mb-4 text-xs text-[var(--text-muted)]">
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
API keys allow external tools to access your notes via the MCP protocol and the REST API.
</p>
<!-- Create Key Form -->
@@ -229,9 +229,19 @@ ${mcpJsonConfig}`);
{/if}
</section>
<!-- REST API Example -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">REST API</h2>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Use your API key to authenticate requests to any <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">/api/*</code> endpoint:
</p>
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 text-xs text-[var(--text)]">curl -H "Authorization: Bearer {activeApiKey}" \
{baseUrl}/api/notes</pre>
</section>
<!-- MCP Config Help -->
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">MCP Client Configuration</h2>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
</p>
@@ -0,0 +1,105 @@
<script lang="ts">
import ColorPicker from '$lib/components/ColorPicker.svelte';
import { getPreferences, updatePreference } from '$lib/stores/preferences.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
const prefs = $derived(getPreferences());
</script>
<div class="space-y-8">
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
<!-- Default note mode -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
<div class="flex gap-1" role="group" aria-label="Default note mode">
<button
onclick={() => updatePreference('defaultNoteMode', 'richtext')}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'richtext' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-mode-richtext"
>
Rich text
</button>
<button
onclick={() => updatePreference('defaultNoteMode', 'markdown')}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'markdown' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-mode-markdown"
>
Markdown
</button>
</div>
</div>
<!-- Default note color -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Default note color</span>
<ColorPicker
selected={prefs.defaultNoteColor}
onSelect={(color: NoteColor) => updatePreference('defaultNoteColor', color)}
/>
</div>
<!-- Hide footer -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="hide-footer"
checked={prefs.hideFooter}
onchange={() => updatePreference('hideFooter', !prefs.hideFooter)}
class="h-4 w-4 rounded-sm"
data-testid="pref-hide-footer"
/>
<label for="hide-footer" class="text-sm text-[var(--text)]">Hide footer</label>
</div>
<!-- Sidebar default state -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="sidebar-default"
checked={prefs.sidebarDefaultState === 'collapsed'}
onchange={() => updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')}
class="h-4 w-4 rounded-sm"
data-testid="pref-sidebar-default"
/>
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
</div>
<!-- Email notifications -->
<div class="space-y-3">
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-share"
checked={prefs.notifyOnShare}
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-share"
/>
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
</div>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-collab-removed"
checked={prefs.notifyOnCollabRemoved}
onchange={() => updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-collab-removed"
/>
<label for="notify-on-collab-removed" class="text-sm text-[var(--text)]">Email me when I'm removed from a shared note</label>
</div>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-note-deleted"
checked={prefs.notifyOnNoteDeleted}
onchange={() => updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-note-deleted"
/>
<label for="notify-on-note-deleted" class="text-sm text-[var(--text)]">Email me when a shared note I have access to is deleted</label>
</div>
</div>
</div>
+8
View File
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { listUsers, createUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
@@ -21,5 +22,12 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
}
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
// Send welcome email (fire-and-forget)
if (isEmailConfigured() && user.email) {
const origin = process.env.ORIGIN || 'http://localhost:3000';
sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {});
}
return json(user, { status: 201 });
};
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
@@ -11,12 +12,19 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (body.role !== undefined) {
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
}
updateUserRole(userId, body.role);
// Notify user of role change (fire-and-forget)
if (isEmailConfigured() && user.email) {
sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {});
}
}
if (body.newPassword !== undefined) {
@@ -24,6 +32,11 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
throw error(400, 'Password must be at least 8 characters');
}
await resetPassword(userId, body.newPassword);
// Notify user of password reset (fire-and-forget)
if (isEmailConfigured() && user.email) {
sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {});
}
}
if (body.revokeSessions) {
+9
View File
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
@@ -17,6 +18,9 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
if (!verified) throw error(401, 'Invalid password');
}
// Capture email/name before deletion
const { email: userEmail, displayName: userName } = user;
// Delete user and all associated data
await deleteUser(userId);
@@ -27,5 +31,10 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
cookies.delete('session', { path: '/' });
}
// Send deletion confirmation email (fire-and-forget)
if (isEmailConfigured() && userEmail) {
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
}
return json({ success: true });
};
+28 -3
View File
@@ -2,6 +2,10 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession } from '$lib/server/auth.js';
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js';
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const ip = getClientAddress();
@@ -11,7 +15,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
throw error(400, 'Email and password are required');
}
const rateCheck = checkRateLimit(ip, email);
const rateCheck = checkRateLimit(db, ip, email);
if (!rateCheck.allowed) {
return json(
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
@@ -21,11 +25,32 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const user = await verifyPassword(email, password);
if (!user) {
recordLoginAttempt(ip, email, false);
recordLoginAttempt(db, ip, email, false);
// Check if this failure just triggered a lockout — if so, notify the user
if (isEmailConfigured()) {
const newCheck = checkRateLimit(db, ip, email);
if (!newCheck.allowed) {
const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60);
const accountUser = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.email, email))
.get();
if (accountUser?.email) {
sendAccountLockedEmail(
accountUser.email,
accountUser.displayName,
retryAfterMinutes
).catch(() => {});
}
}
}
throw error(401, 'Invalid email or password');
}
recordLoginAttempt(ip, email, true);
recordLoginAttempt(db, ip, email, true);
const userAgent = request.headers.get('user-agent') || undefined;
const token = await createSession(user.id, { userAgent, ip });
+11 -1
View File
@@ -1,7 +1,8 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { updateUserProfile } from '$lib/server/auth.js';
import { updateUserProfile, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
@@ -11,6 +12,15 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
throw error(400, 'Email cannot be empty');
}
// Capture old email before update to notify on change
const existingUser = email !== undefined ? getUser(userId) : null;
updateUserProfile(userId, { displayName, email });
// Send notification to old email when email address changes
if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) {
sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {});
}
return json({ success: true });
};
+3 -2
View File
@@ -1,5 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId } from '$lib/server/api-utils.js';
import { listNotes, createNote } from '$lib/server/notes-service.js';
import type { NoteFilter } from '$lib/types/index.js';
@@ -7,13 +8,13 @@ import type { NoteFilter } from '$lib/types/index.js';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
const result = listNotes(userId, filter);
const result = listNotes(db, userId, filter);
return json(result);
};
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const newNote = createNote(userId, body);
const newNote = createNote(db, userId, body);
return json(newNote, { status: 201 });
};
+32 -4
View File
@@ -1,11 +1,15 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js';
import { getPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const note = getNote(userId, params.id);
const note = getNote(db, userId, params.id);
if (!note) throw error(404, 'Note not found');
return json(note);
@@ -14,7 +18,7 @@ export const GET: RequestHandler = async ({ params, ...event }) => {
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const updated = updateNote(userId, params.id, body);
const updated = updateNote(db, userId, params.id, body);
if (!updated) throw error(404, 'Note not found');
return json(updated);
@@ -22,10 +26,34 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(params.id, userId);
requireNoteOwnership(db, params.id, userId);
const deleted = deleteNote(userId, params.id);
// Fetch note and collaborators before deletion to send notifications
let collaboratorsToNotify: Array<{ email: string; displayName: string }> = [];
let noteTitle = 'Untitled';
if (isEmailConfigured()) {
const note = getNote(db, userId, params.id);
if (note) {
noteTitle = note.title || 'Untitled';
const collabMap = fetchCollaboratorsForNotes(db, [params.id]);
const collaborators = collabMap.get(params.id) ?? [];
for (const collab of collaborators) {
const prefs = getPreferences(db, collab.userId);
if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) {
collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName });
}
}
}
}
const deleted = deleteNote(db, userId, params.id);
if (!deleted) throw error(404, 'Note not found');
// Notify collaborators about the deleted note (fire-and-forget)
for (const collab of collaboratorsToNotify) {
sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {});
}
return json({ success: true });
};
@@ -2,16 +2,17 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId, params.id);
const attachment = await getAttachment(db, attachmentId, params.id);
if (!attachment) throw error(404, 'Attachment not found');
const wantThumb = url.searchParams.get('thumb') === '1';
@@ -28,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
});
}
const list = await getAttachmentsByNote(params.id);
const list = await getAttachmentsByNote(db, params.id);
return json(list);
};
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
requireNoteAccess(db, params.id, userId);
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -53,13 +54,13 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
}
const thumbnail = formData.get('thumbnail') as File | null;
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
const attachment = await saveAttachment(db, params.id, file, userId, thumbnail);
return json(attachment, { status: 201 });
};
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
@@ -67,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
const body = await request.json();
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
const updated = await updateAttachment(db, attachmentId, params.id, { featured: body.featured });
if (!updated) throw error(404, 'Attachment not found');
return json(updated);
@@ -75,11 +76,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId, params.id);
await deleteAttachment(db, attachmentId, params.id);
return json({ success: true });
};
@@ -5,18 +5,24 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
import { db } from '$lib/server/db/index.js';
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import {
isEmailConfigured,
sendShareNotification,
sendCollaboratorRemovedEmail
} from '$lib/server/email.js';
import { getPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(params.id, userId);
requireNoteAccess(db, params.id, userId);
const collaborators = fetchCollaboratorsForNotes([params.id]);
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? []);
};
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(params.id, userId);
requireNoteOwnership(db, params.id, userId);
const body = await request.json();
const targetUserId = body.userId;
@@ -46,9 +52,42 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
throw error(409, 'User is already a collaborator');
}
addCollaborator(params.id, targetUserId, userId);
addCollaborator(db, params.id, targetUserId, userId);
const collaborators = fetchCollaboratorsForNotes([params.id]);
// Send email notification (fire-and-forget)
if (isEmailConfigured()) {
const targetPrefs = getPreferences(db, targetUserId);
if (targetPrefs.notifyOnShare !== 'false') {
const target = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.id, targetUserId))
.get();
const sharer = db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, userId))
.get();
const noteData = db
.select({ title: notes.title })
.from(notes)
.where(eq(notes.id, params.id))
.get();
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (target?.email) {
sendShareNotification(
target.email,
target.displayName,
sharer?.displayName || 'Someone',
noteData?.title || 'Untitled',
origin
).catch(() => {});
}
}
}
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? [], { status: 201 });
};
@@ -60,14 +99,57 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
throw error(400, 'userId query parameter is required');
}
const { isOwner } = requireNoteAccess(params.id, userId);
const { isOwner } = requireNoteAccess(db, params.id, userId);
// Owner can remove anyone. Collaborator can only remove self.
if (!isOwner && targetUserId !== userId) {
throw error(403, 'Only the owner can remove other collaborators');
}
removeCollaborator(params.id, targetUserId);
// Fetch collaborator info and note title before removal for email notification
let removedUserEmail: string | undefined;
let removedUserName: string | undefined;
let noteTitle: string | undefined;
let ownerName: string | undefined;
if (isEmailConfigured() && isOwner && targetUserId !== userId) {
const targetPrefs = getPreferences(db, targetUserId);
if (targetPrefs.notifyOnCollabRemoved !== 'false') {
const target = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.id, targetUserId))
.get();
const noteData = db
.select({ title: notes.title })
.from(notes)
.where(eq(notes.id, params.id))
.get();
const owner = db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, userId))
.get();
removedUserEmail = target?.email ?? undefined;
removedUserName = target?.displayName ?? undefined;
noteTitle = noteData?.title ?? undefined;
ownerName = owner?.displayName ?? undefined;
}
}
removeCollaborator(db, params.id, targetUserId);
// Send notification to removed collaborator (fire-and-forget)
if (removedUserEmail) {
const origin = process.env.ORIGIN || 'http://localhost:3000';
sendCollaboratorRemovedEmail(
removedUserEmail,
removedUserName || '',
ownerName || 'Someone',
noteTitle || 'Untitled',
origin
).catch(() => {});
}
return json({ success: true });
};
@@ -0,0 +1,13 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { listVersions } from '$lib/server/versions-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const versions = listVersions(db, params.id);
return json(versions);
};
@@ -0,0 +1,15 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { getVersion } from '$lib/server/versions-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const version = getVersion(db, params.id, params.versionId);
if (!version) throw error(404, 'Version not found');
return json(version);
};
@@ -0,0 +1,29 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { getVersion, snapshotCurrentNote } from '$lib/server/versions-service.js';
import { updateNote } from '$lib/server/notes-service.js';
export const POST: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const version = getVersion(db, params.id, params.versionId);
if (!version) throw error(404, 'Version not found');
// Snapshot current state before overwriting so the user can undo the restore
snapshotCurrentNote(db, params.id);
// Apply the restored content (updateNote will increment version + create a new snapshot)
const restored = updateNote(db, userId, params.id, {
title: version.title,
content: version.content,
checklistMode: version.checklistMode,
color: version.color
});
if (!restored) throw error(404, 'Note not found');
return json(restored);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { reorderNotes } from '$lib/server/notes-service.js';
import { getUserId } from '$lib/server/api-utils.js';
@@ -12,6 +13,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
return json({ error: 'Invalid payload' }, { status: 400 });
}
reorderNotes(userId, orders);
reorderNotes(db, userId, orders);
return json({ ok: true });
};
+18
View File
@@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId } from '$lib/server/api-utils.js';
import { getPreferences, upsertPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const prefs = getPreferences(db, userId);
return json(prefs);
};
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body: Record<string, string> = await request.json();
const updated = upsertPreferences(db, userId, body);
return json(updated);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId } from '$lib/server/api-utils.js';
import { searchNotes } from '$lib/server/notes-service.js';
@@ -11,6 +12,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
return json([]);
}
const results = searchNotes(userId, query);
const results = searchNotes(db, userId, query);
return json(results);
};
+5 -4
View File
@@ -4,6 +4,7 @@ import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { db } from '$lib/server/db/index.js';
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
@@ -13,18 +14,18 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes, userId);
await processSyncPush(db, changes, userId);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since, userId);
const changes = await getChangesSince(db, since, userId);
const noteIds = changes.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const changesWithTags = changes.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
+3 -7
View File
@@ -72,7 +72,7 @@ test.describe('Image Attachments', () => {
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// When the user features the image by clicking the star
// When the user features the image
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click();
@@ -91,8 +91,6 @@ test.describe('Image Attachments', () => {
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// Feature the image
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click();
await page.getByTestId('close-editor-btn').click();
@@ -155,7 +153,7 @@ test.describe('Image Attachments', () => {
});
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
// When the user opens a new note and clicks the image toggle
// When the user opens a new note and enables the image panel
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-Save Test');
await page.getByTestId('image-toggle').click();
@@ -163,10 +161,8 @@ test.describe('Image Attachments', () => {
// Then the upload panel is visible (note was auto-saved)
await expect(page.getByTestId('image-upload')).toBeVisible();
// Cleanup
// And the note persists in the list after closing
await page.getByTestId('close-editor-btn').click();
// And the note exists in the list
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
});
});
+4 -3
View File
@@ -70,10 +70,10 @@ test.describe.serial('Authentication', () => {
});
test.describe('Multi-user', () => {
test('admin can create a new user via API', async ({ authenticatedPage: page }) => {
// authenticatedPage fixture already handles setup/login
test('Scenario: Admin-created user is registered successfully', async ({ authenticatedPage: page }) => {
// Given the admin is authenticated
// Create a new user via admin API
// When the admin creates a new user
const response = await page.request.post('/api/admin/users', {
data: {
email: 'user2@test.com',
@@ -83,6 +83,7 @@ test.describe('Multi-user', () => {
}
});
// Then the user is created successfully
expect(response.status()).toBe(201);
});
});
+37 -33
View File
@@ -1,7 +1,23 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/** Create a checklist note via UI. Leaves the editor closed. */
async function createChecklistNote(page: Page, title: string, items: string[]) {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill(title);
await page.getByTestId('checklist-toggle').click();
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
if (i < items.length - 1) {
await page.getByTestId('checklist-input').nth(i).press('Enter');
}
}
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, title)).toBeVisible();
}
test.describe('Checklist', () => {
test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => {
test('Scenario: Checklist replaces the rich text editor when enabled', async ({ authenticatedPage: page }) => {
// Given the user is creating a new note
await page.getByTestId('new-note-btn').click();
@@ -15,16 +31,9 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('tiptap-editor')).not.toBeVisible();
});
test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => {
// When the user creates a checklist note with an item "Buy milk"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Shopping List');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Buy milk');
await page.getByTestId('close-editor-btn').click();
// Then the note appears in the list
await expect(page.getByText('Shopping List')).toBeVisible();
test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk" exists
await createChecklistNote(page, 'Shopping List', ['Buy milk']);
// When the user reopens the note
await noteCard(page, 'Shopping List').click();
@@ -34,13 +43,9 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
});
test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Tasks');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Buy milk');
await page.getByTestId('close-editor-btn').click();
test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk" exists
await createChecklistNote(page, 'Tasks', ['Buy milk']);
// When the user reopens the note and checks the item
await noteCard(page, 'Tasks').click();
@@ -50,13 +55,13 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await page.getByTestId('close-editor-btn').click();
// And reopening the note shows the item in the done section (expanded by default)
// And the done state persists after reopening
await noteCard(page, 'Tasks').click();
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
});
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
// Given the user is editing a checklist note
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given a checklist with one item
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
@@ -68,46 +73,45 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
});
test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => {
// Given a checklist note with two items
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
// Given a checklist with two items where the second is empty
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
// When the user presses Backspace on the empty second item
// When the user presses Backspace on the empty item
await page.getByTestId('checklist-input').nth(1).press('Backspace');
// Then only the first item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
});
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
// Given a checklist note with a completed item and an uncompleted item
test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => {
// Given a checklist note with two items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
// When the user completes the first item
await page.getByTestId('checklist-checkbox').first().click();
// Then the completed item is separated from the active list
// Then only the pending item remains in the active list
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
// And the "done" toggle shows the count
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
// And the done section shows the completed item
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
// Then the completed items are shown in the done section (expanded by default)
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
});
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
// Given the user has checklist mode enabled
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
// Given checklist mode is enabled on a new note
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await expect(page.getByTestId('checklist')).toBeVisible();
+1 -49
View File
@@ -1,5 +1,5 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page, Locator } from '@playwright/test';
import type { Page } from '@playwright/test';
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
async function runTiptapCommand(page: Page, commandFn: string) {
@@ -38,54 +38,6 @@ test.describe('Rich text formatting', () => {
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
});
test('Scenario: Italic shortcut formats selected text in the editor', async ({ authenticatedPage: page }) => {
// Given the user is editing a new note with content "hello world"
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'hello world');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies italic formatting
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleItalic().run()');
// Then the selected text appears italic in the editor
await expect(editor.locator('em')).toHaveText('world');
});
test('Scenario: Formatting toolbar applies bold to selected text', async ({ authenticatedPage: page }) => {
// Given the user is editing a note
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'hello world');
// When the user selects "world" and clicks the bold button
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).run()');
await page.getByTestId('format-bold').click();
// Then the selected text appears bold
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await expect(editor.locator('strong')).toHaveText('world');
});
test('Scenario: Formatting toolbar is always visible when editing', async ({ authenticatedPage: page }) => {
// When the user opens a new note
await page.getByTestId('new-note-btn').click();
// Then the formatting toolbar is visible immediately
await expect(page.getByTestId('formatting-toolbar')).toBeVisible();
});
test('Scenario: Inline code shortcut wraps selected text', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with content "use npm install"
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'use npm install');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "npm install" and applies code formatting
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:5,to:16}).toggleCode().run()');
// Then the selected text appears as inline code
await expect(editor.locator('code')).toHaveText('npm install');
});
test('Scenario: Existing note shows rich text in TipTap editor', async ({ authenticatedPage: page }) => {
// Given a note with bold markdown content exists
await page.getByTestId('new-note-btn').click();
+24
View File
@@ -0,0 +1,24 @@
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import type { Page } from '@playwright/test';
const COVERAGE_DIR = join(process.cwd(), 'coverage', 'e2e', 'raw');
let counter = 0;
/**
* Collect Istanbul coverage data from the browser page.
* The vite-plugin-istanbul injects `window.__coverage__` when COVERAGE=true.
*/
export async function collectCoverage(page: Page, testTitle: string) {
if (process.env.VITE_COVERAGE !== 'true') return;
const coverage = await page.evaluate(() => (window as any).__coverage__);
if (!coverage) return;
mkdirSync(COVERAGE_DIR, { recursive: true });
const safeName = testTitle.replace(/[^a-zA-Z0-9-_]/g, '_');
const filename = `coverage-${safeName}-${counter++}-${Date.now()}.json`;
writeFileSync(join(COVERAGE_DIR, filename), JSON.stringify(coverage));
}
+49 -3
View File
@@ -1,9 +1,14 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
import { collectCoverage } from './coverage';
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { hasText: title });
@@ -12,11 +17,39 @@ export function noteCard(page: Page, title: string): Locator {
/**
* Extended test fixture that handles setup/auth.
*/
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
export const test = base.extend<{ authenticatedPage: Page; collabPage: Page }>({
authenticatedPage: async ({ page }, use, testInfo) => {
// Setup: create user and login
await setupAndLogin(page);
await use(page);
// Teardown: collect coverage data from the browser
await collectCoverage(page, testInfo.title);
},
collabPage: async ({ browser, authenticatedPage }, use, testInfo) => {
// Create collab user via admin API (idempotent)
const res = await authenticatedPage.request.post('/api/admin/users', {
data: {
email: COLLAB_EMAIL,
displayName: COLLAB_NAME,
password: COLLAB_PASSWORD,
role: 'user'
}
});
// Ignore 409 — user already exists
// Login as collaborator in a new browser context
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
await page.waitForLoadState('networkidle');
await use(page);
// Teardown: collect coverage data from the browser
await collectCoverage(page, testInfo.title + '-collab');
await context.close();
}
});
@@ -57,4 +90,17 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle');
}
export { expect, TEST_EMAIL, TEST_PASSWORD };
/** Create a note via the UI and close the editor. Use in Given steps for state setup. */
export async function createNote(page: Page, title: string, content?: string) {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill(title);
if (content) {
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially(content);
}
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, title)).toBeVisible();
}
export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
+4 -35
View File
@@ -1,14 +1,9 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Notes CRUD', () => {
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "My First Note" with content "Hello world!"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('My First Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Hello world!');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'My First Note', 'Hello world!');
// Then the note is visible in the notes list
await expect(noteCard(page, 'My First Note')).toBeVisible();
@@ -16,12 +11,7 @@ test.describe('Notes CRUD', () => {
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
// Given a note titled "Original Title" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Original Title');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Original content');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Original Title', 'Original content');
// When the user changes the title to "Updated Title"
await noteCard(page, 'Original Title').click();
@@ -35,9 +25,7 @@ test.describe('Notes CRUD', () => {
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Delete Me');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Delete Me');
// When the user trashes the note
const card = noteCard(page, 'Delete Me');
@@ -47,23 +35,4 @@ test.describe('Notes CRUD', () => {
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
});
test('Scenario: Bold formatting applies to selected text in the editor', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with content "hello world"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('markdown-toggle').click();
await page.getByTestId('note-content-input').fill('hello world');
await page.getByTestId('markdown-toggle').click();
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies bold formatting
await page.getByTestId('tiptap-editor').evaluate((el) => {
const ed = (el as any).__tiptapEditor;
if (!ed) return;
ed.chain().focus().setTextSelection({ from: 7, to: 12 }).toggleBold().run();
});
// Then the selected text appears bold in the editor
await expect(editor.locator('strong')).toHaveText('world');
});
});
+21 -32
View File
@@ -1,7 +1,7 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Offline Support', () => {
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
test('Scenario: Sync status indicator is visible on the main page', async ({ authenticatedPage: page }) => {
// Given the user is authenticated
// Then the sync status indicator is displayed
await expect(page.getByTestId('sync-indicator')).toBeVisible();
@@ -9,9 +9,7 @@ test.describe('Offline Support', () => {
test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "Offline Test Note"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Test Note');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Offline Test Note');
// Then the note appears in the list
await expect(page.getByText('Offline Test Note')).toBeVisible();
@@ -19,10 +17,7 @@ test.describe('Offline Support', () => {
test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Edit" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Edit');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Edit')).toBeVisible();
await createNote(page, 'Offline Edit');
// When the network is unavailable
await page.context().setOffline(true);
@@ -33,10 +28,10 @@ test.describe('Offline Support', () => {
await page.getByTestId('note-title-input').fill('Offline Edited');
await page.getByTestId('close-editor-btn').click();
// Then the note "Offline Edited" appears in the list
// Then the updated note appears in the list
await expect(page.getByText('Offline Edited')).toBeVisible();
// And a "Saved offline" toast is shown
// And an offline save confirmation is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
@@ -49,28 +44,30 @@ test.describe('Offline Support', () => {
await page.getByTestId('note-title-input').fill('Offline New');
await page.getByTestId('close-editor-btn').click();
// Then the note "Offline New" appears in the list
// Then the note appears in the list
await expect(page.getByText('Offline New')).toBeVisible();
// And a "Saved offline" toast is shown
// And an offline save confirmation is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => {
// When the network is unavailable
// Given the network is unavailable
await page.context().setOffline(true);
// And the user creates a note titled "Survive Reload"
// And a note titled "Survive Reload" is created offline
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Survive Reload');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Survive Reload')).toBeVisible();
// When the network is restored (triggers 'online' event + navigator.onLine = true)
// When the network is restored and sync completes
const syncResponse = page.waitForResponse((resp) => resp.url().includes('/api/sync'));
await page.context().setOffline(false);
// And the sync completes
await page.waitForResponse('**/api/sync');
// Manually dispatch the online event as a safety net — in CI/headless
// environments, setOffline(false) doesn't always emit it reliably
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await syncResponse;
// And the user reloads the page
await page.reload();
@@ -81,27 +78,19 @@ test.describe('Offline Support', () => {
test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
// Given a note titled "Cached Note" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Cached Note');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Cached Note')).toBeVisible();
await createNote(page, 'Cached Note');
// When the API is unavailable (simulating service worker serving cached shell)
// When the API becomes unavailable and the page is reloaded
await page.route('**/api/**', (route) => route.abort());
// And the user reloads the page
await page.reload();
// Then the note is still visible (loaded from IDB cache)
// Then the note is still visible from the local cache
await expect(page.getByText('Cached Note')).toBeVisible();
});
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Trash" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Trash');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Trash')).toBeVisible();
await createNote(page, 'Offline Trash');
// When the network is unavailable
await page.context().setOffline(true);
@@ -114,7 +103,7 @@ test.describe('Offline Support', () => {
// Then the note is no longer visible
await expect(page.getByText('Offline Trash')).not.toBeVisible();
// And a "Saved offline" toast is shown
// And an offline save confirmation is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
});
+18 -77
View File
@@ -1,11 +1,9 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Organization Features', () => {
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
// Given a note titled "Pin Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Pin Me');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Pin Me');
// When the user pins the note
const pinCard = noteCard(page, 'Pin Me');
@@ -18,9 +16,7 @@ test.describe('Organization Features', () => {
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Archive Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Archive Me');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Archive Me');
// When the user archives the note
const archiveCard = noteCard(page, 'Archive Me');
@@ -31,101 +27,46 @@ test.describe('Organization Features', () => {
await expect(page.getByText('Archive Me')).not.toBeVisible();
});
test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => {
// Given the user is creating a note titled "Colored Note"
test('Scenario: Note card reflects the selected color', async ({ authenticatedPage: page }) => {
// Given the user is creating a new note
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Colored Note');
// When the user sets the note color to "Coral"
// When the user selects the "Coral" color
await page.getByTestId('color-picker-toggle').click();
await page.getByTestId('color-coral').click();
await page.getByTestId('close-editor-btn').click();
// Then the note is saved and visible in the notes list
// Then the note is visible in the notes list
await expect(noteCard(page, 'Colored Note')).toBeVisible();
});
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
// Given a note tagged #important and an untagged note exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Tagged Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('This is #important');
await page.getByTestId('close-editor-btn').click();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Untagged Note');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Tagged Note', 'This is #important');
await createNote(page, 'Untagged Note');
// When the user filters by the #important tag
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
if (await tagChip.isVisible()) {
await tagChip.click();
await expect(tagChip).toBeVisible();
await tagChip.click();
// Then only the tagged note is visible
await expect(page.getByText('Tagged Note').first()).toBeVisible();
}
// Then only the tagged note is visible
await expect(page.getByText('Tagged Note').first()).toBeVisible();
});
});
test.describe('Note Sorting', () => {
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
// Given two notes exist with different update times
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sort-First');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Sort-First')).toBeVisible();
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
// Given two notes exist created at different times
await createNote(page, 'Sort-First');
await createNote(page, 'Sort-Second');
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sort-Second');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
// When the user selects "Updated" sort mode
await page.getByTestId('sort-updated').click();
// Then Sort-Second (most recently updated) appears before Sort-First
// Then the most recently created note appears before the older one
const sortFirst = noteCard(page, 'Sort-First');
const sortSecond = noteCard(page, 'Sort-Second');
const firstBox = await sortSecond.boundingBox();
const secondBox = await sortFirst.boundingBox();
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
});
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
// Given two notes exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Created-Older');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Created-Older')).toBeVisible();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Created-Newer');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
// When the user selects "Created" sort mode
await page.getByTestId('sort-created').click();
// Then Created-Newer appears before Created-Older
const newer = noteCard(page, 'Created-Newer');
const older = noteCard(page, 'Created-Older');
const newerBox = await newer.boundingBox();
const olderBox = await older.boundingBox();
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
});
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
// Given the user selects "Created" sort mode
await page.getByTestId('sort-created').click();
// When the page is reloaded
await page.reload();
await page.waitForLoadState('networkidle');
// Then the "Created" sort mode is still active
const createdBtn = page.getByTestId('sort-created');
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
});
});
+4 -17
View File
@@ -1,19 +1,13 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import { test, expect, createNote } from './helpers/fixtures.js';
test.describe('Search', () => {
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
// Given a note titled "Grocery Errands" and a note titled "Work Meeting" exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Grocery Errands');
await page.getByTestId('close-editor-btn').click();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Work Meeting');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Grocery Errands');
await createNote(page, 'Work Meeting');
// When the user searches for "Grocery"
await page.getByTestId('search-input').fill('Grocery');
await page.waitForTimeout(500);
// Then "Grocery Errands" is visible in the results
await expect(page.getByText('Grocery Errands')).toBeVisible();
@@ -21,16 +15,10 @@ test.describe('Search', () => {
test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => {
// Given a note titled "Recipe" with content "Pasta with tomato sauce" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Recipe');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Pasta with tomato sauce');
await page.getByTestId('close-editor-btn').click();
await createNote(page, 'Recipe', 'Pasta with tomato sauce');
// When the user searches for "tomato"
await page.getByTestId('search-input').fill('tomato');
await page.waitForTimeout(500);
// Then "Recipe" is visible in the results
await expect(page.getByText('Recipe')).toBeVisible();
@@ -39,7 +27,6 @@ test.describe('Search', () => {
test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => {
// When the user searches for a term that matches nothing
await page.getByTestId('search-input').fill('xyznonexistent');
await page.waitForTimeout(500);
// Then no notes are displayed
await expect(page.getByTestId('note-card')).toHaveCount(0);
+149 -15
View File
@@ -1,12 +1,143 @@
import { test, expect } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
const DEFAULT_PREFS = {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open'
};
/**
* Reset preferences to defaults on both server and client localStorage.
*
* Server reset ensures GET /api/preferences returns defaults.
* localStorage reset ensures initPreferences() picks up correct values
* even if the sync request is blocked or returns stale data.
*/
async function resetPreferences(page: Page) {
await page.request.put('/api/preferences', {
data: {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: 'false',
sidebarDefaultState: 'open'
}
});
await page.evaluate(
(prefs) => localStorage.setItem('crumbs-preferences', JSON.stringify(prefs)),
DEFAULT_PREFS
);
}
/**
* Navigate to a URL with reliable preference state.
*
* page.goto() triggers a full page reload which re-runs initPreferences():
* 1. loadFromLocalStorage() — synchronous, returns correct value
* 2. syncPreferencesFromServer() — async, can return stale data from
* parallel test workers that share the same user's server-side prefs
*
* This helper blocks the one GET /api/preferences sync request so
* initPreferences() falls back to localStorage (which is always correct
* from either resetPreferences or updatePreference on the settings page).
*/
async function gotoWithStablePrefs(page: Page, url: string) {
await page.route(
'**/api/preferences',
(route) => route.abort(),
{ times: 1 }
);
await page.goto(url);
}
test.describe('Settings — Preferences', () => {
test('Scenario: Preferences tab is visible in settings nav', async ({
authenticatedPage: page
}) => {
// When the user navigates to settings
await page.goto('/settings');
// Then the Preferences nav link is visible
await expect(page.getByTestId('settings-nav-preferences')).toBeVisible();
});
test('Scenario: Default note mode change applies to new notes', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given the user sets default note mode to Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user creates a new note
await gotoWithStablePrefs(page, '/');
await page.getByTestId('new-note-btn').click();
// Then the note editor opens in markdown mode (textarea visible)
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given the footer is visible
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).toBeVisible();
// When the user enables the hide footer preference
await gotoWithStablePrefs(page, '/settings/preferences');
await page.getByTestId('pref-hide-footer').check();
// Then the footer is hidden
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).not.toBeVisible();
// When the user disables the hide footer preference
await gotoWithStablePrefs(page, '/settings/preferences');
await page.getByTestId('pref-hide-footer').uncheck();
// Then the footer is visible again
await gotoWithStablePrefs(page, '/');
await expect(page.getByTestId('app-footer')).toBeVisible();
});
test('Scenario: Preferences persist across page reload', async ({
authenticatedPage: page
}) => {
await resetPreferences(page);
// Given the user changes default note mode to Markdown
await gotoWithStablePrefs(page, '/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the page is reloaded (block sync to prevent parallel-test interference)
await page.route('**/api/preferences', (route) => route.abort(), { times: 1 });
await page.reload();
// Then the Markdown button is still selected (has primary styling)
const mdBtn = page.getByTestId('pref-mode-markdown');
await expect(mdBtn).toHaveClass(/font-medium/);
});
});
test.describe('Settings — API Key Management', () => {
test('Scenario: Created API key appears in the keys list', async ({
authenticatedPage: page
}) => {
// Use a unique name to avoid strict-mode violations from parallel test runs
const keyName = `Test Key ${Date.now()}`;
// When the user navigates to the MCP settings and creates an API key
await page.goto('/settings/mcp');
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
await page.getByTestId('api-key-name-input').fill(keyName);
await page.getByTestId('create-api-key-btn').click();
// Then the key is shown once for copying
@@ -15,20 +146,22 @@ test.describe('Settings — API Key Management', () => {
expect(keyValue).toMatch(/^crumbs_/);
// And it appears in the keys list
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
await expect(keyItem).toBeVisible();
});
test('Scenario: Revoked API key disappears from the keys list', async ({
authenticatedPage: page
}) => {
const keyName = `Revoke Key ${Date.now()}`;
// Given an API key exists
await page.goto('/settings/mcp');
await page.getByTestId('api-key-name-input').fill('Revoke Me');
await page.getByTestId('api-key-name-input').fill(keyName);
await page.getByTestId('create-api-key-btn').click();
await expect(page.getByTestId('created-key-display')).toBeVisible();
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
await expect(keyItem).toBeVisible();
// When the user revokes the key
@@ -39,33 +172,34 @@ test.describe('Settings — API Key Management', () => {
await expect(keyItem).not.toBeVisible();
});
test('Scenario: Settings page is accessible from sidebar', async ({
test('Scenario: Sidebar settings link opens the preferences page', async ({
authenticatedPage: page
}) => {
// Given the user is on the main page
// When the user clicks the Settings link in the sidebar
// When the user navigates to settings from the sidebar
await page.getByTestId('settings-link').click();
// Then the settings page is shown
await expect(page).toHaveURL('/settings');
// Then the preferences page is displayed
await expect(page).toHaveURL('/settings/preferences');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
test('Scenario: Settings subpages are navigable', async ({
test('Scenario: Settings tabs navigate between subpages', async ({
authenticatedPage: page
}) => {
// Given the user is on the settings page
await page.goto('/settings');
// When the user clicks the MCP Server nav link
await page.getByRole('link', { name: 'MCP Server' }).click();
// When the user selects the API tab
await page.getByRole('link', { name: 'API' }).click();
// Then the MCP settings page is shown
// Then the API settings are displayed
await expect(page).toHaveURL('/settings/mcp');
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
// When the user clicks back to Profile
// When the user selects the Profile tab
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page).toHaveURL('/settings');
// Then the profile settings are displayed
await expect(page).toHaveURL('/settings/profile');
});
});
+114 -191
View File
@@ -1,233 +1,156 @@
import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js';
import type { Page, BrowserContext } from '@playwright/test';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_NAME = 'Collaborator User';
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/**
* Create a second user via the admin API (using the admin's session cookies).
* Create a note via API and share it with the collaborator.
* Returns the note object (with id).
*/
async function createCollaboratorUser(adminPage: Page) {
const cookies = await adminPage.context().cookies();
const sessionCookie = cookies.find((c) => c.name === 'session');
if (!sessionCookie) throw new Error('No session cookie found');
const res = await adminPage.request.post('/api/admin/users', {
data: {
email: COLLAB_EMAIL,
displayName: COLLAB_NAME,
password: COLLAB_PASSWORD,
role: 'user'
}
async function createSharedNote(
ownerPage: Page,
collabPage: Page,
title: string,
content = 'Shared content'
) {
// Create note
const res = await ownerPage.request.post('/api/notes', {
data: { title, content }
});
if (!res.ok()) {
const body = await res.text();
// User may already exist from prior test
if (!body.includes('already exists') && res.status() !== 409) {
throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`);
}
}
const note = await res.json();
// Find the collab user ID
const usersRes = await ownerPage.request.get('/api/users/search?q=collab');
const users = await usersRes.json();
const collabUser = users[0];
// Share the note
await ownerPage.request.post(`/api/notes/${note.id}/collaborators`, {
data: { userId: collabUser.id }
});
return note;
}
/**
* Log in as the collaborator user in a new browser context.
*/
async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
await page.waitForLoadState('networkidle');
return { context, page };
}
test.describe('Note Sharing', () => {
test('Scenario: Owner shares a note via UI and collaborator sees it', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a note titled "Share Test" exists
await page.request.post('/api/notes', {
data: { title: 'Share Test', content: 'Shared content' }
});
await page.reload();
await page.waitForLoadState('networkidle');
await expect(noteCard(page, 'Share Test')).toBeVisible();
test.describe.serial('Note Sharing', () => {
let collabContext: BrowserContext;
let collabPage: Page;
test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => {
// Given a second user exists
await createCollaboratorUser(page);
// When the owner creates a note titled "Shared Note"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Shared Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Shared content');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Shared Note')).toBeVisible();
// And the owner opens the share dialog
await noteCard(page, 'Shared Note').click();
// And the owner opens the share dialog and adds the collaborator
await noteCard(page, 'Share Test').click();
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
// And the owner searches for the collaborator
await page.getByTestId('share-search-input').fill('collab');
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
// And the owner adds the collaborator
await page.getByTestId('share-user-result').first().click();
// Then the collaborator appears in the share dialog
await expect(page.getByTestId('share-collaborator')).toBeVisible();
// Close dialogs
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
// Then the note shows a collaborator indicator
const card = noteCard(page, 'Shared Note');
await expect(card.getByTestId('collaborator-indicator')).toBeVisible();
// Then the owner sees a collaborator indicator
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
// And the collaborator sees the shared note in their list
await collabPage.reload();
await collabPage.waitForLoadState('networkidle');
await expect(noteCard(collabPage, 'Share Test')).toBeVisible({ timeout: 10000 });
});
test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => {
// Given the note was shared in the previous test
// When the collaborator logs in
const collab = await loginAsCollaborator(browser);
collabContext = collab.context;
collabPage = collab.page;
test('Scenario: Collaborator can edit shared note and owner sees changes', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Collab Edit Test');
// Then the collaborator sees "Shared Note" in their notes list
await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 });
// And the note shows a collaborator indicator
await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible();
});
test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => {
// When the collaborator edits the note
await noteCard(collabPage, 'Shared Note').click();
const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially(' — edited by collaborator');
await collabPage.getByTestId('close-editor-btn').click();
// And the owner reloads
await page.reload();
await page.waitForLoadState('networkidle');
// Then the owner sees the collaborator's changes
await noteCard(page, 'Shared Note').click();
await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator');
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => {
// When the collaborator pins the shared note
const card = noteCard(collabPage, 'Shared Note');
await card.hover();
await card.getByTestId('pin-btn').click({ force: true });
// Then the collaborator sees the note as pinned
await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible();
// But the owner does not see it as pinned
await page.reload();
await page.waitForLoadState('networkidle');
const ownerCard = noteCard(page, 'Shared Note');
await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible();
});
test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => {
// When the owner edits the shared note content via the API
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
expect(sharedNote).toBeDefined();
await page.request.patch(`/api/notes/${sharedNote.id}`, {
data: { content: 'Content updated by owner for sync test' }
// When the collaborator edits the note via API
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { content: 'Edited by collaborator' }
});
// Then the collaborator sees the updated content after fetching from the API
const collabNotesRes = await collabPage.request.get('/api/notes');
const collabNotes = await collabNotesRes.json();
const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(collabSharedNote).toBeDefined();
expect(collabSharedNote.content).toBe('Content updated by owner for sync test');
// And the sync endpoint also returns the change for the collaborator
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Content updated by owner for sync test');
// Then the owner sees the updated content
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
const ownerNote = await ownerRes.json();
expect(ownerNote.content).toBe('Edited by collaborator');
});
test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => {
// Given we have the shared note ID
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
test('Scenario: Owner and collaborator have independent per-user state', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Pin Independence Test');
// When the collaborator edits the content via the API
await collabPage.request.patch(`/api/notes/${sharedNote.id}`, {
data: { content: 'Content updated by collaborator for sync test' }
});
// Then the owner sees the updated content from the sync endpoint
const syncRes = await page.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Content updated by collaborator for sync test');
});
test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => {
// Given the shared note exists
const notesRes = await page.request.get('/api/notes');
const allNotes = await notesRes.json();
const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note');
// And the owner pins the note (owner's pin state)
await page.request.patch(`/api/notes/${sharedNote.id}`, {
// When the collaborator pins the note
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { pinned: true }
});
// Then the sync endpoint returns the collaborator's per-user state (not owner's)
const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`);
const collabSyncedNotes = await collabSyncRes.json();
const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id);
// Then the collaborator sees it as pinned
const collabRes = await collabPage.request.get(`/api/notes/${note.id}`);
const collabNote = await collabRes.json();
expect(collabNote.pinned).toBeTruthy();
// Collaborator should see their own pinned state (from noteUserState), not the owner's
// The collaborator pinned this note in a previous test, so it should be true for them
// but via their own per-user state, not leaked from the owner
expect(collabSyncedNote).toBeDefined();
// But the owner does not see it as pinned
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
const ownerNote = await ownerRes.json();
expect(ownerNote.pinned).toBeFalsy();
});
test('Scenario: Collaborator sees leave action instead of trash', async () => {
// Then the collaborator's card shows a leave button, not a trash button
const card = noteCard(collabPage, 'Shared Note');
test('Scenario: Sync returns correct data for both owner and collaborator', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Sync Round-trip Test');
// When the owner updates the content
await page.request.patch(`/api/notes/${note.id}`, {
data: { content: 'Owner sync update' }
});
// Then the collaborator sees the change via sync endpoint
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === note.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Owner sync update');
});
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Leave Test Note');
// When the collaborator views their notes
await collabPage.reload();
await collabPage.waitForLoadState('networkidle');
await expect(noteCard(collabPage, 'Leave Test Note')).toBeVisible({ timeout: 10000 });
// Then the collaborator's card shows a leave button, not trash
const card = noteCard(collabPage, 'Leave Test Note');
await card.hover();
await expect(card.getByTestId('leave-btn')).toBeVisible();
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
});
test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => {
// When the collaborator leaves the shared note
const card = noteCard(collabPage, 'Shared Note');
await card.hover();
// When the collaborator leaves
await card.getByTestId('leave-btn').click({ force: true });
// Then the note disappears from the collaborator's list
await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 });
await expect(noteCard(collabPage, 'Leave Test Note')).not.toBeVisible({ timeout: 5000 });
// But the owner still sees it
// But the owner still has it
await page.reload();
await page.waitForLoadState('networkidle');
await expect(noteCard(page, 'Shared Note')).toBeVisible();
});
test.afterAll(async () => {
if (collabContext) await collabContext.close();
await expect(noteCard(page, 'Leave Test Note')).toBeVisible();
});
});
+12
View File
@@ -1,6 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig } from 'vite';
export default defineConfig({
@@ -10,6 +11,17 @@ export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
? [
istanbul({
include: 'src/**/*',
exclude: ['node_modules', 'tests/', '**/*.test.ts'],
extension: ['.ts', '.svelte'],
requireEnv: true,
forceBuildInstrument: true
})
]
: []),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
+8 -1
View File
@@ -9,6 +9,13 @@ export default defineConfig({
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov', 'json-summary'],
reportsDirectory: './coverage/unit',
include: ['src/lib/**/*.ts'],
exclude: ['src/lib/**/*.test.ts', 'src/lib/types/**']
}
}
});
+3 -3
View File
@@ -145,8 +145,8 @@
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
</div>
<h3>MCP Server</h3>
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
<h3>API & MCP</h3>
<p>Full REST API with token auth. Integrate with n8n, Zapier, or scripts. Built-in MCP server for AI tools like Claude and Cursor.</p>
</div>
<!-- Multi-User -->
@@ -253,7 +253,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.12.0</span>
<span class="footer-version">v0.21.2</span>
</div>
</footer>