From 0b34654736365356465af3e79e7eba386976d29e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:20:14 +0000 Subject: [PATCH] feat: add note version history and restore (#note-history) Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com> --- src/lib/components/NoteEditor.svelte | 54 +++++ src/lib/components/NoteHistory.svelte | 222 ++++++++++++++++++ src/lib/server/db/index.ts | 14 ++ src/lib/server/db/schema.ts | 20 ++ src/lib/server/db/test-helpers.ts | 13 + src/lib/server/notes-service.ts | 21 ++ src/lib/server/versions-service.test.ts | 207 ++++++++++++++++ src/lib/server/versions-service.ts | 118 ++++++++++ src/lib/types/index.ts | 20 ++ src/routes/api/notes/[id]/versions/+server.ts | 13 + .../[id]/versions/[versionId]/+server.ts | 15 ++ .../versions/[versionId]/restore/+server.ts | 29 +++ 12 files changed, 746 insertions(+) create mode 100644 src/lib/components/NoteHistory.svelte create mode 100644 src/lib/server/versions-service.test.ts create mode 100644 src/lib/server/versions-service.ts create mode 100644 src/routes/api/notes/[id]/versions/+server.ts create mode 100644 src/routes/api/notes/[id]/versions/[versionId]/+server.ts create mode 100644 src/routes/api/notes/[id]/versions/[versionId]/restore/+server.ts diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 64a1911..6aefdb3 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -5,6 +5,7 @@ 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'; @@ -19,6 +20,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; @@ -55,6 +57,7 @@ let attachmentsList = $state(note?.attachments ?? []); let showShareDialog = $state(false); + let showHistory = $state(false); // svelte-ignore state_referenced_locally let collaboratorsList = $state(note?.collaborators ?? []); const isOwner = $derived(note?.isOwner !== false); @@ -84,6 +87,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)) { @@ -381,6 +415,18 @@ {/if} + + + {#if !currentlyNew} + + {/if} + + +
+ +
+ {#if isLoading} +
+ Loading… +
+ {:else if versions.length === 0} +
+ No history yet +
+ {:else} +
    + {#each versions as v (v.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + +
+ {#if isLoadingVersion} +
+ Loading… +
+ {:else if selectedVersion} +
+
+ {#if selectedVersion.title} +

{selectedVersion.title}

+ {/if} +
{selectedVersion.content || '(empty)'}
+
+ + +
+ {#if showConfirm} +
+

+ This will replace the current note. The current version will be saved as a snapshot. +

+
+ + +
+
+ {:else} + + {/if} +
+
+ {:else} +
+ Select a version to preview +
+ {/if} +
+
+ + diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index e055336..931834d 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -123,6 +123,18 @@ sqlite.exec(` 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 @@ -196,6 +208,8 @@ sqlite.exec(` 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, user_id INTEGER NOT NULL REFERENCES users(id), diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index d3c80f8..0c84755 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -189,3 +189,23 @@ export const userPreferences = sqliteTable( }, (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) + ] +); diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index 68d42eb..e33487f 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -136,6 +136,19 @@ export function createTestDb(options?: { seedUser?: boolean }) { 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 }); diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts index b500aaa..7cbf947 100644 --- a/src/lib/server/notes-service.ts +++ b/src/lib/server/notes-service.ts @@ -7,6 +7,7 @@ import { fetchTagsForNotes, syncNoteTags } from './tags.js'; import { fetchAttachmentsForNotes } from './attachments.js'; import { fetchCollaboratorsForNotes } from './collaborators.js'; import { canAccessNote } from './api-utils.js'; +import { createSnapshot } from './versions-service.js'; import type { NoteFilter } from '$lib/types/index.js'; interface NoteRow { @@ -293,6 +294,26 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote } } + // Create a version snapshot whenever content-related fields are saved + const hasContentUpdate = + input.title !== undefined || + input.content !== undefined || + input.color !== undefined || + input.checklistMode !== undefined; + + if (hasContentUpdate) { + const saved = db.select().from(notes).where(eq(notes.id, id)).get(); + if (saved) { + createSnapshot(db, id, { + version: saved.version, + title: saved.title, + content: saved.content, + checklistMode: saved.checklistMode, + color: saved.color + }); + } + } + return getNote(db, userId, id); } diff --git a/src/lib/server/versions-service.test.ts b/src/lib/server/versions-service.test.ts new file mode 100644 index 0000000..681dc9f --- /dev/null +++ b/src/lib/server/versions-service.test.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/src/lib/server/versions-service.ts b/src/lib/server/versions-service.ts new file mode 100644 index 0000000..4974102 --- /dev/null +++ b/src/lib/server/versions-service.ts @@ -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. + * Called on every save. 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; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 494abe4..144fe26 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -98,5 +98,25 @@ 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'; diff --git a/src/routes/api/notes/[id]/versions/+server.ts b/src/routes/api/notes/[id]/versions/+server.ts new file mode 100644 index 0000000..231d647 --- /dev/null +++ b/src/routes/api/notes/[id]/versions/+server.ts @@ -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); +}; diff --git a/src/routes/api/notes/[id]/versions/[versionId]/+server.ts b/src/routes/api/notes/[id]/versions/[versionId]/+server.ts new file mode 100644 index 0000000..21ec42c --- /dev/null +++ b/src/routes/api/notes/[id]/versions/[versionId]/+server.ts @@ -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); +}; diff --git a/src/routes/api/notes/[id]/versions/[versionId]/restore/+server.ts b/src/routes/api/notes/[id]/versions/[versionId]/restore/+server.ts new file mode 100644 index 0000000..d850826 --- /dev/null +++ b/src/routes/api/notes/[id]/versions/[versionId]/restore/+server.ts @@ -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); +};