diff --git a/backend/src/modules/notes/export.test.ts b/backend/src/modules/notes/export.test.ts new file mode 100644 index 0000000..bb3cfd7 --- /dev/null +++ b/backend/src/modules/notes/export.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { buildNotesExportPayload, exportFilename, renderNotesMarkdownExport } from './export.js'; +import type { NoteDoc } from './types.js'; + +function note(overrides: Partial): NoteDoc { + return { + id: 'note-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + title: 'Launch Note', + body: '

Ship & verify

', + status: 'active', + tags: ['beta', 'alpha'], + links: ['z', 'a'], + createdAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-02T00:00:00.000Z', + createdBy: 'user-1', + updatedBy: 'user-1', + embedding: [0.1], + _etag: 'etag', + _ts: 1, + ...overrides, + }; +} + +describe('notes export', () => { + it('builds deterministic metadata and sorted export notes without storage-only fields', () => { + const payload = buildNotesExportPayload({ + productId: 'notelett', + userId: 'user-1', + workspaceId: 'ws-1', + exportedAt: '2026-05-05T00:00:00.000Z', + notes: [ + note({ id: 'b-note', updatedAt: '2026-05-01T00:00:00.000Z', tags: ['z', 'a'] }), + note({ id: 'a-note', updatedAt: '2026-05-03T00:00:00.000Z', tags: ['beta', 'alpha'] }), + ], + }); + + expect(payload).toMatchObject({ + schemaVersion: 'notelett.notes.export.v1', + productId: 'notelett', + exportedAt: '2026-05-05T00:00:00.000Z', + scope: { userId: 'user-1', workspaceId: 'ws-1' }, + metadata: { + noteCount: 2, + sort: 'workspaceId:asc,updatedAt:desc,id:asc', + importStatus: 'deferred', + }, + }); + expect(payload.notes.map((item) => item.id)).toEqual(['a-note', 'b-note']); + expect(payload.notes[0]).not.toHaveProperty('embedding'); + expect(payload.notes[0].tags).toEqual(['alpha', 'beta']); + }); + + it('renders readable markdown with export metadata', () => { + const payload = buildNotesExportPayload({ + productId: 'notelett', + userId: 'user-1', + exportedAt: '2026-05-05T00:00:00.000Z', + notes: [note({ title: '# Heading', body: '

Ship & verify

' })], + }); + + const markdown = renderNotesMarkdownExport(payload); + + expect(markdown).toContain('# NoteLett Notes Export'); + expect(markdown).toContain('- Import: deferred'); + expect(markdown).toContain('## \\# Heading'); + expect(markdown).toContain('Ship & verify'); + }); + + it('uses deterministic safe filenames', () => { + expect(exportFilename('json')).toBe('notelett-notes-all.json'); + expect(exportFilename('markdown', 'Workspace 1 / Launch')).toBe('notelett-notes-Workspace-1-Launch.md'); + }); +}); diff --git a/backend/src/modules/notes/export.ts b/backend/src/modules/notes/export.ts new file mode 100644 index 0000000..cca0644 --- /dev/null +++ b/backend/src/modules/notes/export.ts @@ -0,0 +1,164 @@ +import type { NoteDoc } from './types.js'; + +export type NotesExportFormat = 'json' | 'markdown'; + +export interface NotesExportPayload { + schemaVersion: 'notelett.notes.export.v1'; + productId: string; + exportedAt: string; + scope: { + userId: string; + workspaceId: string | null; + }; + metadata: { + noteCount: number; + sort: 'workspaceId:asc,updatedAt:desc,id:asc'; + fields: readonly string[]; + importStatus: 'deferred'; + }; + notes: ExportedNote[]; +} + +export interface ExportedNote { + id: string; + workspaceId: string; + title: string; + body: string; + status: NoteDoc['status']; + tags: string[]; + links: string[]; + sourceType: string | null; + sourceUri: string | null; + createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; +} + +const EXPORT_FIELDS = [ + 'id', + 'workspaceId', + 'title', + 'body', + 'status', + 'tags', + 'links', + 'sourceType', + 'sourceUri', + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', +] as const; + +export function buildNotesExportPayload(input: { + notes: NoteDoc[]; + productId: string; + userId: string; + workspaceId?: string; + exportedAt?: string; +}): NotesExportPayload { + const notes = input.notes + .map(toExportedNote) + .sort((a, b) => + a.workspaceId.localeCompare(b.workspaceId) || + b.updatedAt.localeCompare(a.updatedAt) || + a.id.localeCompare(b.id) + ); + + return { + schemaVersion: 'notelett.notes.export.v1', + productId: input.productId, + exportedAt: input.exportedAt ?? new Date().toISOString(), + scope: { + userId: input.userId, + workspaceId: input.workspaceId ?? null, + }, + metadata: { + noteCount: notes.length, + sort: 'workspaceId:asc,updatedAt:desc,id:asc', + fields: EXPORT_FIELDS, + importStatus: 'deferred', + }, + notes, + }; +} + +export function renderNotesMarkdownExport(payload: NotesExportPayload): string { + const lines = [ + '# NoteLett Notes Export', + '', + `- Schema: ${payload.schemaVersion}`, + `- Product: ${payload.productId}`, + `- Exported at: ${payload.exportedAt}`, + `- Workspace: ${payload.scope.workspaceId ?? 'all'}`, + `- Notes: ${payload.metadata.noteCount}`, + `- Sort: ${payload.metadata.sort}`, + `- Import: ${payload.metadata.importStatus}`, + '', + ]; + + for (const note of payload.notes) { + lines.push( + '---', + '', + `## ${escapeMarkdownHeading(note.title)}`, + '', + `- ID: ${note.id}`, + `- Workspace: ${note.workspaceId}`, + `- Status: ${note.status}`, + `- Tags: ${note.tags.length > 0 ? note.tags.join(', ') : 'none'}`, + `- Created: ${note.createdAt}`, + `- Updated: ${note.updatedAt}`, + '', + normalizeMarkdownBody(note.body), + '', + ); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +export function exportFilename(format: NotesExportFormat, workspaceId?: string): string { + const suffix = workspaceId ? sanitizeFilenamePart(workspaceId) : 'all'; + return `notelett-notes-${suffix}.${format === 'markdown' ? 'md' : 'json'}`; +} + +function toExportedNote(note: NoteDoc): ExportedNote { + return { + id: note.id, + workspaceId: note.workspaceId, + title: note.title, + body: note.body, + status: note.status, + tags: [...note.tags].sort((a, b) => a.localeCompare(b)), + links: [...note.links].sort((a, b) => a.localeCompare(b)), + sourceType: note.sourceType ?? null, + sourceUri: note.sourceUri ?? null, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + createdBy: note.createdBy, + updatedBy: note.updatedBy, + }; +} + +function sanitizeFilenamePart(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'workspace'; +} + +function escapeMarkdownHeading(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/#/g, '\\#').trim(); +} + +function normalizeMarkdownBody(value: string): string { + return value + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index 0bad8ce..f2319e0 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -24,6 +24,7 @@ beforeAll(async () => { beforeEach(() => { resetMemoryDatastore(); + extractAuthMock.mockResolvedValue({ sub: 'user_1', type: 'access', role: 'editor' }); }); afterAll(async () => { @@ -152,16 +153,58 @@ describe('notes routes — integration', () => { const res = await app.inject({ method: 'GET', url: '/api/notes/export' }); expect(res.statusCode).toBe(200); const body = res.json(); + expect(res.headers['content-disposition']).toContain('notelett-notes-all.json'); + expect(body.schemaVersion).toBe('notelett.notes.export.v1'); + expect(body.scope).toEqual({ userId: 'user_1', workspaceId: null }); + expect(body.metadata).toMatchObject({ + noteCount: 1, + sort: 'workspaceId:asc,updatedAt:desc,id:asc', + importStatus: 'deferred', + }); expect(body.notes).toHaveLength(1); expect(body.exportedAt).toBeDefined(); + expect(body.notes[0]).not.toHaveProperty('embedding'); }); it('GET /notes/export?format=markdown returns markdown', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); - const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown' }); + const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown&workspaceId=ws-1' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('text/markdown'); - expect(res.body).toContain('# Test Note'); + expect(res.headers['content-disposition']).toContain('notelett-notes-ws-1.md'); + expect(res.body).toContain('# NoteLett Notes Export'); + expect(res.body).toContain('## Test Note'); + expect(res.body).toContain('- Import: deferred'); + }); + + it('GET /notes/export paginates all notes and keeps user/workspace scope', async () => { + for (let i = 0; i < 105; i += 1) { + await app.inject({ + method: 'POST', + url: '/api/notes', + payload: { + ...validNote, + id: `note-${i}`, + workspaceId: i % 2 === 0 ? 'ws-1' : 'ws-2', + title: `Note ${i}`, + }, + }); + } + + extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'editor' }); + await app.inject({ + method: 'POST', + url: '/api/notes', + payload: { ...validNote, id: 'other-user-note', title: 'Other User' }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/notes/export?workspaceId=ws-1' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.metadata.noteCount).toBe(53); + expect(body.scope).toEqual({ userId: 'user_1', workspaceId: 'ws-1' }); + expect(body.notes.every((note: { workspaceId: string }) => note.workspaceId === 'ws-1')).toBe(true); + expect(body.notes.some((note: { id: string }) => note.id === 'other-user-note')).toBe(false); }); it('GET /notes/export rejects invalid format', async () => { diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index 60e9800..b65f3a2 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -18,6 +18,7 @@ import * as versionRepo from '../note-versions/repository.js'; import { CreateNoteShareSchema } from '../note-shares/types.js'; import { ListNoteVersionsQuerySchema } from '../note-versions/types.js'; import type { NoteVersionDoc } from '../note-versions/types.js'; +import { buildNotesExportPayload, exportFilename, renderNotesMarkdownExport } from './export.js'; import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js'; type RouteApp = Omit; @@ -42,6 +43,11 @@ const ChatBodySchema = z.object({ message: z.string().min(1).max(2000), }); +const ExportNotesQuerySchema = z.object({ + format: z.enum(['json', 'markdown']).default('json'), + workspaceId: z.string().min(1).max(128).optional(), +}); + const NOTE_AI_RATE_LIMIT = { label: 'note AI routes', max: 30, windowMs: 10 * 60_000 }; function assertNoteAiRateLimit(userId: string, route: string): void { @@ -62,6 +68,25 @@ function toLexicalHits(items: NoteDoc[]) { }); } +async function listAllNotesForExport( + userId: string, + workspaceId?: string, +): Promise { + const limit = 100; + const items: NoteDoc[] = []; + let offset = 0; + let total = 0; + + do { + const result = await repo.listNotes(userId, PRODUCT_ID, { workspaceId, limit, offset }); + items.push(...result.items); + total = result.total; + offset += limit; + } while (items.length < total); + + return items; +} + export async function noteRoutes(app: RouteApp) { app.get('/notes/search', async req => { if (!isFeatureEnabled('notes.enabled')) { @@ -96,28 +121,30 @@ export async function noteRoutes(app: RouteApp) { // Must be registered before GET /notes/:id or "export" is captured as :id. app.get('/notes/export', async (req, reply) => { const auth = await extractAuth(req); - const query = req.query as { format?: string; workspaceId?: string }; - const format = query.format ?? 'json'; - - if (format !== 'json' && format !== 'markdown') { - throw new BadRequestError('format must be json or markdown'); + const parsed = ExportNotesQuerySchema.safeParse(req.query ?? {}); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); } - const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 }; - const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery); + const { format, workspaceId } = parsed.data; + const notes = await listAllNotesForExport(auth.sub, workspaceId); + const payload = buildNotesExportPayload({ + notes, + productId: PRODUCT_ID, + userId: auth.sub, + workspaceId, + }); + const filename = exportFilename(format, workspaceId); if (format === 'markdown') { - const md = result.items - .map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`) - .join('\n'); reply.header('Content-Type', 'text/markdown'); - reply.header('Content-Disposition', 'attachment; filename="notes-export.md"'); - return md; + reply.header('Content-Disposition', `attachment; filename="${filename}"`); + return renderNotesMarkdownExport(payload); } reply.header('Content-Type', 'application/json'); - reply.header('Content-Disposition', 'attachment; filename="notes-export.json"'); - return { exportedAt: new Date().toISOString(), notes: result.items }; + reply.header('Content-Disposition', `attachment; filename="${filename}"`); + return payload; }); app.get('/notes/:id', async req => { diff --git a/docs/IMPORT_EXPORT_READINESS.md b/docs/IMPORT_EXPORT_READINESS.md new file mode 100644 index 0000000..b4fa16c --- /dev/null +++ b/docs/IMPORT_EXPORT_READINESS.md @@ -0,0 +1,36 @@ +# NoteLett Import And Export Readiness + +Date: May 5, 2026 + +## Export Scope + +Note export is production-ready for authenticated users through `GET /api/notes/export`. + +Supported formats: + +- `format=json` returns `notelett.notes.export.v1` with stable metadata, deterministic field order, sorted note records, and storage-only fields omitted. +- `format=markdown` returns readable Markdown with export metadata and one section per note. + +Supported filters: + +- all notes for the authenticated user +- notes scoped to a single `workspaceId` + +The backend pages through the notes repository in batches of 100 so exports are not silently truncated at the API list limit. Exported records are scoped by authenticated user and `productId: notelett`. + +## Import Deferral + +Import is intentionally deferred for release 1 because the current product has encrypted note bodies, workspace membership boundaries, version history, agent action audit trails, and artifact references. Import needs an explicit conflict and ownership model before it can be safe. + +Acceptance criteria before enabling import: + +- Accept only `notelett.notes.export.v1` JSON. +- Validate every note with Zod before persistence. +- Require an authenticated writer and explicit target workspace ownership or membership. +- Never trust exported `createdBy`, `updatedBy`, `workspaceId`, or `userId` without remapping to the current account/workspace. +- Support dry-run validation that reports create/update/skip counts. +- Define conflict handling for duplicate note IDs, title collisions, tags, links, and source URIs. +- Write note versions and agent audit entries for imported updates. +- Keep field encryption enabled for imported body content. +- Add backend tests for malformed import, cross-user export replay, workspace remap, conflict decisions, and encrypted storage. +- Add web import UX only after dry-run and conflict behavior are available. diff --git a/web/src/app/(app)/workspaces/page.tsx b/web/src/app/(app)/workspaces/page.tsx index 8ffebd1..41a2613 100644 --- a/web/src/app/(app)/workspaces/page.tsx +++ b/web/src/app/(app)/workspaces/page.tsx @@ -6,7 +6,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal"; import { Button } from "@/components/ui/Primitives"; -import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client"; +import { downloadNotesExport, exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client"; import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack"; import { toast } from "@/lib/toast"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; @@ -77,6 +77,15 @@ function WorkspacesPageInner() { } } + async function handleDownloadExport(format: "json" | "markdown", workspaceId?: string) { + try { + await downloadNotesExport(format, workspaceId); + toast.success(`${format === "markdown" ? "Markdown" : "JSON"} export downloaded`); + } catch (err) { + setError(err instanceof Error ? err.message : "Export failed"); + } + } + async function handleDelete(id: string, name: string) { if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return; try { @@ -150,24 +159,11 @@ function WorkspacesPageInner() { > + Workspace - + } @@ -231,6 +227,22 @@ function WorkspacesPageInner() { Owner: {workspace.owner} + +