Compare commits

...
89 Commits
Author SHA1 Message Date
github-actions[bot] ba73ccde1c chore: bump version to 0.22.0 2026-03-12 23:00:57 +00:00
Frederic CabassutandClaude Opus 4.6 6973b0c24c feat: auto-generate discoverable OpenAPI spec from route tree
Adds a script that scans src/routes/api/ for exported HTTP methods and
generates an OpenAPI 3.1 spec at /api/openapi.json. Runs automatically
before each build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:59:52 +01:00
Frederic CabassutandClaude Opus 4.6 ed2cdaac6a chore: update about page wording to "bretzel.app universe"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:53:40 +01:00
github-actions[bot] 0a96faa5e3 chore: pin docker-compose to v0.21.9 [skip ci] 2026-03-12 22:31:07 +00:00
github-actions[bot] fb6ff18a16 chore: bump version to 0.21.9 2026-03-12 22:26:57 +00:00
Frederic CabassutandClaude Opus 4.6 de7780486f fix: replace emoji PWA icons with pixel-art pretzel from favicon.svg
The PWA manifest icons were a generic pretzel emoji on a dark background,
not matching the app's pixel-art pretzel favicon. Regenerated from the
favicon.svg source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:26:43 +01:00
github-actions[bot] e079a1e783 chore: pin docker-compose to v0.21.8 [skip ci] 2026-03-12 22:16:28 +00:00
github-actions[bot] 0329ee3052 chore: bump version to 0.21.8 2026-03-12 22:12:11 +00:00
Frederic CabassutandClaude Opus 4.6 f7652f2de3 fix: use absolute paths for service worker registration
SvelteKit sets Vite's base to './' for client builds, causing the PWA
plugin to generate relative SW paths (./sw.js). On sub-routes like
/settings or /tag/foo, this resolves to /settings/sw.js which returns
HTML, breaking SW registration. Force scope and buildBase to '/'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:11:56 +01:00
github-actions[bot] 46a17232c4 chore: pin docker-compose to v0.21.7 [skip ci] 2026-03-12 21:16:06 +00:00
github-actions[bot] 1a7e214675 chore: bump version to 0.21.7 2026-03-12 21:11:35 +00:00
Frederic CabassutandClaude Opus 4.6 84e99a3497 fix: remove maskable icon purpose to prevent double-cropping
The maskable purpose caused Android to crop the already-round icon
into its adaptive shape, resulting in a square-inside-circle appearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:11:08 +01:00
github-actions[bot] d1c48e7799 chore: pin docker-compose to v0.21.6 [skip ci] 2026-03-12 20:13:13 +00:00
github-actions[bot] a30af4423c chore: bump version to 0.21.6 2026-03-12 20:08:34 +00:00
Frederic CabassutandClaude Opus 4.6 deca26931e fix: block service worker in e2e tests to prevent response interception
Active service workers change the fetch lifecycle enough that Playwright's
waitForResponse() misses SW-mediated responses, causing preferences tests
to timeout. Block SW registration during e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:08:20 +01:00
github-actions[bot] 335b01f60e chore: bump version to 0.21.5 2026-03-12 20:01:20 +00:00
Frederic CabassutandClaude Opus 4.6 ba1ddd4489 fix: restrict SW API caching to GET requests to avoid intercepting mutations
Workbox route matching intercepts all HTTP methods, which caused PUT/POST
requests (e.g. preferences updates) to be handled by the SW strategy
handler instead of passing through directly. Restrict API and attachment
cache rules to GET-only using function-based urlPattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:00:56 +01:00
github-actions[bot] 541f910564 chore: bump version to 0.21.4 2026-03-12 19:56:04 +00:00
Frederic CabassutandClaude Opus 4.6 27e59989b5 fix: register PWA service worker via virtual:pwa-register for offline support
@vite-pwa/sveltekit does not auto-inject SW registration into SSR HTML.
Add explicit registration via virtual:pwa-register in root layout onMount,
inject webmanifest link via virtual:pwa-info, disable SvelteKit's built-in
SW registration to avoid conflicts, and add required dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:53:38 +01:00
github-actions[bot] c949b40a20 chore: pin docker-compose to v0.21.3 [skip ci] 2026-03-12 19:24:11 +00:00
github-actions[bot] bbfcf72e74 chore: bump version to 0.21.3 2026-03-12 19:19:56 +00:00
copilot-swe-agent[bot]andfredicious d02af48b50 fix: snapshot pre-update state to enable 3-way merge on concurrent PATCH
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-12 20:19:10 +01:00
Claude 57a05bbee0 fix: resolve lost updates in multi-user collaboration sync
Concurrent edits to shared notes (e.g., one user checks a shopping list
item while another adds an item) were lost because the sync system used
whole-document Last-Write-Wins. This replaces it with a 3-way line-level
content merge using node-diff3, so changes to different lines are
preserved from both users.

Key changes:
- Add node-diff3 for 3-way line-level content merging
- Remove the timestamp gate in processSyncPush that rejected entire
  offline sync pushes when another user had updated the note
- Add content merge to both the sync push path and the PATCH endpoint,
  using version snapshots as the merge base
- Send baseVersion from the client so the server can find the correct
  common ancestor for 3-way merge
- Protect pending local fields during client pull merge by reading
  the sync queue and preserving queued-but-unpushed changes
- Add comprehensive tests for content merge, server sync, and CRDT merge

https://claude.ai/code/session_01FNpVBBjM6vKzfR8y9NM9Ci
2026-03-12 20:19:10 +01:00
Frederic CabassutandClaude Opus 4.6 0965da70b4 fix: enable offline page caching in service worker for PWA refresh support
Navigation requests had no caching rule, so refreshing offline showed the
browser's default "you are offline" page. Add NetworkFirst runtime caching
for navigations (3s timeout), cache Google Fonts for offline use, remove
conflicting static webmanifest in favor of the plugin-generated one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:16:23 +01:00
Claude 8475ec807e fix: make checklist checkboxes read-only in note card list view
Checking items directly from the card view caused glitches. Checkboxes
in the list preview are now disabled — users open the note to interact.

https://claude.ai/code/session_01HoLKBDNrHWHUVgTUbhSLE4
2026-03-12 18:56:16 +01:00
Frederic CabassutandClaude Opus 4.6 e240f57352 fix: resolve dependabot vulnerabilities — override hono, remove stale package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:49:57 +01:00
Frederic CabassutandClaude Opus 4.6 2c16ed5870 fix: delete all user-related records before removing user to prevent FK constraint error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:47 +01:00
Frederic CabassutandClaude Opus 4.6 6d503bbc8c docs: add SMTP config to README, document recent features, expose all env vars in docker-compose
- Add SMTP configuration example to README install section
- Add note version history, sharing, and email notifications to README feature list
- Expand FEATURES.md with all 9 email notification types and version history section
- Add note sharing, version history, and email alerts feature cards to landing page
- Add all OAuth/SSO env vars to docker-compose.yml with empty defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:49:55 +01:00
github-actions[bot] d2e4a0a4b4 chore: pin docker-compose to v0.21.2 [skip ci] 2026-03-12 09:32:50 +00:00
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
105 changed files with 7184 additions and 768 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"]
}
+12
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
@@ -159,6 +163,13 @@ GitHub Actions with two workflows:
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Database schema changes
When adding or modifying tables in `src/lib/server/db/schema.ts`:
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
## Testing philosophy: BDD/TDD
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
@@ -176,6 +187,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
+18
View File
@@ -46,6 +46,21 @@ volumes:
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
#### With Email Notifications (optional)
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
```yaml
environment:
- SMTP_HOST=smtp.example.com
- SMTP_PORT=587
- SMTP_USER=your-username
- SMTP_PASS=your-password
- SMTP_FROM=Crumbs <noreply@example.com>
```
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
### Manual (Node.js)
```bash
@@ -75,6 +90,9 @@ pnpm build # Production build
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- Note version history — browse and restore previous versions
- Share notes with other users on the same instance
- Email notifications for shares, security alerts, and account events (optional SMTP)
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
+16 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.14.0
image: ghcr.io/bretzel-app/crumbs:0.21.9
container_name: crumbs
ports:
- "3000:3000"
@@ -11,6 +11,21 @@ services:
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=${ORIGIN:-http://localhost:3000}
# OAuth / SSO (optional — providers auto-enable when configured)
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
- 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 auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
| 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`:
+16
View File
@@ -91,6 +91,22 @@
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
- Email notifications (requires SMTP config, opt-out in preferences):
- Share notifications when a note is shared with you
- Welcome email when admin creates your account
- Password reset notification
- Account deletion confirmation
- Email address change notification
- Role change notification (admin/user)
- Removed from shared note notification
- Shared note permanently deleted notification
- Account locked alert (after failed login attempts)
### Note Version History
- Browse previous versions of any note
- Versions are automatically saved when content changes
- Restore any previous version with one click
- Version snapshots are only created when content actually changes (not on metadata-only updates)
### 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
}
]
}
+16 -3
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.15.0",
"version": "0.22.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -12,14 +12,18 @@
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build": "tsx scripts/generate-openapi.ts && vite build",
"openapi": "tsx scripts/generate-openapi.ts",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"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",
@@ -51,6 +55,8 @@
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
@@ -65,17 +71,23 @@
"@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",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
"pnpm": {
@@ -87,7 +99,8 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0"
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
}
}
}
+5 -3
View File
@@ -4,13 +4,15 @@ 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: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
screenshot: 'only-on-failure',
serviceWorkers: 'block'
},
projects: [
{
@@ -27,7 +29,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,
+968 -8
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
+147
View File
@@ -0,0 +1,147 @@
/**
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
*
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
*
* Usage: npx tsx scripts/generate-openapi.ts
*/
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
import { resolve, relative, dirname, join } from 'path';
const ROOT = process.cwd();
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
function walkDir(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walkDir(full));
} else if (entry === '+server.ts') {
results.push(full);
}
}
return results;
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
function svelteKitPathToOpenAPI(filePath: string): string {
const rel = relative(ROUTES_DIR, dirname(filePath));
const segments = rel === '' ? [] : rel.split('/');
const apiPath =
'/api/' +
segments
.map((s) => {
const match = s.match(/^\[(.+)\]$/);
return match ? `{${match[1]}}` : s;
})
.join('/');
return apiPath;
}
function extractMethods(filePath: string): string[] {
const content = readFileSync(filePath, 'utf-8');
const methods: string[] = [];
let match;
while ((match = METHOD_RE.exec(content)) !== null) {
methods.push(match[1].toLowerCase());
}
METHOD_RE.lastIndex = 0;
return methods;
}
function inferTag(path: string): string {
const first = path.replace('/api/', '').split('/')[0];
return first.charAt(0).toUpperCase() + first.slice(1);
}
function extractPathParams(path: string) {
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
const re = /\{(\w+)\}/g;
let match;
while ((match = re.exec(path)) !== null) {
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
}
return params;
}
const files = walkDir(ROUTES_DIR);
const paths: Record<string, Record<string, unknown>> = {};
for (const fullPath of files) {
const apiPath = svelteKitPathToOpenAPI(fullPath);
const methods = extractMethods(fullPath);
if (methods.length === 0) continue;
const pathParams = extractPathParams(apiPath);
const pathEntry: Record<string, unknown> = {};
if (pathParams.length > 0) {
pathEntry.parameters = pathParams;
}
for (const method of methods) {
const operation: Record<string, unknown> = {
tags: [inferTag(apiPath)],
responses: {
'200': { description: 'Success' }
}
};
if (['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
content: {
'application/json': {
schema: { type: 'object' }
}
}
};
}
pathEntry[method] = operation;
}
paths[apiPath] = pathEntry;
}
const sortedPaths: Record<string, Record<string, unknown>> = {};
for (const key of Object.keys(paths).sort()) {
sortedPaths[key] = paths[key];
}
const spec = {
openapi: '3.1.0',
info: {
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
description: `Auto-generated API spec for ${pkg.name}`,
version: pkg.version,
contact: {
name: 'Bretzel',
url: 'https://bretzel.app'
}
},
servers: [{ url: '/', description: 'Current instance' }],
security: [{ sessionCookie: [] }],
paths: sortedPaths,
components: {
securitySchemes: {
sessionCookie: {
type: 'apiKey',
in: 'cookie',
name: 'session'
}
}
}
};
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
+3
View File
@@ -1,3 +1,6 @@
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/vanillajs" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
-1
View File
@@ -11,7 +11,6 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Crumbs" />
<link rel="manifest" href="/site.webmanifest" />
<title>Crumbs</title>
%sveltekit.head%
</head>
+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}
+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>
+6 -20
View File
@@ -1,7 +1,7 @@
<script lang="ts">
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 { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import type { Note } from '$lib/types/index.js';
@@ -58,20 +58,6 @@
};
}
function toggleChecklistItem(item: ChecklistItem) {
const lines = note.content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const text = line.replace(/^- \[[ x]\] /, '');
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
lines[i] = item.checked
? `- [ ] ${text}`
: `- [x] ${text}`;
break;
}
}
updateNote(note.id, { content: lines.join('\n') });
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
@@ -138,17 +124,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"
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
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}
+92 -6
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,7 +256,8 @@
<!-- 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"
>
@@ -203,7 +265,6 @@
<div
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"
>
@@ -265,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">
@@ -346,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
@@ -368,3 +446,11 @@
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
+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>
+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);
+6
View File
@@ -228,6 +228,12 @@ export function getUser(userId: number): User | null {
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
sqlite
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
.run(userId, userId);
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
+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);
});
});
+73 -29
View File
@@ -1,16 +1,16 @@
import { db as defaultDb } 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 type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
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 { mergeContent } from '$lib/utils/content-merge.js';
import type { NoteFilter } from '$lib/types/index.js';
const db = defaultDb;
interface NoteRow {
id: string;
userId: number;
@@ -28,11 +28,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 +52,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 +90,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 +114,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 +124,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 +151,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 +165,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 +192,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 };
}
@@ -206,13 +206,14 @@ export interface UpdateNoteInput {
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
baseVersion?: number;
}
/** 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();
@@ -233,7 +234,33 @@ export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
let hasSharedUpdates = false;
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; }
if (input.content !== undefined) {
// 3-way merge to preserve concurrent edits from other users
if (input.content !== existing.content) {
const baseSnapshot = input.baseVersion !== undefined
? db.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get()
: db.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, id))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (baseSnapshot && baseSnapshot.content !== existing.content) {
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
} else {
sharedUpdates.content = input.content;
}
} else {
sharedUpdates.content = input.content;
}
hasSharedUpdates = true;
}
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
@@ -291,14 +318,31 @@ 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) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.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 +356,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 +430,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;
}
+10 -2
View File
@@ -142,11 +142,19 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
}
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
// Read current version for 3-way merge base on the server
let baseVersion: number | undefined;
notes.update((list) => {
const current = list.find((n) => n.id === id);
if (current) baseVersion = current.version;
return list;
});
try {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
body: JSON.stringify({ ...updates, baseVersion })
});
if (res.ok) {
const updated: Note = await res.json();
@@ -173,7 +181,7 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
);
if (optimistic) {
await putNote(optimistic);
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion });
showToast('Saved offline — will sync when reconnected', 'info');
}
return optimistic;
+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);
});
});
+17 -1
View File
@@ -100,10 +100,26 @@ async function pullChanges(): Promise<Note[]> {
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
// Build pending-fields map from sync queue to protect unpushed local changes
let pendingFieldsMap = new Map<string, Set<string>>();
try {
const queue = await getSyncQueue();
for (const item of queue) {
if (item.data) {
const fields = pendingFieldsMap.get(item.noteId) || new Set<string>();
Object.keys(item.data).forEach((k) => fields.add(k));
pendingFieldsMap.set(item.noteId, fields);
}
}
} catch {
// Queue unavailable — merge without protection
}
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
const pendingFields = pendingFieldsMap.get(remote.id);
const merged = mergeNotes(local, remote, pendingFields);
await putNote(merged);
} else {
await putNote(remote);
+22 -12
View File
@@ -1,29 +1,39 @@
import type { Note } from '$lib/types/index.js';
/** Fields that can be individually merged between local and remote notes */
const MUTABLE_FIELDS = ['title', 'content', 'color', 'pinned', 'archived', 'trashed', 'checklistMode', 'sortOrder'] as const;
/**
* Last-Write-Wins (LWW) merge strategy per field.
* For a single-user app with multiple devices, this is sufficient.
* Each field is resolved by comparing updatedAt timestamps.
* Last-Write-Wins (LWW) merge strategy with pending-field protection.
*
* When remote is newer, takes remote values for all fields EXCEPT those
* listed in `pendingFields` — these represent locally-queued changes that
* haven't been pushed yet and must be preserved.
*/
export function mergeNotes(local: Note, remote: Note): Note {
// If remote is newer overall, take remote
export function mergeNotes(local: Note, remote: Note, pendingFields?: Set<string>): Note {
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();
if (remoteTime > localTime) {
return { ...remote };
}
const remoteWins = remoteTime > localTime ||
(remoteTime === localTime && remote.version > local.version);
if (localTime > remoteTime) {
if (!remoteWins) {
return { ...local };
}
// Same timestamp - prefer higher version number
if (remote.version > local.version) {
// Remote wins — take remote as base, but preserve pending local fields
if (!pendingFields || pendingFields.size === 0) {
return { ...remote };
}
return { ...local };
const merged = { ...remote };
for (const field of MUTABLE_FIELDS) {
if (pendingFields.has(field)) {
(merged as unknown as Record<string, unknown>)[field] = (local as unknown as Record<string, unknown>)[field];
}
}
return merged;
}
/**
+1
View File
@@ -48,6 +48,7 @@ export interface SyncQueueItem {
operation: 'create' | 'update' | 'delete';
data?: Partial<Note>;
timestamp: number;
baseVersion?: number;
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
+71 -23
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,32 +213,80 @@ 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);
});
it('should reject changes with older timestamp than server note', async () => {
it('should apply field changes even when timestamp is older (no timestamp gate)', async () => {
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
// Collaborator's change has a timestamp BEFORE the server's updatedAt
// Collaborator's change has a timestamp BEFORE the server's updatedAt.
// With field-level sync, the title change should still be applied because
// the sync data only contains fields the client explicitly modified.
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);
data: { title: 'Offline edit' }
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Shared Note'); // Unchanged
expect(note.title).toBe('Offline edit');
// updatedAt should be max of change timestamp and server timestamp
expect(note.updatedAt.getTime()).toBe(t2.getTime());
});
it('should merge checklist content when collaborator pushes concurrent content change', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: t1,
createdAt: t1,
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true
});
// Simulate owner editing content on server (checking Bread)
const t2 = new Date('2024-01-01T12:00:00Z');
db.update(notes)
.set({ content: '- [ ] Milk\n- [x] Bread', updatedAt: t2, version: 2 })
.where(eq(notes.id, 'n1'))
.run();
// Create a version snapshot for the base content (version 1)
const { noteVersions } = await import('../server/db/schema.js');
db.insert(noteVersions).values({
id: 'snap-1',
noteId: 'n1',
version: 1,
title: 'Shared Note',
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
color: 'default',
createdAt: t1
}).run();
// Collaborator was offline since version 1, checked Milk
const t3 = new Date('2024-01-01T13:00:00Z').getTime();
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t3,
data: { content: '- [x] Milk\n- [ ] Bread' },
baseVersion: 1
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
// Both changes should be merged: Milk checked by collab, Bread checked by owner
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
});
it('should not allow non-collaborator to update the note', async () => {
@@ -263,12 +311,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 +327,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);
+91 -58
View File
@@ -1,18 +1,41 @@
import { db as defaultDb } 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 type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } 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 { mergeContent } from '$lib/utils/content-merge.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>;
/**
* Find the base content for 3-way merge from version snapshots.
* Returns the content from the snapshot at or before the given version,
* or the current note content if no snapshot is found.
*/
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
if (baseVersion !== undefined) {
const snapshot = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (snapshot) return snapshot.content;
}
// Fallback: get the most recent snapshot before the current version
const latest = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return latest?.content ?? null;
}
/**
* 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) => {
@@ -35,59 +58,70 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number,
if (!isOwner && !isCollaborator) continue;
if (change.timestamp > note.updatedAt.getTime()) {
// Shared fields: title, content, color, checklistMode
const sharedData: Record<string, unknown> = {
updatedAt: new Date(change.timestamp),
version: note.version + 1
};
if (change.data.title !== undefined) sharedData.title = change.data.title;
if (change.data.content !== undefined) sharedData.content = change.data.content;
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
// Apply field-level updates unconditionally (no timestamp gate).
// Each sync change only contains the fields the client explicitly modified,
// so applying them won't overwrite other users' changes to different fields.
const sharedData: Record<string, unknown> = {
updatedAt: new Date(Math.max(change.timestamp, note.updatedAt.getTime())),
version: note.version + 1
};
if (change.data.title !== undefined) sharedData.title = change.data.title;
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
if (isOwner) {
// Owner: per-user fields go to notes table
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
if (change.data.trashed !== undefined) {
sharedData.trashed = change.data.trashed;
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
}
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
// Content: 3-way merge to preserve concurrent edits from other users
if (change.data.content !== undefined && change.data.content !== note.content) {
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
if (baseContent !== null && baseContent !== note.content) {
// 3-way merge: base, incoming (local), server current (remote)
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
} else {
// No base or base matches server — take incoming content
sharedData.content = change.data.content;
}
}
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
.run();
if (isOwner) {
// Owner: per-user fields go to notes table
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
if (change.data.trashed !== undefined) {
sharedData.trashed = change.data.trashed;
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
}
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUser = change.data.pinned !== undefined ||
change.data.archived !== undefined ||
change.data.sortOrder !== undefined;
if (hasPerUser) {
const existing = tx.select().from(noteUserState)
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
.run();
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUser = change.data.pinned !== undefined ||
change.data.archived !== undefined ||
change.data.sortOrder !== undefined;
if (hasPerUser) {
const existing = tx.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.get();
if (existing) {
const updates: Record<string, unknown> = {};
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
if (change.data.archived !== undefined) updates.archived = change.data.archived;
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
tx.update(noteUserState).set(updates)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.get();
if (existing) {
const updates: Record<string, unknown> = {};
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
if (change.data.archived !== undefined) updates.archived = change.data.archived;
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
tx.update(noteUserState).set(updates)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.run();
} else {
tx.insert(noteUserState).values({
noteId: change.noteId,
userId,
pinned: change.data.pinned ?? false,
archived: change.data.archived ?? false,
sortOrder: change.data.sortOrder ?? 0
}).run();
}
.run();
} else {
tx.insert(noteUserState).values({
noteId: change.noteId,
userId,
pinned: change.data.pinned ?? false,
archived: change.data.archived ?? false,
sortOrder: change.data.sortOrder ?? 0
}).run();
}
}
}
@@ -140,7 +174,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 +185,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
+65
View File
@@ -55,6 +55,71 @@ describe('mergeNotes (LWW)', () => {
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should preserve pending local fields when remote is newer', () => {
const local = makeNote({
title: 'Local title',
content: 'Local content',
color: 'coral',
updatedAt: new Date('2024-01-01'),
version: 1
});
const remote = makeNote({
title: 'Remote title',
content: 'Remote content',
color: 'default',
updatedAt: new Date('2024-01-02'),
version: 2
});
// Content has pending local changes; title and color do not
const pendingFields = new Set(['content']);
const result = mergeNotes(local, remote, pendingFields);
// Remote wins overall, but content is preserved from local
expect(result.title).toBe('Remote title');
expect(result.content).toBe('Local content');
expect(result.color).toBe('default'); // remote wins for non-pending field
expect(result.version).toBe(2); // metadata from remote
});
it('should take remote values for non-pending fields when remote is newer', () => {
const local = makeNote({
pinned: true,
archived: true,
updatedAt: new Date('2024-01-01')
});
const remote = makeNote({
pinned: false,
archived: false,
updatedAt: new Date('2024-01-02')
});
const pendingFields = new Set(['pinned']);
const result = mergeNotes(local, remote, pendingFields);
expect(result.pinned).toBe(true); // pending — preserved
expect(result.archived).toBe(false); // not pending — remote wins
});
it('should ignore pendingFields when local is newer', () => {
const local = makeNote({
title: 'Local',
updatedAt: new Date('2024-01-02'),
version: 2
});
const remote = makeNote({
title: 'Remote',
updatedAt: new Date('2024-01-01'),
version: 1
});
const pendingFields = new Set(['title']);
const result = mergeNotes(local, remote, pendingFields);
// Local wins entirely since it's newer
expect(result.title).toBe('Local');
});
});
describe('hasChanges', () => {
+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'
]);
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [ ] Milk\n- [ ] Bread';
const remote = '- [x] Milk\n- [ ] Bread';
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
});
it('should return local when remote is unchanged', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread';
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
});
it('should merge when User A checks an item and User B adds an item', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter'; // User B adds Butter
const result = mergeContent(base, local, remote);
expect(result).toBe('- [x] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter');
});
it('should merge when both users check different items', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
const remote = '- [ ] Milk\n- [x] Bread\n- [ ] Eggs'; // User B checks Bread
const result = mergeContent(base, local, remote);
expect(result).toBe('- [x] Milk\n- [x] Bread\n- [ ] Eggs');
});
it('should LWW (prefer remote) when both users edit the same line', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [ ] Whole Milk\n- [ ] Bread'; // User A renames Milk
const remote = '- [x] Milk\n- [ ] Bread'; // User B checks Milk
const result = mergeContent(base, local, remote);
// Both changed line 1 — conflict resolved by preferring remote
expect(result).toBe('- [x] Milk\n- [ ] Bread');
});
it('should handle line additions by both sides', () => {
const base = '- [ ] Milk';
const local = '- [ ] Milk\n- [ ] Bread'; // User A adds Bread at end
const remote = '- [ ] Eggs\n- [ ] Milk'; // User B adds Eggs at beginning
const result = mergeContent(base, local, remote);
// Both additions should be preserved
expect(result).toContain('- [ ] Milk');
expect(result).toContain('- [ ] Bread');
expect(result).toContain('- [ ] Eggs');
});
it('should handle line deletion by one side', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [ ] Milk\n- [ ] Eggs'; // User A removes Bread
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'; // User B unchanged
const result = mergeContent(base, local, remote);
expect(result).toBe('- [ ] Milk\n- [ ] Eggs');
});
it('should work with non-checklist markdown (prose paragraphs)', () => {
const base = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nNothing special.';
const local = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nRemember coupons.'; // User A edits notes
const remote = '# Shopping List\n\nBuy groceries today.\n\n## Notes\n\nNothing special.'; // User B edits title
const result = mergeContent(base, local, remote);
expect(result).toContain('# Shopping List');
expect(result).toContain('Remember coupons.');
});
it('should handle empty content', () => {
expect(mergeContent('', '', '')).toBe('');
expect(mergeContent('', 'hello', '')).toBe('hello');
expect(mergeContent('', '', 'hello')).toBe('hello');
});
it('should return remote when local equals remote', () => {
const content = '- [ ] Milk\n- [ ] Bread';
expect(mergeContent('old', content, content)).toBe(content);
});
});
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { diff3Merge } from 'node-diff3';
/**
* Resolve a conflict region by comparing each line against the base.
* If only one side changed a line, take that change.
* If both sides changed the same line, prefer remote (LWW).
* Handles different-length regions by appending extra lines.
*/
function resolveConflict(
aLines: string[],
oLines: string[],
bLines: string[]
): string[] {
const maxLen = Math.max(aLines.length, bLines.length, oLines.length);
const result: string[] = [];
for (let i = 0; i < maxLen; i++) {
const a = i < aLines.length ? aLines[i] : undefined;
const o = i < oLines.length ? oLines[i] : undefined;
const b = i < bLines.length ? bLines[i] : undefined;
const aChanged = a !== o;
const bChanged = b !== o;
if (aChanged && !bChanged && a !== undefined) {
// Only local changed this line — take local
result.push(a);
} else if (bChanged && !aChanged && b !== undefined) {
// Only remote changed this line — take remote
result.push(b);
} else if (aChanged && bChanged) {
// Both changed — LWW: prefer remote
if (b !== undefined) result.push(b);
// If remote deleted (undefined) but local has it, skip (remote wins)
} else {
// Neither changed, or both are the same as base
if (o !== undefined) result.push(o);
else if (b !== undefined) result.push(b);
else if (a !== undefined) result.push(a);
}
}
return result;
}
/**
* 3-way line-level merge for note content.
* Takes a base (common ancestor), local (incoming), and remote (server current).
* For conflict regions, resolves line-by-line: if only one side changed a line,
* take that change. If both changed the same line, prefer remote (LWW).
*/
export function mergeContent(base: string, local: string, remote: string): string {
// If any two are identical, short-circuit
if (local === remote) return remote;
if (local === base) return remote;
if (remote === base) return local;
const baseLines = base.split('\n');
const localLines = local.split('\n');
const remoteLines = remote.split('\n');
const regions = diff3Merge(localLines, baseLines, remoteLines);
const merged: string[] = [];
for (const region of regions) {
if (region.ok) {
merged.push(...region.ok);
} else if (region.conflict) {
merged.push(...resolveConflict(
region.conflict.a,
region.conflict.o,
region.conflict.b
));
}
}
return merged.join('\n');
}
/**
* 2-way merge fallback when no base is available.
* Uses an empty string as the base, so all lines from both sides appear
* as additions. For conflicts (same region), prefers remote.
*/
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, remote);
}
+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.app</a
> universe.
</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>
+23
View File
@@ -1,7 +1,30 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
let { children } = $props();
onMount(async () => {
if (pwaInfo) {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
immediate: true,
onRegistered(r: ServiceWorkerRegistration | undefined) {
console.log('SW Registered:', r);
},
onRegisterError(error: Error) {
console.log('SW registration error', error);
}
});
}
});
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
{@html webManifest}
</svelte:head>
{@render children()}
+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) ?? [],
+796
View File
@@ -0,0 +1,796 @@
{
"openapi": "3.1.0",
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.21.9",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
}
},
"servers": [
{
"url": "/",
"description": "Current instance"
}
],
"security": [
{
"sessionCookie": []
}
],
"paths": {
"/api/admin/users": {
"get": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/admin/users/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"patch": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/account": {
"delete": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/login": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/oauth/{provider}": {
"parameters": [
{
"name": "provider",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/oauth/{provider}/callback": {
"parameters": [
{
"name": "provider",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/password": {
"put": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/profile": {
"put": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/sessions": {
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"delete": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/setup": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/mcp": {
"post": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"get": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"delete": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes": {
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/notes/reorder": {
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/notes/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"patch": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/attachments": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"patch": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/collaborators": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions/{versionId}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "versionId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions/{versionId}/restore": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "versionId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/preferences": {
"get": {
"tags": [
"Preferences"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"put": {
"tags": [
"Preferences"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/search": {
"get": {
"tags": [
"Search"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/settings/api-keys": {
"get": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/settings/api-keys/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"delete": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/sync": {
"post": {
"tags": [
"Sync"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"get": {
"tags": [
"Sync"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/tags": {
"get": {
"tags": [
"Tags"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/users/search": {
"get": {
"tags": [
"Users"
],
"responses": {
"200": {
"description": "Success"
}
}
}
}
},
"components": {
"securitySchemes": {
"sessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": "session"
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 299 KiB

-21
View File
@@ -1,21 +0,0 @@
{
"name": "Crumbs by Bretzel",
"short_name": "Crumbs",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#f9f4ea",
"background_color": "#f9f4ea",
"display": "standalone"
}
+4 -1
View File
@@ -5,7 +5,10 @@ const config = {
kit: {
adapter: adapter({
out: 'build'
})
}),
serviceWorker: {
register: false
}
}
};
+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();
});
});
+17 -37
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,58 +27,42 @@ 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: Most recently updated note appears first', 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();
// 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();
// 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();
+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);

Some files were not shown because too many files have changed in this diff Show More