@knotpad/app 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +49 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
package/lib/note-sync.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for keeping notes ↔ tasks in sync.
|
|
3
|
+
* - Snapshot management (base content for three-way merge)
|
|
4
|
+
* - Note badge updates when task status changes
|
|
5
|
+
* - Re-parse note content into tasks after edits
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { prisma } from "@/lib/prisma";
|
|
9
|
+
import type { PrismaClient, Priority } from "@/app/generated/prisma/client";
|
|
10
|
+
import { parseTasksFromMarkdown } from "@/lib/task-parser";
|
|
11
|
+
import { resolveAssignee } from "@/lib/mentions";
|
|
12
|
+
import { encryptForSync, decryptFromSync } from "@/lib/note-crypto";
|
|
13
|
+
|
|
14
|
+
// --- Snapshot store (noteSnapshots field in SyncState) ---
|
|
15
|
+
|
|
16
|
+
type Snapshots = Record<string, string>; // noteId → content
|
|
17
|
+
|
|
18
|
+
export async function getOrCreateSyncState(workspaceId: string, db: PrismaClient = prisma) {
|
|
19
|
+
const existing = await db.syncState.findUnique({ where: { workspaceId } });
|
|
20
|
+
if (existing) return existing;
|
|
21
|
+
return db.syncState.create({ data: { workspaceId } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getSnapshots(
|
|
25
|
+
workspaceId: string,
|
|
26
|
+
db: PrismaClient = prisma
|
|
27
|
+
): Promise<{ snapshots: Snapshots; version: number }> {
|
|
28
|
+
const state = await db.syncState.findUnique({ where: { workspaceId } });
|
|
29
|
+
const version = state?.snapshotVersion ?? 0;
|
|
30
|
+
if (!state?.noteSnapshots) return { snapshots: {}, version };
|
|
31
|
+
try {
|
|
32
|
+
// Snapshots hold note content (the three-way-merge base), so the JSON blob
|
|
33
|
+
// is encrypted at rest. Values inside the parsed map are plaintext.
|
|
34
|
+
const json = await decryptFromSync(state.noteSnapshots, workspaceId);
|
|
35
|
+
return { snapshots: JSON.parse(json) as Snapshots, version };
|
|
36
|
+
} catch {
|
|
37
|
+
return { snapshots: {}, version };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function saveSnapshots(
|
|
42
|
+
workspaceId: string,
|
|
43
|
+
snapshots: Snapshots,
|
|
44
|
+
expectedVersion?: number,
|
|
45
|
+
db: PrismaClient = prisma
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const blob = await encryptForSync(JSON.stringify(snapshots), workspaceId);
|
|
48
|
+
|
|
49
|
+
if (expectedVersion !== undefined) {
|
|
50
|
+
// Optimistic concurrency: only write if the version hasn't changed since we read.
|
|
51
|
+
// Protects against concurrent writes from multiple Node.js instances (cloud mode).
|
|
52
|
+
const updated = await db.syncState.updateMany({
|
|
53
|
+
where: { workspaceId, snapshotVersion: expectedVersion },
|
|
54
|
+
data: { noteSnapshots: blob, snapshotVersion: { increment: 1 } },
|
|
55
|
+
});
|
|
56
|
+
if (updated.count === 0) {
|
|
57
|
+
throw new Error("Snapshot version conflict — another process updated concurrently");
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await db.syncState.upsert({
|
|
63
|
+
where: { workspaceId },
|
|
64
|
+
update: { noteSnapshots: blob, snapshotVersion: { increment: 1 } },
|
|
65
|
+
create: { workspaceId, noteSnapshots: blob, snapshotVersion: 1 },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Per-workspace serialization: getSnapshots → mutate → saveSnapshots is a
|
|
70
|
+
// read-modify-write of one JSON blob, so concurrent single-note updates within
|
|
71
|
+
// this process would clobber each other. Chain them per workspace.
|
|
72
|
+
// For multi-instance protection, saveSnapshots uses optimistic concurrency.
|
|
73
|
+
const snapshotChains = new Map<string, Promise<unknown>>();
|
|
74
|
+
|
|
75
|
+
function withSnapshotLock<T>(workspaceId: string, fn: () => Promise<T>): Promise<T> {
|
|
76
|
+
const prev = snapshotChains.get(workspaceId) ?? Promise.resolve();
|
|
77
|
+
const next = prev.then(fn, fn);
|
|
78
|
+
// Keep the chain alive but don't let rejections poison the next caller.
|
|
79
|
+
snapshotChains.set(workspaceId, next.catch(() => {}));
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function saveSnapshot(
|
|
84
|
+
workspaceId: string,
|
|
85
|
+
noteId: string,
|
|
86
|
+
content: string,
|
|
87
|
+
db: PrismaClient = prisma
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
await withSnapshotLock(workspaceId, async () => {
|
|
90
|
+
const { snapshots, version } = await getSnapshots(workspaceId, db);
|
|
91
|
+
snapshots[noteId] = content;
|
|
92
|
+
await saveSnapshots(workspaceId, snapshots, version, db);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function deleteSnapshot(
|
|
97
|
+
workspaceId: string,
|
|
98
|
+
noteId: string,
|
|
99
|
+
db: PrismaClient = prisma
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
await withSnapshotLock(workspaceId, async () => {
|
|
102
|
+
const { snapshots, version } = await getSnapshots(workspaceId, db);
|
|
103
|
+
delete snapshots[noteId];
|
|
104
|
+
await saveSnapshots(workspaceId, snapshots, version, db);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Note badge update (inline status markers) ---
|
|
109
|
+
|
|
110
|
+
const STATUS_COMMENT_RE = /\s*<!--task::[^>]*-->/g;
|
|
111
|
+
const TASK_LINE_RE = /^(\s*)-\s+\[([ x])\]\s+(.+)$/;
|
|
112
|
+
|
|
113
|
+
export async function updateNoteBadge(
|
|
114
|
+
noteId: string,
|
|
115
|
+
taskTitle: string,
|
|
116
|
+
newStatus: string,
|
|
117
|
+
db: PrismaClient = prisma
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const note = await db.note.findUnique({ where: { id: noteId } });
|
|
120
|
+
if (!note) return;
|
|
121
|
+
|
|
122
|
+
const plain = await decryptFromSync(note.content, note.workspaceId);
|
|
123
|
+
const lines = plain.split("\n");
|
|
124
|
+
let changed = false;
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
const m = TASK_LINE_RE.exec(lines[i]);
|
|
128
|
+
if (!m) continue;
|
|
129
|
+
|
|
130
|
+
const lineTitle = m[3]
|
|
131
|
+
.replace(/@[\w-]+/g, "")
|
|
132
|
+
.replace(/<[^>]+>/g, "")
|
|
133
|
+
.replace(STATUS_COMMENT_RE, "")
|
|
134
|
+
.trim();
|
|
135
|
+
|
|
136
|
+
if (lineTitle !== taskTitle) continue;
|
|
137
|
+
|
|
138
|
+
// Flip the checkbox only; <!--task::STATUS--> comments are no longer written
|
|
139
|
+
// (nothing reads them and they caused spurious sync diffs).
|
|
140
|
+
const stripped = lines[i].replace(STATUS_COMMENT_RE, "");
|
|
141
|
+
const checkbox = newStatus === "DONE" ? "x" : " ";
|
|
142
|
+
const next = stripped.replace(/\[([ x])\]/, `[${checkbox}]`);
|
|
143
|
+
if (next !== lines[i]) { lines[i] = next; changed = true; }
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (changed) {
|
|
148
|
+
const stored = await encryptForSync(lines.join("\n"), note.workspaceId);
|
|
149
|
+
await db.note.update({
|
|
150
|
+
where: { id: noteId },
|
|
151
|
+
data: { content: stored, version: { increment: 1 } },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Re-parse note content → sync tasks (idempotent) ---
|
|
157
|
+
|
|
158
|
+
export async function syncNoteToTasks(
|
|
159
|
+
noteId: string,
|
|
160
|
+
workspaceId: string,
|
|
161
|
+
db: PrismaClient = prisma
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const note = await db.note.findUnique({
|
|
164
|
+
where: { id: noteId },
|
|
165
|
+
include: { tasks: true },
|
|
166
|
+
});
|
|
167
|
+
if (!note) return;
|
|
168
|
+
|
|
169
|
+
const plain = await decryptFromSync(note.content, workspaceId);
|
|
170
|
+
const parsed = parseTasksFromMarkdown(plain);
|
|
171
|
+
const existingByTitle = new Map(note.tasks.map((t) => [t.title, t]));
|
|
172
|
+
|
|
173
|
+
await db.$transaction(async (tx) => {
|
|
174
|
+
for (const p of parsed) {
|
|
175
|
+
if (existingByTitle.has(p.title)) {
|
|
176
|
+
existingByTitle.delete(p.title);
|
|
177
|
+
continue; // already exists, idempotent
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { assigneeId, assigneeType } = await resolveAssignee(tx, workspaceId, p.assigneeHandle);
|
|
181
|
+
|
|
182
|
+
const task = await tx.task.create({
|
|
183
|
+
data: {
|
|
184
|
+
title: p.title,
|
|
185
|
+
noteId,
|
|
186
|
+
workspaceId,
|
|
187
|
+
assigneeType,
|
|
188
|
+
assigneeId,
|
|
189
|
+
fileRefs: p.fileRefs,
|
|
190
|
+
...(p.priority && { priority: p.priority as Priority }),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Snippet is intentionally empty — computed from decrypted note content at
|
|
195
|
+
// read time (task-detail page) so no plaintext leaks into the DB.
|
|
196
|
+
await tx.taskReference.create({
|
|
197
|
+
data: { taskId: task.id, noteId, snippet: "" },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Extract a human-readable title from raw markdown content.
|
|
2
|
+
// Strips common markdown syntax and returns the first meaningful sentence,
|
|
3
|
+
// capped at 80 characters so it fits nicely in sidebars and card labels.
|
|
4
|
+
|
|
5
|
+
const MAX_AUTO_TITLE_LEN = 80;
|
|
6
|
+
|
|
7
|
+
export function extractTitleFromContent(content: string): string | null {
|
|
8
|
+
if (!content || !content.trim()) return null;
|
|
9
|
+
|
|
10
|
+
const lines = content.split("\n");
|
|
11
|
+
for (const raw of lines) {
|
|
12
|
+
let line = raw.trim();
|
|
13
|
+
if (!line) continue;
|
|
14
|
+
|
|
15
|
+
// Strip heading markers
|
|
16
|
+
line = line.replace(/^#{1,6}\s*/, "");
|
|
17
|
+
|
|
18
|
+
// Strip task checkbox prefix
|
|
19
|
+
line = line.replace(/^\s*-\s+\[[ x]\]\s*/, "");
|
|
20
|
+
|
|
21
|
+
// Strip bold / italic / strikethrough markers
|
|
22
|
+
line = line.replace(/(\*{1,2}|_{1,2}|~{2})([^*_~]+)\1/g, "$2");
|
|
23
|
+
|
|
24
|
+
// Strip inline code backticks
|
|
25
|
+
line = line.replace(/`([^`]+)`/g, "$1");
|
|
26
|
+
|
|
27
|
+
// Strip markdown links → keep only the text part
|
|
28
|
+
line = line.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
29
|
+
|
|
30
|
+
// Strip bare URLs
|
|
31
|
+
line = line.replace(/https?:\/\/\S+/g, "");
|
|
32
|
+
|
|
33
|
+
// Strip HTML tags
|
|
34
|
+
line = line.replace(/<[^>]+>/g, "");
|
|
35
|
+
|
|
36
|
+
// Strip image syntax
|
|
37
|
+
line = line.replace(/!\[[^\]]*\]\([^)]+\)/g, "");
|
|
38
|
+
|
|
39
|
+
// Strip note/task refs
|
|
40
|
+
line = line.replace(/\[\[[^\]]+\]\]/g, "");
|
|
41
|
+
line = line.replace(/\(\([^)]+\)\)/g, "");
|
|
42
|
+
|
|
43
|
+
// Strip mention handles
|
|
44
|
+
line = line.replace(/@[\w-]+/g, "");
|
|
45
|
+
|
|
46
|
+
// Strip file references <...>
|
|
47
|
+
line = line.replace(/<[^>]+>/g, "");
|
|
48
|
+
|
|
49
|
+
// Strip date/date-ranges
|
|
50
|
+
line = line.replace(/\d{4}-\d{2}-\d{2}(\.\.\d{4}-\d{2}-\d{2})?/g, "");
|
|
51
|
+
|
|
52
|
+
// Strip remaining markdown list markers
|
|
53
|
+
line = line.replace(/^(\s*[-*+]|\d+\.)\s+/, "");
|
|
54
|
+
|
|
55
|
+
// Clean up whitespace
|
|
56
|
+
line = line.replace(/\s+/g, " ").trim();
|
|
57
|
+
|
|
58
|
+
if (!line) continue;
|
|
59
|
+
|
|
60
|
+
// Cap length at first sentence boundary if possible, otherwise first 4 words
|
|
61
|
+
let title = line;
|
|
62
|
+
const sentenceEnd = title.search(/[.!?](\s|$)/);
|
|
63
|
+
if (sentenceEnd !== -1 && sentenceEnd > 0) {
|
|
64
|
+
title = title.slice(0, sentenceEnd + 1);
|
|
65
|
+
} else {
|
|
66
|
+
// No punctuation — grab the first 4 words
|
|
67
|
+
const words = title.split(" ");
|
|
68
|
+
if (words.length > 4) {
|
|
69
|
+
title = words.slice(0, 4).join(" ");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (title.length > MAX_AUTO_TITLE_LEN) {
|
|
73
|
+
title = title.slice(0, MAX_AUTO_TITLE_LEN);
|
|
74
|
+
// Don't chop mid-word
|
|
75
|
+
const lastSpace = title.lastIndexOf(" ");
|
|
76
|
+
if (lastSpace > 20) title = title.slice(0, lastSpace);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
title = title.trim();
|
|
80
|
+
if (title) return title;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function autoTitle(content: string, currentTitle?: string): string {
|
|
87
|
+
// Only auto-title if the user hasn't explicitly named it
|
|
88
|
+
if (currentTitle && currentTitle.trim() && currentTitle.trim() !== "Untitled") {
|
|
89
|
+
return currentTitle.trim();
|
|
90
|
+
}
|
|
91
|
+
const extracted = extractTitleFromContent(content);
|
|
92
|
+
return extracted ?? "Untitled";
|
|
93
|
+
}
|
package/lib/prisma.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma client factory.
|
|
3
|
+
*
|
|
4
|
+
* IS_CLOUD=true (VPS / SaaS)
|
|
5
|
+
* prisma → Neon (DATABASE_URL) — all data lives in cloud
|
|
6
|
+
* cloudPrisma → same Neon client — re-exported for sync layer compatibility
|
|
7
|
+
*
|
|
8
|
+
* IS_CLOUD=false (npx / local)
|
|
9
|
+
* prisma → PGlite (file-based, .brief/db/)
|
|
10
|
+
* cloudPrisma → Neon (CLOUD_DATABASE_URL) or null (free tier, no cloud)
|
|
11
|
+
*
|
|
12
|
+
* Initialization:
|
|
13
|
+
* Cloud mode — synchronous, happens at module load
|
|
14
|
+
* Local mode — async (PGlite uses dynamic import); call initLocalPrisma()
|
|
15
|
+
* from instrumentation.ts before requests start
|
|
16
|
+
*
|
|
17
|
+
* Callers that need cloudPrisma should guard: if (!getCloudPrisma()) return;
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { PrismaClient } from "@/app/generated/prisma/client";
|
|
21
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
22
|
+
import { Pool } from "pg";
|
|
23
|
+
import { PrismaNeon } from "@prisma/adapter-neon";
|
|
24
|
+
import path from "path";
|
|
25
|
+
|
|
26
|
+
const IS_CLOUD = process.env.IS_CLOUD === "true";
|
|
27
|
+
|
|
28
|
+
const g = global as unknown as {
|
|
29
|
+
briefPrisma: PrismaClient | undefined;
|
|
30
|
+
briefCloudPrisma: PrismaClient | null | undefined;
|
|
31
|
+
briefPglite: unknown;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function isNeonUrl(url: string): boolean {
|
|
35
|
+
return url.includes("neon.tech") || url.includes("neoncloud");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makePgPool(url: string): Pool {
|
|
39
|
+
return new Pool({
|
|
40
|
+
connectionString: url,
|
|
41
|
+
max: parseInt(process.env.DB_POOL_MAX ?? "10"),
|
|
42
|
+
idleTimeoutMillis: 30_000,
|
|
43
|
+
connectionTimeoutMillis: 5_000,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Cloud mode: synchronous init at module load ───────────────────────────────
|
|
48
|
+
|
|
49
|
+
if (IS_CLOUD && !g.briefPrisma) {
|
|
50
|
+
const url = process.env.DATABASE_URL ?? process.env.CLOUD_DATABASE_URL;
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"[Brief] IS_CLOUD=true requires DATABASE_URL or CLOUD_DATABASE_URL to be set."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let client: PrismaClient;
|
|
58
|
+
if (isNeonUrl(url)) {
|
|
59
|
+
// Neon serverless driver — ideal for Vercel serverless (HTTP/WebSocket, no persistent TCP)
|
|
60
|
+
client = new PrismaClient({
|
|
61
|
+
adapter: new PrismaNeon({ connectionString: url }),
|
|
62
|
+
} as never);
|
|
63
|
+
} else {
|
|
64
|
+
// Standard pg Pool — for self-hosted Postgres / VPS
|
|
65
|
+
client = new PrismaClient({
|
|
66
|
+
adapter: new PrismaPg(makePgPool(url)),
|
|
67
|
+
} as never);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
g.briefPrisma = client;
|
|
71
|
+
g.briefCloudPrisma = client; // same DB in cloud mode
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Local mode: async init (called by instrumentation.ts) ────────────────────
|
|
75
|
+
|
|
76
|
+
export async function initLocalPrisma(): Promise<void> {
|
|
77
|
+
if (IS_CLOUD || g.briefPrisma) return;
|
|
78
|
+
|
|
79
|
+
const { PGlite } = await import("@electric-sql/pglite");
|
|
80
|
+
const { PrismaPGlite } = await import("pglite-prisma-adapter");
|
|
81
|
+
|
|
82
|
+
const dataDir = process.env.BRIEF_DATA_DIR
|
|
83
|
+
? path.resolve(process.env.BRIEF_DATA_DIR)
|
|
84
|
+
: path.join(process.cwd(), ".brief", "db");
|
|
85
|
+
|
|
86
|
+
const pglite = new PGlite(dataDir);
|
|
87
|
+
g.briefPglite = pglite;
|
|
88
|
+
|
|
89
|
+
g.briefPrisma = new PrismaClient({
|
|
90
|
+
adapter: new PrismaPGlite(pglite),
|
|
91
|
+
} as never);
|
|
92
|
+
|
|
93
|
+
const cloudUrl = process.env.CLOUD_DATABASE_URL;
|
|
94
|
+
g.briefCloudPrisma = cloudUrl
|
|
95
|
+
? new PrismaClient({
|
|
96
|
+
adapter: isNeonUrl(cloudUrl)
|
|
97
|
+
? new PrismaNeon({ connectionString: cloudUrl })
|
|
98
|
+
: new PrismaPg(makePgPool(cloudUrl)),
|
|
99
|
+
} as never)
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Primary Prisma client.
|
|
107
|
+
* Local mode → PGlite. Cloud mode → Neon.
|
|
108
|
+
* Will throw if accessed before initLocalPrisma() resolves in local mode.
|
|
109
|
+
*/
|
|
110
|
+
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
|
|
111
|
+
get(_t, prop) {
|
|
112
|
+
const client = g.briefPrisma;
|
|
113
|
+
if (!client) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"[Brief] prisma accessed before DB is ready. " +
|
|
116
|
+
"Ensure instrumentation.ts has completed initLocalPrisma()."
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const val = (client as never as Record<string, unknown>)[prop as string];
|
|
120
|
+
return typeof val === "function" ? val.bind(client) : val;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Cloud Prisma client (Neon).
|
|
126
|
+
* Always use getCloudPrisma() rather than this constant — it correctly
|
|
127
|
+
* reflects null (free tier / no cloud) vs a live client (Pro / cloud mode).
|
|
128
|
+
*
|
|
129
|
+
* @deprecated prefer getCloudPrisma()
|
|
130
|
+
*/
|
|
131
|
+
export const cloudPrisma: PrismaClient | null = new Proxy(
|
|
132
|
+
{} as PrismaClient,
|
|
133
|
+
{
|
|
134
|
+
get(_t, prop) {
|
|
135
|
+
return (g.briefCloudPrisma as never as Record<string, unknown>)?.[prop as string];
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
) as unknown as PrismaClient | null;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns the cloud Prisma client, or null if cloud is not configured.
|
|
142
|
+
* Use this for all null-checks:
|
|
143
|
+
* const cloud = getCloudPrisma();
|
|
144
|
+
* if (!cloud) return; // free tier
|
|
145
|
+
*/
|
|
146
|
+
export function getCloudPrisma(): PrismaClient | null {
|
|
147
|
+
return g.briefCloudPrisma ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns the appropriate Prisma client for a workspace:
|
|
152
|
+
* - Pro workspace + cloud configured → Neon (cloud-first)
|
|
153
|
+
* - Free workspace or no cloud → PGlite (local)
|
|
154
|
+
* - IS_CLOUD=true deployments → prisma IS Neon; getCloudPrisma() returns null
|
|
155
|
+
* so this returns prisma (correct — already cloud)
|
|
156
|
+
*
|
|
157
|
+
* Always reads workspace flags from local PGlite, which is the authoritative
|
|
158
|
+
* source for plan state (billing webhook writes there).
|
|
159
|
+
*/
|
|
160
|
+
export async function getPrimaryDb(workspaceId: string): Promise<PrismaClient> {
|
|
161
|
+
const cloud = getCloudPrisma();
|
|
162
|
+
if (!cloud) return prisma;
|
|
163
|
+
|
|
164
|
+
const ws = await prisma.workspace.findUnique({
|
|
165
|
+
where: { id: workspaceId },
|
|
166
|
+
select: { isPro: true, isCloud: true },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return ws?.isPro && ws?.isCloud ? cloud : prisma;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True if a Prisma/fetch error indicates the cloud DB is unreachable.
|
|
174
|
+
* Used by API routes to fall back to the local PGlite write buffer.
|
|
175
|
+
*/
|
|
176
|
+
export function isConnectionError(err: unknown): boolean {
|
|
177
|
+
if (err instanceof Error) {
|
|
178
|
+
const msg = err.message.toLowerCase();
|
|
179
|
+
if (
|
|
180
|
+
msg.includes("can't reach database") ||
|
|
181
|
+
msg.includes("connection refused") ||
|
|
182
|
+
msg.includes("econnrefused") ||
|
|
183
|
+
msg.includes("etimedout") ||
|
|
184
|
+
msg.includes("connection timed out") ||
|
|
185
|
+
msg.includes("server has closed the connection")
|
|
186
|
+
) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const code = (err as { code?: string }).code;
|
|
191
|
+
// Prisma connectivity error codes
|
|
192
|
+
return code === "P1001" || code === "P1002" || code === "P1008" || code === "P1017";
|
|
193
|
+
}
|