@knotpad/app 0.1.4 → 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.
Files changed (340) hide show
  1. package/app/(app)/calendar/page.tsx +57 -0
  2. package/app/(app)/error.tsx +35 -0
  3. package/app/(app)/graph/page.tsx +32 -0
  4. package/app/(app)/guide/page.tsx +21 -0
  5. package/app/(app)/kanban/loading.tsx +24 -0
  6. package/app/(app)/kanban/page.tsx +59 -0
  7. package/app/(app)/layout.tsx +122 -0
  8. package/app/(app)/list/loading.tsx +21 -0
  9. package/app/(app)/list/page.tsx +137 -0
  10. package/app/(app)/loading.tsx +18 -0
  11. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  12. package/app/(app)/notes/layout.tsx +30 -0
  13. package/app/(app)/notes/page.tsx +39 -0
  14. package/app/(app)/page.tsx +5 -0
  15. package/app/(app)/settings/agent-token/page.tsx +59 -0
  16. package/app/(app)/settings/backup/page.tsx +49 -0
  17. package/app/(app)/settings/billing/page.tsx +53 -0
  18. package/app/(app)/settings/calendar/page.tsx +41 -0
  19. package/app/(app)/settings/layout.test.tsx +39 -0
  20. package/app/(app)/settings/layout.tsx +71 -0
  21. package/app/(app)/settings/page.tsx +4 -0
  22. package/app/(app)/settings/security/page.tsx +43 -0
  23. package/app/(app)/settings/team/page.tsx +74 -0
  24. package/app/(app)/settings/workspace/page.tsx +27 -0
  25. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  26. package/app/(auth)/forgot-password/page.tsx +106 -0
  27. package/app/(auth)/guest/page.tsx +56 -0
  28. package/app/(auth)/layout.tsx +13 -0
  29. package/app/(auth)/login/page.tsx +14 -0
  30. package/app/(auth)/register/page.tsx +193 -0
  31. package/app/(auth)/reset-password/page.tsx +138 -0
  32. package/app/api/account/claim/route.tsx +135 -0
  33. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  34. package/app/api/admin/license/route.tsx +42 -0
  35. package/app/api/auth/2fa/route.tsx +148 -0
  36. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  37. package/app/api/auth/change-password/route.tsx +61 -0
  38. package/app/api/auth/check-2fa/route.tsx +19 -0
  39. package/app/api/auth/forgot-password/route.tsx +65 -0
  40. package/app/api/auth/reset-password/route.tsx +52 -0
  41. package/app/api/auth/verify-2fa/route.tsx +88 -0
  42. package/app/api/backup/download/db/route.ts +29 -0
  43. package/app/api/backup/download/notes/route.ts +25 -0
  44. package/app/api/backup/settings/route.ts +92 -0
  45. package/app/api/billing/checkout/route.tsx +81 -0
  46. package/app/api/billing/migrate/route.tsx +163 -0
  47. package/app/api/billing/portal/route.tsx +24 -0
  48. package/app/api/billing/setup-intent/route.tsx +55 -0
  49. package/app/api/billing/status/route.tsx +36 -0
  50. package/app/api/billing/subscribe/route.tsx +85 -0
  51. package/app/api/billing/webhook/route.tsx +199 -0
  52. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  53. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  54. package/app/api/calendar-feeds/events/route.tsx +82 -0
  55. package/app/api/calendar-feeds/route.tsx +52 -0
  56. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  57. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  58. package/app/api/cron/stale-tasks/route.tsx +51 -0
  59. package/app/api/cron/sync/route.tsx +34 -0
  60. package/app/api/devices/[deviceId]/route.tsx +25 -0
  61. package/app/api/devices/route.tsx +41 -0
  62. package/app/api/export/route.tsx +40 -0
  63. package/app/api/feedback/route.tsx +54 -0
  64. package/app/api/folders/[folderId]/route.tsx +51 -0
  65. package/app/api/folders/route.tsx +37 -0
  66. package/app/api/graph/route.tsx +242 -0
  67. package/app/api/guest/route.tsx +58 -0
  68. package/app/api/health/route.tsx +10 -0
  69. package/app/api/holidays/countries/route.tsx +14 -0
  70. package/app/api/holidays/route.tsx +49 -0
  71. package/app/api/holidays/states/route.tsx +21 -0
  72. package/app/api/invites/[token]/route.tsx +131 -0
  73. package/app/api/invites/route.tsx +74 -0
  74. package/app/api/mcp/generate-token/route.tsx +55 -0
  75. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  76. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  77. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  78. package/app/api/notes/[noteId]/route.tsx +360 -0
  79. package/app/api/notes/route.tsx +112 -0
  80. package/app/api/notifications/route.tsx +44 -0
  81. package/app/api/register/route.tsx +67 -0
  82. package/app/api/restore/route.tsx +148 -0
  83. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  84. package/app/api/sync/conflicts/route.tsx +48 -0
  85. package/app/api/sync/status/route.tsx +49 -0
  86. package/app/api/sync/trigger/route.tsx +15 -0
  87. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  88. package/app/api/tasks/[taskId]/route.tsx +259 -0
  89. package/app/api/tasks/bulk/route.tsx +133 -0
  90. package/app/api/tasks/route.tsx +36 -0
  91. package/app/api/workspace/active/route.tsx +39 -0
  92. package/app/api/workspace/create-team/route.tsx +42 -0
  93. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  94. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  95. package/app/api/workspace/route.tsx +24 -0
  96. package/app/download/page.tsx +170 -0
  97. package/app/favicon.ico +0 -0
  98. package/app/generated/prisma/client.d.ts +1 -0
  99. package/app/generated/prisma/client.js +5 -0
  100. package/app/generated/prisma/default.d.ts +1 -0
  101. package/app/generated/prisma/default.js +5 -0
  102. package/app/generated/prisma/edge.d.ts +1 -0
  103. package/app/generated/prisma/edge.js +497 -0
  104. package/app/generated/prisma/index-browser.js +523 -0
  105. package/app/generated/prisma/index.d.ts +46376 -0
  106. package/app/generated/prisma/index.js +497 -0
  107. package/app/generated/prisma/package.json +144 -0
  108. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  111. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  112. package/app/generated/prisma/runtime/client.js +86 -0
  113. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  114. package/app/generated/prisma/runtime/index-browser.js +6 -0
  115. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  116. package/app/generated/prisma/schema.prisma +456 -0
  117. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  118. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  119. package/app/globals.css +54 -0
  120. package/app/invite/[token]/page.tsx +52 -0
  121. package/app/layout.tsx +90 -0
  122. package/app/mcp/route.tsx +430 -0
  123. package/app/opengraph-image.tsx +120 -0
  124. package/app/page.tsx +398 -0
  125. package/app/privacy/page.tsx +69 -0
  126. package/app/robots.tsx +25 -0
  127. package/app/sitemap.tsx +36 -0
  128. package/app/terms/page.tsx +69 -0
  129. package/app/upgrade/page.tsx +75 -0
  130. package/auth.config.ts +33 -0
  131. package/auth.ts +79 -0
  132. package/bin/brief.js +224 -0
  133. package/components/auth/login-form.tsx +302 -0
  134. package/components/auth/password-checklist.tsx +31 -0
  135. package/components/auth/password-input.tsx +36 -0
  136. package/components/auth/switch-account-button.test.tsx +22 -0
  137. package/components/auth/switch-account-button.tsx +19 -0
  138. package/components/auth/two-factor-input.tsx +116 -0
  139. package/components/billing/billing-dashboard.tsx +265 -0
  140. package/components/billing/card-form.tsx +210 -0
  141. package/components/billing/claim-account-form.tsx +99 -0
  142. package/components/branding/app-logo.test.tsx +20 -0
  143. package/components/branding/app-logo.tsx +25 -0
  144. package/components/calendar/calendar-agenda.tsx +150 -0
  145. package/components/calendar/calendar-drag.test.tsx +177 -0
  146. package/components/calendar/calendar-grid.tsx +357 -0
  147. package/components/calendar/calendar-hooks.test.tsx +27 -0
  148. package/components/calendar/calendar-hooks.ts +351 -0
  149. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  150. package/components/calendar/calendar-toolbar.tsx +291 -0
  151. package/components/calendar/calendar-types.ts +148 -0
  152. package/components/calendar/calendar-view.test.tsx +295 -0
  153. package/components/calendar/calendar-view.tsx +307 -0
  154. package/components/calendar/day-detail-popover.tsx +174 -0
  155. package/components/calendar/task-chip.tsx +86 -0
  156. package/components/command/command-palette.test.tsx +33 -0
  157. package/components/command/command-palette.tsx +310 -0
  158. package/components/download-cta.tsx +87 -0
  159. package/components/feedback/feedback-popup.tsx +207 -0
  160. package/components/graph/graph-draw.ts +337 -0
  161. package/components/graph/graph-overlays.tsx +160 -0
  162. package/components/graph/graph-page.test.tsx +131 -0
  163. package/components/graph/graph-page.tsx +263 -0
  164. package/components/graph/graph-types.ts +47 -0
  165. package/components/graph/graph-view.tsx +322 -0
  166. package/components/guide/guide-view.tsx +522 -0
  167. package/components/kanban/kanban-board.test.tsx +128 -0
  168. package/components/kanban/kanban-board.tsx +361 -0
  169. package/components/kanban/kanban-card-menu.tsx +102 -0
  170. package/components/kanban/kanban-card.tsx +227 -0
  171. package/components/kanban/kanban-column.tsx +49 -0
  172. package/components/kanban/kanban-status-context.tsx +28 -0
  173. package/components/landing/calendar-sandbox.test.tsx +15 -0
  174. package/components/landing/calendar-sandbox.tsx +107 -0
  175. package/components/landing/graph-sandbox.test.tsx +27 -0
  176. package/components/landing/graph-sandbox.tsx +80 -0
  177. package/components/landing/kanban-sandbox.test.tsx +24 -0
  178. package/components/landing/kanban-sandbox.tsx +101 -0
  179. package/components/landing/landing-showcase.test.tsx +21 -0
  180. package/components/landing/landing-showcase.tsx +54 -0
  181. package/components/landing/list-sandbox.tsx +86 -0
  182. package/components/landing/mock-workspace.ts +168 -0
  183. package/components/landing/notes-sandbox.test.tsx +14 -0
  184. package/components/landing/notes-sandbox.tsx +88 -0
  185. package/components/layout/app-shell.tsx +83 -0
  186. package/components/layout/backup-scheduler.tsx +122 -0
  187. package/components/layout/bottom-nav.tsx +43 -0
  188. package/components/layout/icon-bar.test.tsx +29 -0
  189. package/components/layout/icon-bar.tsx +118 -0
  190. package/components/layout/mobile-top-bar.tsx +68 -0
  191. package/components/layout/notes-panel-folder.tsx +127 -0
  192. package/components/layout/notes-panel-note-item.tsx +140 -0
  193. package/components/layout/notes-panel-task-tab.tsx +63 -0
  194. package/components/layout/notes-panel-types.ts +44 -0
  195. package/components/layout/notes-panel.tsx +476 -0
  196. package/components/layout/notification-bell.tsx +251 -0
  197. package/components/layout/paywall-screen.tsx +41 -0
  198. package/components/layout/pro-banner.tsx +76 -0
  199. package/components/layout/sw-register.tsx +27 -0
  200. package/components/layout/workspace-switcher.tsx +90 -0
  201. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  202. package/components/notes/note-editor-context-menu.tsx +47 -0
  203. package/components/notes/note-editor-dom.ts +33 -0
  204. package/components/notes/note-editor-dropdowns.tsx +484 -0
  205. package/components/notes/note-editor-hooks.ts +692 -0
  206. package/components/notes/note-editor-keyboard.ts +305 -0
  207. package/components/notes/note-editor-overlay.tsx +90 -0
  208. package/components/notes/note-editor.test.tsx +372 -0
  209. package/components/notes/note-editor.tsx +662 -0
  210. package/components/notes/note-preview-pane.tsx +156 -0
  211. package/components/notes/note-tabs.tsx +120 -0
  212. package/components/notes/note-types.tsx +157 -0
  213. package/components/settings/accept-invite.tsx +108 -0
  214. package/components/settings/agent-token-settings.tsx +369 -0
  215. package/components/settings/backup-restore-settings.test.tsx +25 -0
  216. package/components/settings/backup-restore-settings.tsx +327 -0
  217. package/components/settings/calendar-feeds-settings.tsx +489 -0
  218. package/components/settings/calendar-general-settings.tsx +174 -0
  219. package/components/settings/confirm-danger-action.test.tsx +215 -0
  220. package/components/settings/confirm-danger-action.tsx +65 -0
  221. package/components/settings/security-settings.tsx +252 -0
  222. package/components/settings/settings-guidance.test.tsx +98 -0
  223. package/components/settings/team-settings.tsx +319 -0
  224. package/components/settings/two-factor-auth.tsx +296 -0
  225. package/components/settings/workspace-settings-client.tsx +363 -0
  226. package/components/settings/workspace-settings-form.tsx +73 -0
  227. package/components/sync/conflict-viewer.tsx +247 -0
  228. package/components/sync/sync-indicator.tsx +171 -0
  229. package/components/tasks/snippet-thread.tsx +119 -0
  230. package/components/tasks/status-dot.tsx +47 -0
  231. package/components/tasks/task-badge.tsx +43 -0
  232. package/components/tasks/task-detail.test.tsx +187 -0
  233. package/components/tasks/task-detail.tsx +458 -0
  234. package/components/tasks/task-list-filters.test.tsx +75 -0
  235. package/components/tasks/task-list-filters.tsx +163 -0
  236. package/components/tasks/task-list-types.ts +20 -0
  237. package/components/tasks/task-list.test.tsx +175 -0
  238. package/components/tasks/task-list.tsx +481 -0
  239. package/components/tasks/task-row.tsx +85 -0
  240. package/components/tasks/task-table-row.tsx +259 -0
  241. package/components/ui/skeleton.tsx +3 -0
  242. package/components/ui/toast.test.tsx +42 -0
  243. package/components/ui/toast.tsx +70 -0
  244. package/instrumentation.tsx +23 -0
  245. package/lib/api-error.ts +50 -0
  246. package/lib/backup/backup-runner.test.ts +32 -0
  247. package/lib/backup/backup-runner.ts +19 -0
  248. package/lib/backup/backup-schedule.test.ts +23 -0
  249. package/lib/backup/backup-schedule.ts +55 -0
  250. package/lib/backup/backup-settings.test.ts +30 -0
  251. package/lib/backup/backup-settings.ts +27 -0
  252. package/lib/backup/export-notes-zip.test.ts +26 -0
  253. package/lib/backup/export-notes-zip.ts +82 -0
  254. package/lib/backup/export-workspace-backup.test.ts +17 -0
  255. package/lib/backup/export-workspace-backup.ts +77 -0
  256. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  257. package/lib/backup/restore-workspace-from-export.ts +183 -0
  258. package/lib/backup/types.ts +14 -0
  259. package/lib/brand-icons.ts +1 -0
  260. package/lib/calendar-feed-crypto.ts +38 -0
  261. package/lib/calendar-feed.ts +239 -0
  262. package/lib/client/online-status.ts +47 -0
  263. package/lib/conflict-resolver.test.ts +57 -0
  264. package/lib/conflict-resolver.ts +240 -0
  265. package/lib/db-init.ts +79 -0
  266. package/lib/email.ts +159 -0
  267. package/lib/encryption.test.ts +41 -0
  268. package/lib/encryption.ts +98 -0
  269. package/lib/extract-snippet.test.ts +123 -0
  270. package/lib/extract-snippet.ts +69 -0
  271. package/lib/kanban-status.ts +55 -0
  272. package/lib/license.ts +21 -0
  273. package/lib/limits.ts +31 -0
  274. package/lib/mcp-auth.test.ts +58 -0
  275. package/lib/mcp-auth.ts +65 -0
  276. package/lib/mcp-contract.test.ts +25 -0
  277. package/lib/mcp-contract.ts +210 -0
  278. package/lib/mcp-handler.ts +31 -0
  279. package/lib/mcp-url.test.ts +12 -0
  280. package/lib/mcp-url.ts +7 -0
  281. package/lib/mentions.test.ts +45 -0
  282. package/lib/mentions.ts +73 -0
  283. package/lib/note-crypto.ts +108 -0
  284. package/lib/note-sync.ts +201 -0
  285. package/lib/note-title.ts +93 -0
  286. package/lib/prisma.ts +193 -0
  287. package/lib/pro-flush.ts +292 -0
  288. package/lib/rate-limit.ts +57 -0
  289. package/lib/stripe.ts +38 -0
  290. package/lib/sync-worker.ts +388 -0
  291. package/lib/task-parser.test.ts +91 -0
  292. package/lib/task-parser.ts +81 -0
  293. package/lib/task-utils.ts +52 -0
  294. package/lib/use-is-electron.ts +19 -0
  295. package/lib/use-is-mobile.ts +22 -0
  296. package/lib/validation/calendar-feed.ts +31 -0
  297. package/lib/validation/note.ts +27 -0
  298. package/lib/validation/task.ts +26 -0
  299. package/lib/view-preferences.test.ts +54 -0
  300. package/lib/view-preferences.ts +28 -0
  301. package/lib/workspace.ts +66 -0
  302. package/next.config.ts +21 -0
  303. package/package.json +49 -3
  304. package/postcss.config.mjs +7 -0
  305. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  306. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  307. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  308. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  309. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  310. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  311. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  312. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  313. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  314. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  315. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  316. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  317. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  318. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  319. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  320. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  321. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  322. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  323. package/prisma/migrations/migration_lock.toml +3 -0
  324. package/prisma/schema.prisma +457 -0
  325. package/public/Logo_icon.svg +1 -0
  326. package/public/file.svg +1 -0
  327. package/public/globe.svg +1 -0
  328. package/public/icon-192.png +0 -0
  329. package/public/icon-512.png +0 -0
  330. package/public/icon.svg +4 -0
  331. package/public/icon_dark.svg +1 -0
  332. package/public/knotpad_icon.svg +1 -0
  333. package/public/knotpad_logo_full.svg +1 -0
  334. package/public/manifest.json +14 -0
  335. package/public/next.svg +1 -0
  336. package/public/sw.js +137 -0
  337. package/public/vercel.svg +1 -0
  338. package/public/window.svg +1 -0
  339. package/tsconfig.json +35 -0
  340. package/brief.js +0 -311
@@ -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
+ }