@knotpad/app 0.1.0
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/README.md +167 -0
- 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/electron/main.ts +251 -0
- package/electron/preload.ts +56 -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 +99 -0
- 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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { verifyMcpToken, extractBearerToken, McpTokenPayload } from "@/lib/mcp-auth";
|
|
3
|
+
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
|
4
|
+
|
|
5
|
+
export type McpContext = McpTokenPayload & { alias: string | null };
|
|
6
|
+
|
|
7
|
+
// 120 requests per minute per IP — generous for agent polling, tight enough to block abuse
|
|
8
|
+
const MCP_RATE_LIMIT_MAX = 120;
|
|
9
|
+
const MCP_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
export async function withMcpAuth(
|
|
12
|
+
req: NextRequest,
|
|
13
|
+
handler: (ctx: McpContext) => Promise<NextResponse>
|
|
14
|
+
): Promise<NextResponse> {
|
|
15
|
+
const ip = getClientIp(req);
|
|
16
|
+
const rl = rateLimit(`mcp:${ip}`, MCP_RATE_LIMIT_MAX, MCP_RATE_LIMIT_WINDOW_MS);
|
|
17
|
+
if (rl.limited) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: "Too many requests" },
|
|
20
|
+
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const token = extractBearerToken(req.headers.get("authorization"));
|
|
25
|
+
if (!token) return NextResponse.json({ error: "Missing token" }, { status: 401 });
|
|
26
|
+
|
|
27
|
+
const ctx = await verifyMcpToken(token);
|
|
28
|
+
if (!ctx) return NextResponse.json({ error: "Invalid or revoked token" }, { status: 401 });
|
|
29
|
+
|
|
30
|
+
return handler(ctx);
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getMcpUrl } from "@/lib/mcp-url";
|
|
3
|
+
|
|
4
|
+
describe("getMcpUrl", () => {
|
|
5
|
+
it("prefers the explicit MCP URL", () => {
|
|
6
|
+
expect(getMcpUrl("https://app.example.com", "https://mcp.example.com")).toBe("https://mcp.example.com");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("derives the MCP URL from the app URL when no explicit value is set", () => {
|
|
10
|
+
expect(getMcpUrl("http://localhost:3000", undefined)).toBe("http://localhost:3000/mcp");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/lib/mcp-url.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveAssignee, normalizeHandle } from "@/lib/mentions";
|
|
3
|
+
|
|
4
|
+
// Minimal fake tx exposing only what resolveAssignee touches.
|
|
5
|
+
function fakeTx(members: Array<{ userId: string; user: { name: string | null; email: string | null } }>) {
|
|
6
|
+
return { workspaceMember: { findMany: async () => members } } as never;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const members = [
|
|
10
|
+
{ userId: "u1", user: { name: "John Doe", email: "john@acme.com" } },
|
|
11
|
+
{ userId: "u2", user: { name: null, email: "alice@acme.com" } },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("resolveAssignee", () => {
|
|
15
|
+
it("matches a name handle even though the editor strips spaces", async () => {
|
|
16
|
+
// editor builds "@johndoe" from "John Doe"
|
|
17
|
+
const r = await resolveAssignee(fakeTx(members), "ws", "@johndoe");
|
|
18
|
+
expect(r).toEqual({ assigneeId: "u1", assigneeType: "HUMAN" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("matches a bare email local-part", async () => {
|
|
22
|
+
const r = await resolveAssignee(fakeTx(members), "ws", "@alice");
|
|
23
|
+
expect(r.assigneeId).toBe("u2");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not ambiguously prefix-match emails", async () => {
|
|
27
|
+
// "@al" must NOT resolve to alice@ (old startsWith bug)
|
|
28
|
+
const r = await resolveAssignee(fakeTx(members), "ws", "@al");
|
|
29
|
+
expect(r.assigneeId).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats @agent as an agent, not a human", async () => {
|
|
33
|
+
const r = await resolveAssignee(fakeTx(members), "ws", "@agent");
|
|
34
|
+
expect(r).toEqual({ assigneeId: null, assigneeType: "AGENT" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns no assignee for a null handle", async () => {
|
|
38
|
+
const r = await resolveAssignee(fakeTx(members), "ws", null);
|
|
39
|
+
expect(r).toEqual({ assigneeId: null, assigneeType: "HUMAN" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("normalizeHandle strips @, case, and whitespace", () => {
|
|
43
|
+
expect(normalizeHandle("@John Doe")).toBe("johndoe");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/lib/mentions.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention handle resolution — shared by every note→task sync path so the
|
|
3
|
+
* matching rules can't drift between call sites.
|
|
4
|
+
*
|
|
5
|
+
* The editor builds handles from a member's name (preferred) or email:
|
|
6
|
+
* @${(name ?? email).toLowerCase().replace(/\s+/g, "")}
|
|
7
|
+
* so resolution must normalise both sides identically. We also accept a bare
|
|
8
|
+
* email local-part (e.g. "@alice" for alice@acme.com) since people type that.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { prisma } from "@/lib/prisma";
|
|
12
|
+
import { isAgentHandle } from "@/lib/task-parser";
|
|
13
|
+
|
|
14
|
+
type Tx = Parameters<Parameters<typeof prisma.$transaction>[0]>[0];
|
|
15
|
+
|
|
16
|
+
export type ResolvedAssignee = {
|
|
17
|
+
assigneeId: string | null;
|
|
18
|
+
assigneeType: "HUMAN" | "AGENT";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WorkspaceMemberWithUser = {
|
|
22
|
+
userId: string;
|
|
23
|
+
user: { id: string; name: string | null; email: string | null };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Strip a leading "@", lower-case, and remove whitespace. */
|
|
27
|
+
export function normalizeHandle(handle: string): string {
|
|
28
|
+
return handle.replace(/^@/, "").toLowerCase().replace(/\s+/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fetch all workspace members once so callers in a loop can pass the list in
|
|
33
|
+
* and avoid an N+1 per-task query.
|
|
34
|
+
*/
|
|
35
|
+
export async function fetchWorkspaceMembers(
|
|
36
|
+
tx: Tx,
|
|
37
|
+
workspaceId: string
|
|
38
|
+
): Promise<WorkspaceMemberWithUser[]> {
|
|
39
|
+
return tx.workspaceMember.findMany({
|
|
40
|
+
where: { workspaceId },
|
|
41
|
+
include: { user: { select: { id: true, name: true, email: true } } },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve an @mention handle to a workspace user.
|
|
47
|
+
* Pass `prefetchedMembers` (from `fetchWorkspaceMembers`) to avoid a DB query
|
|
48
|
+
* per call when resolving multiple tasks in a loop.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveAssignee(
|
|
51
|
+
tx: Tx,
|
|
52
|
+
workspaceId: string,
|
|
53
|
+
handle: string | null,
|
|
54
|
+
prefetchedMembers?: WorkspaceMemberWithUser[]
|
|
55
|
+
): Promise<ResolvedAssignee> {
|
|
56
|
+
if (!handle) return { assigneeId: null, assigneeType: "HUMAN" };
|
|
57
|
+
if (isAgentHandle(handle)) return { assigneeId: null, assigneeType: "AGENT" };
|
|
58
|
+
|
|
59
|
+
const slug = normalizeHandle(handle);
|
|
60
|
+
const members = prefetchedMembers ?? await tx.workspaceMember.findMany({
|
|
61
|
+
where: { workspaceId },
|
|
62
|
+
include: { user: { select: { id: true, name: true, email: true } } },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const match = members.find((m) => {
|
|
66
|
+
const email = (m.user.email ?? "").toLowerCase().replace(/\s+/g, "");
|
|
67
|
+
const nameSlug = (m.user.name ?? "").toLowerCase().replace(/\s+/g, "");
|
|
68
|
+
const emailLocal = email.split("@")[0];
|
|
69
|
+
return slug === nameSlug || slug === email || slug === emailLocal;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { assigneeId: match?.userId ?? null, assigneeType: "HUMAN" };
|
|
73
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transparent note encryption-at-rest layer — server-side AES-256-GCM.
|
|
3
|
+
*
|
|
4
|
+
* Encryption policy:
|
|
5
|
+
* - Local-only mode (IS_CLOUD=false, no CLOUD_DATABASE_URL):
|
|
6
|
+
* Notes are stored as plaintext in PGlite. The data never leaves the
|
|
7
|
+
* user's device, so encryption is unnecessary and would only create
|
|
8
|
+
* a footgun (lose ENCRYPTION_PEPPER → lose all notes).
|
|
9
|
+
*
|
|
10
|
+
* - Cloud mode (IS_CLOUD=true) or local with cloud sync (CLOUD_DATABASE_URL):
|
|
11
|
+
* Notes are always encrypted before storage. This protects data at rest
|
|
12
|
+
* in the cloud DB (Neon). The server holds the key (ENCRYPTION_PEPPER).
|
|
13
|
+
*
|
|
14
|
+
* Two sets of helpers, kept for call-site clarity:
|
|
15
|
+
* encryptContent / decryptContent → API routes / MCP (primary `prisma`)
|
|
16
|
+
* encryptForSync / decryptFromSync → sync-worker talking to either DB
|
|
17
|
+
* Both behave identically. Decryption is safe to call on plaintext (returns
|
|
18
|
+
* it unchanged) so legacy rows and local-only data don't throw.
|
|
19
|
+
*
|
|
20
|
+
* Only note.content is encrypted. Titles, ids, timestamps, and task data stay
|
|
21
|
+
* plaintext so they remain queryable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { prisma, getCloudPrisma } from "@/lib/prisma";
|
|
25
|
+
import { encryptNote, decryptNote, generateSalt, isEncrypted } from "@/lib/encryption";
|
|
26
|
+
|
|
27
|
+
// ── Cloud check ─────────────────────────────────────────────────────────────
|
|
28
|
+
// Encryption is only needed when data may reach the cloud DB. In local-only
|
|
29
|
+
// mode (no cloud sync), notes stay plaintext on the user's own device.
|
|
30
|
+
function isCloudEnabled(): boolean {
|
|
31
|
+
return process.env.IS_CLOUD === "true" || !!process.env.CLOUD_DATABASE_URL;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Salt cache ────────────────────────────────────────────────────────────────
|
|
35
|
+
// Per-workspace salt persisted in the workspace row. Cached in-process to avoid
|
|
36
|
+
// an extra round-trip on every note read/write.
|
|
37
|
+
|
|
38
|
+
const saltCache = new Map<string, string>();
|
|
39
|
+
|
|
40
|
+
async function getWorkspaceSalt(workspaceId: string): Promise<string> {
|
|
41
|
+
const cached = saltCache.get(workspaceId);
|
|
42
|
+
if (cached) return cached;
|
|
43
|
+
|
|
44
|
+
const workspace = await prisma.workspace.findUnique({
|
|
45
|
+
where: { id: workspaceId },
|
|
46
|
+
select: { encryptionSalt: true },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (workspace?.encryptionSalt) {
|
|
50
|
+
saltCache.set(workspaceId, workspace.encryptionSalt);
|
|
51
|
+
return workspace.encryptionSalt;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// First time for this workspace: generate and persist the salt.
|
|
55
|
+
const salt = generateSalt();
|
|
56
|
+
await prisma.workspace.update({
|
|
57
|
+
where: { id: workspaceId },
|
|
58
|
+
data: { encryptionSalt: salt },
|
|
59
|
+
});
|
|
60
|
+
// Mirror to Neon so the migration step can find the salt alongside the workspace record.
|
|
61
|
+
// Non-fatal: if the cloud write fails, the migration endpoint will copy the workspace row.
|
|
62
|
+
const cloud = getCloudPrisma();
|
|
63
|
+
if (cloud) {
|
|
64
|
+
cloud.workspace.update({ where: { id: workspaceId }, data: { encryptionSalt: salt } }).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
saltCache.set(workspaceId, salt);
|
|
67
|
+
return salt;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getPepper(): string {
|
|
71
|
+
const pepper = process.env.ENCRYPTION_PEPPER;
|
|
72
|
+
if (!pepper) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"[Brief] ENCRYPTION_PEPPER env var is required to encrypt notes at rest. " +
|
|
75
|
+
"Generate one with: openssl rand -base64 32"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return pepper;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Public helpers ──────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export async function encryptContent(
|
|
84
|
+
content: string,
|
|
85
|
+
workspaceId: string
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
if (!isCloudEnabled()) return content; // local-only: store plaintext
|
|
88
|
+
if (isEncrypted(content)) return content; // never double-encrypt
|
|
89
|
+
const salt = await getWorkspaceSalt(workspaceId);
|
|
90
|
+
return encryptNote(content, getPepper(), salt);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function decryptContent(
|
|
94
|
+
content: string,
|
|
95
|
+
workspaceId: string
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
if (!isEncrypted(content)) return content; // plaintext or local-only safety
|
|
98
|
+
const salt = await getWorkspaceSalt(workspaceId);
|
|
99
|
+
return decryptNote(content, getPepper(), salt);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Sync-worker aliases — identical behaviour, named for call-site intent.
|
|
103
|
+
export const encryptForSync = encryptContent;
|
|
104
|
+
export const decryptFromSync = decryptContent;
|
|
105
|
+
|
|
106
|
+
export function invalidateSaltCache(workspaceId: string): void {
|
|
107
|
+
saltCache.delete(workspaceId);
|
|
108
|
+
}
|
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
|
+
}
|