Compare commits

..
8 Commits
Author SHA1 Message Date
github-actions[bot] e2ee18893f chore: bump version to 0.27.0 2026-03-24 22:50:50 +00:00
Frederic CabassutandClaude Opus 4.6 be53b4b26e fix: sync attachment changes to store and skip spurious auto-save
Two issues fixed:

1. Attachment operations (upload, feature, remove) only updated the local
   editor state, not the notes store. Before auto-save, this was masked
   because saveAndClose always called updateNote, whose PATCH response
   refreshed the store. With auto-save skipping redundant saves, the store
   was never updated with attachment changes. Now attachment handlers
   update the store directly.

2. TiptapEditor fires onUpdate on init with round-tripped markdown that
   can differ from the original. Added an autoSaveReady guard to absorb
   this initial normalization as the baseline, preventing spurious saves
   when reopening existing notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 042c4c06e5 fix: rename test note to avoid collision with offline test
The auto-save test used "Survive Reload" which collides with the same
title in offline.spec.ts when tests run in parallel with a shared DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 9d1c898b84 fix: address review feedback on auto-save
- Snapshot fields at start of performSave to avoid race with user edits
  during async save (fields could change between await and lastSaved update)
- Only update lastSaved tracking on successful save, so failed saves
  are retried on the next debounce cycle
- Stop restoring title after Untitled fallback in autoSave(), which was
  re-triggering the auto-save effect
- Capture note count before opening editor in empty-note e2e test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 07b27c01e7 feat: auto-save notes with debounced persistence
Notes are now automatically saved 2 seconds after the user stops editing,
preventing data loss when the browser tab is closed without closing the
editor first. A beforeunload handler provides an additional safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 4f4cc69b9f fix: replace hardcoded test passwords with runtime-generated UUIDs
Generate random passwords in global-setup and share via temp file so
all Playwright workers use the same credentials. Screenshot scripts
use module-level crypto.randomUUID() since they run single-process.
Unit test auth passwords also use randomUUID().

Eliminates security scanner alerts for passwords committed to git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:29:45 +01:00
Frederic CabassutandClaude Opus 4.6 3f81f22026 fix: align checkboxes in richtext task list previews
Add missing nested task list rule for .prose context (matching TipTap's
existing rule) and adjust checkbox vertical alignment with margin-top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:13 +01:00
github-actions[bot] 9331351c6d chore: pin docker-compose to v0.26.0 [skip ci] 2026-03-22 19:16:52 +00:00
11 changed files with 245 additions and 34 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.25.0
image: ghcr.io/bretzel-app/crumbs:0.26.0
container_name: crumbs
ports:
- "3000:3000"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.26.0",
"version": "0.27.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
+2 -2
View File
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: 'bretzel2026!'
password: crypto.randomUUID()
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: 'salzig2026!!'
password: crypto.randomUUID()
};
+3 -1
View File
@@ -143,7 +143,8 @@ body::before {
}
/* Nested task lists take full width below the parent item */
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"] {
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
.prose ul.task-list li > ul.task-list {
flex-basis: 100%;
padding-left: 1.5rem;
}
@@ -166,6 +167,7 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
+125 -19
View File
@@ -63,6 +63,100 @@
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// Auto-save: track last-saved state to detect real changes
let lastSavedTitle = note?.title ?? '';
let lastSavedContent = note?.content ?? '';
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
let isSaving = false;
let savingPromise: Promise<void> | null = null;
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
// from the original. Track whether auto-save should be active yet.
let autoSaveReady = isNew;
function hasUnsavedChanges(): boolean {
return (
title !== lastSavedTitle ||
content !== lastSavedContent ||
color !== lastSavedColor ||
checklistMode !== lastSavedChecklistMode
);
}
async function performSave() {
if (isSaving) return;
// Snapshot current fields to avoid races with user edits during await
const snap = { title, content, color, checklistMode };
if (!snap.title.trim() && !snap.content.trim()) return;
if (
snap.title === lastSavedTitle &&
snap.content === lastSavedContent &&
snap.color === lastSavedColor &&
snap.checklistMode === lastSavedChecklistMode
) return;
isSaving = true;
try {
if (currentlyNew) {
const created = await createNote(snap);
if (created) {
noteId = created.id;
currentlyNew = false;
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
} else if (noteId) {
const updated = await updateNote(noteId, snap);
if (updated) {
lastSavedTitle = snap.title;
lastSavedContent = snap.content;
lastSavedColor = snap.color;
lastSavedChecklistMode = snap.checklistMode;
}
}
} finally {
isSaving = false;
}
}
// Debounced auto-save: triggers 2s after the user stops editing
$effect(() => {
const _t = title;
const _c = content;
const _col = color;
const _cm = checklistMode;
if (!autoSaveReady) {
// Absorb TiptapEditor's initial content normalization as the baseline
lastSavedContent = _c;
autoSaveReady = true;
return;
}
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
if (!_t.trim() && !_c.trim()) return;
autoSaveTimer = setTimeout(() => {
savingPromise = performSave();
}, 2000);
return () => clearTimeout(autoSaveTimer);
});
// Safety net: save on tab close / navigation
$effect(() => {
function handleBeforeUnload() {
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
performSave();
}
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
@@ -188,7 +282,13 @@
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
const updated = [...attachmentsList, attachment];
attachmentsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
@@ -200,9 +300,14 @@
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
const updated = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
attachmentsList = updated;
// Keep the notes store in sync so the card reflects featured state
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
@@ -215,7 +320,11 @@
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
attachmentsList = updated;
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
@@ -242,15 +351,18 @@
}
async function saveAndClose() {
clearTimeout(autoSaveTimer);
// Wait for any in-flight auto-save to finish
if (savingPromise) await savingPromise;
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
// Final save if auto-save hasn't caught up yet
if (hasUnsavedChanges()) {
await performSave();
}
onClose();
}
@@ -258,18 +370,12 @@
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
clearTimeout(autoSaveTimer);
if (savingPromise) await savingPromise;
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
if (!title.trim()) title = 'Untitled';
await performSave();
return noteId;
}
async function toggleImageUpload() {
+5 -5
View File
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
@@ -16,16 +16,16 @@ beforeEach(() => {
describe('Auth - Password Hashing', () => {
it('should hash and verify a password correctly', async () => {
const password = 'mysecurepassword';
const password = randomUUID();
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
expect(await argon2.verify(hash, randomUUID())).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = 'mysecurepassword';
const password = randomUUID();
const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
describe('Auth - User Setup', () => {
it('should create a user with hashed password', async () => {
const password = 'testpassword123';
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
+5 -1
View File
@@ -1,4 +1,8 @@
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: 'userpassword123',
password: userPassword,
role: 'user'
}
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Auto-save', () => {
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
// When the user starts a new note and types a title and content
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-saved Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('This should be saved automatically');
// Then the note is persisted after the auto-save delay (2s debounce)
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
// And closing the editor preserves the note
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
});
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
// Given a note titled "Edit Me" exists
await createNote(page, 'Edit Me', 'Original content');
// When the user opens the note and changes the title
await noteCard(page, 'Edit Me').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Edited Title');
// Then the updated title is auto-saved after the debounce delay
// Wait for the API call that confirms the save
await page.waitForResponse(
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
{ timeout: 5000 }
);
// And the note card reflects the change while editor is still open
await expect(noteCard(page, 'Edited Title')).toBeVisible();
// Cleanup: close the editor
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
// Given the current number of notes
const noteCards = page.locator('[data-testid="note-card"]');
const countBefore = await noteCards.count();
// When the user opens a new note but leaves it empty
await page.getByTestId('new-note-btn').click();
// And waits longer than the auto-save delay
await page.waitForTimeout(3000);
// And closes the empty editor
await page.getByTestId('close-editor-btn').click();
// Then no new note was created
const countAfter = await noteCards.count();
expect(countAfter).toBe(countBefore);
});
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
// When the user creates a note and waits for auto-save
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Content that survives');
// Wait for auto-save to fire
await page.waitForResponse(
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
{ timeout: 5000 }
);
// When the page is reloaded without closing the editor
await page.reload();
await page.waitForLoadState('networkidle');
// Then the note is still visible
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
});
});
+13 -1
View File
@@ -1,6 +1,8 @@
import { existsSync, unlinkSync } from 'fs';
import { existsSync, unlinkSync, writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
const TEST_DB = './data/test-crumbs.db';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
@@ -10,4 +12,14 @@ export default function globalSetup() {
unlinkSync(path);
}
}
// Generate random passwords for this test run, shared across all workers
writeFileSync(
TEST_CREDENTIALS_FILE,
JSON.stringify({
testPassword: `test-${randomUUID()}`,
collabPassword: `collab-${randomUUID()}`,
userPassword: `user-${randomUUID()}`
})
);
}
+6 -2
View File
@@ -1,12 +1,16 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
import { readFileSync } from 'fs';
import { collectCoverage } from './coverage';
import { TEST_CREDENTIALS_FILE } from '../global-setup';
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_PASSWORD = testPassword;
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.26.0</span>
<span class="footer-version">v0.27.0</span>
</div>
</footer>