Compare commits

...
21 Commits
Author SHA1 Message Date
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
30 changed files with 719 additions and 13240 deletions
+8
View File
@@ -163,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.
@@ -180,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
+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)
+11 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.20.0
image: ghcr.io/bretzel-app/crumbs:0.21.3
container_name: crumbs
ports:
- "3000:3000"
@@ -11,6 +11,16 @@ 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:-}
+1 -1
View File
@@ -116,7 +116,7 @@ Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported
### Email Notifications (optional)
Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set.
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 |
|----------|---------|-------------|
+16 -1
View File
@@ -91,7 +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 when a note is shared with you (requires SMTP config, opt-out in preferences)
- 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
-13070
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.21.0",
"version": "0.21.4",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -54,6 +54,7 @@
"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",
@@ -83,7 +84,9 @@
"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": {
@@ -95,7 +98,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"
}
}
}
+24 -8
View File
@@ -8,6 +8,7 @@ overrides:
serialize-javascript: '>=7.0.3'
esbuild@<0.25.0: 0.25.12
cookie: '>=0.7.0'
hono: '>=4.12.7'
importers:
@@ -79,6 +80,9 @@ importers:
markdown-it:
specifier: ^14.1.1
version: 14.1.1
node-diff3:
specifier: ^3.2.0
version: 3.2.0
nodemailer:
specifier: ^8.0.2
version: 8.0.2
@@ -161,9 +165,15 @@ importers:
vite-plugin-istanbul:
specifier: ^7.2.1
version: 7.2.1(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-pwa:
specifier: ^1.2.0
version: 1.2.0(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
workbox-window:
specifier: ^7.4.0
version: 7.4.0
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -1006,7 +1016,7 @@ packages:
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
hono: '>=4.12.7'
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
@@ -2502,8 +2512,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hono@4.12.5:
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
hono@4.12.7:
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
@@ -3003,6 +3013,10 @@ packages:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21}
node-diff3@3.2.0:
resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
engines: {bun: '>=1.3.0', node: '>=18'}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -4833,9 +4847,9 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@hono/node-server@1.19.11(hono@4.12.5)':
'@hono/node-server@1.19.11(hono@4.12.7)':
dependencies:
hono: 4.12.5
hono: 4.12.7
'@isaacs/cliui@8.0.2':
dependencies:
@@ -4884,7 +4898,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.11(hono@4.12.5)
'@hono/node-server': 1.19.11(hono@4.12.7)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4894,7 +4908,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.0(express@5.2.1)
hono: 4.12.5
hono: 4.12.7
jose: 6.2.0
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -6336,7 +6350,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
hono@4.12.5: {}
hono@4.12.7: {}
html-escaper@2.0.2: {}
@@ -6779,6 +6793,8 @@ snapshots:
node-addon-api@8.6.0: {}
node-diff3@3.2.0: {}
node-gyp-build@4.8.4: {}
node-preload@0.2.1:
+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>
+3 -17
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 -->
@@ -144,8 +130,8 @@
<input
type="checkbox"
checked={item.checked}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
class="mt-0.5 h-3.5 w-3.5 shrink-0 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="break-words min-w-0">{item.text}</span>
+29 -32
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
@@ -56,6 +57,15 @@
// 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
@@ -170,36 +180,19 @@
bgStyle = `background-color: ${colors.bg}`;
});
// Track whether mousedown started on the overlay (not inside the editor).
// Prevents closing when the user selects text inside the editor and
// releases the mouse outside — the click event fires on the overlay,
// but we only want to close if the press also started there.
let mousedownOnOverlay = false;
// 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;
// On mobile, the tap that opens the editor produces synthetic mouse events
// (mousedown → mouseup → click) at the original touch coordinates. If those
// coordinates land on the overlay backdrop, the editor would close instantly.
// Guard against this by ignoring mousedowns that arrive within the first
// animation frame after mount.
let overlayReady = false;
$effect(() => {
const id = requestAnimationFrame(() => {
overlayReady = true;
});
return () => {
overlayReady = false;
cancelAnimationFrame(id);
};
});
function handleOverlayMousedown(e: MouseEvent) {
if (!overlayReady) return;
mousedownOnOverlay = e.target === e.currentTarget;
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayClick() {
if (mousedownOnOverlay) saveAndClose();
mousedownOnOverlay = false;
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
}
async function saveAndClose() {
@@ -263,8 +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]"
onmousedown={handleOverlayMousedown}
onclick={handleOverlayClick}
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
@@ -272,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"
>
@@ -334,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">
+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
+38 -13
View File
@@ -1,6 +1,6 @@
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
import { 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';
@@ -8,6 +8,7 @@ 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';
interface NoteRow {
@@ -205,6 +206,7 @@ export interface UpdateNoteInput {
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
baseVersion?: number;
}
/** Per-user fields that go to noteUserState for collaborators */
@@ -232,7 +234,33 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
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; }
@@ -302,16 +330,13 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
if (saved) {
createSnapshot(db, id, {
version: saved.version,
title: saved.title,
content: saved.content,
checklistMode: saved.checklistMode,
color: saved.color
});
}
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
return getNote(db, userId, id);
+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;
+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;
+52 -4
View File
@@ -224,21 +224,69 @@ describe('processSyncPush — shared notes', () => {
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(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1,
data: { title: 'Stale edit' }
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 () => {
+88 -50
View File
@@ -1,10 +1,37 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql } from 'drizzle-orm';
import { 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';
/**
* 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.
*/
@@ -31,59 +58,70 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
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();
}
}
}
+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', () => {
+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);
}
+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()}
-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
}
}
};
+4 -1
View File
@@ -62,8 +62,11 @@ test.describe('Offline Support', () => {
await expect(page.getByText('Survive Reload')).toBeVisible();
// When the network is restored and sync completes
const syncResponse = page.waitForResponse('**/api/sync');
const syncResponse = page.waitForResponse((resp) => resp.url().includes('/api/sync'));
await page.context().setOffline(false);
// 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
+31
View File
@@ -124,6 +124,37 @@ test.describe('Note Sharing', () => {
expect(syncedNote.content).toBe('Owner sync update');
});
test('Scenario: Both users\' edits to different fields are preserved after concurrent PATCH', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared checklist note exists
const note = await createSharedNote(
page,
collabPage,
'Shopping List',
'- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'
);
// When the owner checks Milk
await page.request.patch(`/api/notes/${note.id}`, {
data: { content: '- [x] Milk\n- [ ] Bread\n- [ ] Eggs', baseVersion: note.version }
});
// And the collaborator adds Butter (based on the same original version)
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { content: '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter', baseVersion: note.version }
});
// Then both changes are preserved via content merge
const res = await page.request.get(`/api/notes/${note.id}`);
const merged = await res.json();
expect(merged.content).toContain('- [x] Milk');
expect(merged.content).toContain('- [ ] Butter');
expect(merged.content).toContain('- [ ] Bread');
expect(merged.content).toContain('- [ ] Eggs');
});
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
authenticatedPage: page,
collabPage
+18 -1
View File
@@ -40,8 +40,17 @@ export default defineConfig({
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
navigateFallback: '/',
navigateFallback: null,
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'pages-cache',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }
}
},
{
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
handler: 'CacheFirst',
@@ -57,6 +66,14 @@ export default defineConfig({
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
},
{
urlPattern: /^https?:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 }
}
}
]
}
+28 -1
View File
@@ -166,6 +166,33 @@
<h3>OAuth & SSO</h3>
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
</div>
<!-- Note Sharing -->
<div class="feature-card animate-in delay-4">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</div>
<h3>Note Sharing</h3>
<p>Share notes with other users on your instance. Full collaboration with per-user pins and archives.</p>
</div>
<!-- Version History -->
<div class="feature-card animate-in delay-5">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<h3>Version History</h3>
<p>Browse and restore previous versions of any note. Never lose important edits again.</p>
</div>
<!-- Email Notifications -->
<div class="feature-card animate-in delay-6">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</div>
<h3>Email Alerts</h3>
<p>Optional SMTP notifications for shares, security alerts, and account events. Opt out per user.</p>
</div>
</div>
</div>
</section>
@@ -253,7 +280,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.21.0</span>
<span class="footer-version">v0.21.4</span>
</div>
</footer>