vertex-notes 0.3.1 → 0.3.4

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 (69) hide show
  1. package/README.md +130 -130
  2. package/bin/run.js +3 -3
  3. package/dist/{chunk-JGE6NM2D.js → chunk-23CKP3YP.js} +1 -2
  4. package/dist/{chunk-PBF5EE4Y.js → chunk-DRIWYEQE.js} +0 -1
  5. package/dist/{chunk-HJR4UBO3.js → chunk-QQO4JMNC.js} +2 -3
  6. package/dist/{chunk-QEOOUDO3.js → chunk-XJVEK2OB.js} +552 -47
  7. package/dist/commands/capture.js +3 -4
  8. package/dist/commands/daily.js +3 -4
  9. package/dist/commands/delete.js +3 -4
  10. package/dist/commands/edit.js +3 -8
  11. package/dist/commands/export.js +3 -4
  12. package/dist/commands/hello.js +1 -2
  13. package/dist/commands/help.js +1 -2
  14. package/dist/commands/howto.js +0 -1
  15. package/dist/commands/import.js +3 -4
  16. package/dist/commands/interactive.js +45 -4
  17. package/dist/commands/login.js +2 -3
  18. package/dist/commands/logout.js +1 -2
  19. package/dist/commands/new.js +3 -4
  20. package/dist/commands/notes.js +3 -4
  21. package/dist/commands/restore.js +3 -4
  22. package/dist/commands/search.js +3 -4
  23. package/dist/commands/status.js +3 -4
  24. package/dist/commands/syntax.js +0 -1
  25. package/dist/commands/tags.js +3 -4
  26. package/dist/commands/today.js +3 -4
  27. package/dist/commands/trash/empty.js +4 -5
  28. package/dist/commands/trash/index.js +3 -4
  29. package/dist/commands/view.js +3 -4
  30. package/dist/commands/whoami.js +1 -2
  31. package/dist/index.js +0 -1
  32. package/dist/lib/client.js +3 -4
  33. package/dist/lib/config.js +1 -2
  34. package/dist/lib/file-cleanup.js +2 -3
  35. package/package.json +56 -54
  36. package/dist/chunk-HJR4UBO3.js.map +0 -1
  37. package/dist/chunk-JGE6NM2D.js.map +0 -1
  38. package/dist/chunk-PBF5EE4Y.js.map +0 -1
  39. package/dist/chunk-QEOOUDO3.js.map +0 -1
  40. package/dist/commands/capture.js.map +0 -1
  41. package/dist/commands/daily.js.map +0 -1
  42. package/dist/commands/delete.js.map +0 -1
  43. package/dist/commands/edit.js.map +0 -1
  44. package/dist/commands/export.js.map +0 -1
  45. package/dist/commands/hello.js.map +0 -1
  46. package/dist/commands/help.js.map +0 -1
  47. package/dist/commands/howto.js.map +0 -1
  48. package/dist/commands/import.js.map +0 -1
  49. package/dist/commands/interactive.js.map +0 -1
  50. package/dist/commands/login.js.map +0 -1
  51. package/dist/commands/logout.js.map +0 -1
  52. package/dist/commands/new.js.map +0 -1
  53. package/dist/commands/notes.js.map +0 -1
  54. package/dist/commands/restore.js.map +0 -1
  55. package/dist/commands/search.js.map +0 -1
  56. package/dist/commands/status.js.map +0 -1
  57. package/dist/commands/syntax.js.map +0 -1
  58. package/dist/commands/tags.js.map +0 -1
  59. package/dist/commands/today.js.map +0 -1
  60. package/dist/commands/trash/empty.js.map +0 -1
  61. package/dist/commands/trash/index.js.map +0 -1
  62. package/dist/commands/view.js.map +0 -1
  63. package/dist/commands/whoami.js.map +0 -1
  64. package/dist/index.js.map +0 -1
  65. package/dist/lib/client.js.map +0 -1
  66. package/dist/lib/config.js.map +0 -1
  67. package/dist/lib/file-cleanup.js.map +0 -1
  68. package/dist/lib/md-to-tiptap.js +0 -250
  69. package/dist/lib/md-to-tiptap.js.map +0 -1
@@ -1,10 +1,14 @@
1
1
  // ../core/dist/index.js
2
2
  import { createClient } from "@supabase/supabase-js";
3
+ import { WebSocket } from "ws";
3
4
  function createSupabaseClient(url, anonKey) {
4
5
  return createClient(url, anonKey, {
5
6
  auth: {
6
7
  autoRefreshToken: true,
7
8
  persistSession: true
9
+ },
10
+ realtime: {
11
+ transport: WebSocket
8
12
  }
9
13
  });
10
14
  }
@@ -78,6 +82,17 @@ async function hardDeleteNote(client, noteId, onFilesRemoved) {
78
82
  if (src && (b.type === "file" || b.type === "image") && !src.startsWith("data:")) {
79
83
  fileUrls.push({ src, filesize: attrs.filesize ?? 0 });
80
84
  }
85
+ if (tiptap?.type === "excalidrawBlock" && attrs.data) {
86
+ try {
87
+ const excalidrawData = JSON.parse(attrs.data);
88
+ for (const file of Object.values(excalidrawData.files ?? {})) {
89
+ if (file.dataURL?.startsWith("https://")) {
90
+ fileUrls.push({ src: file.dataURL, filesize: 0 });
91
+ }
92
+ }
93
+ } catch {
94
+ }
95
+ }
81
96
  }
82
97
  for (const file of fileUrls) {
83
98
  try {
@@ -187,11 +202,28 @@ async function getUserTags(client, userId) {
187
202
  if (error) throw error;
188
203
  return data ?? [];
189
204
  }
205
+ async function deleteTag(client, tagId) {
206
+ const { error } = await client.from("tags").delete().eq("id", tagId);
207
+ if (error) throw error;
208
+ }
190
209
  async function addTagEdge(client, userId, parentId, childId) {
191
210
  const { data, error } = await client.from("tag_edges").insert({ parent_id: parentId, child_id: childId, user_id: userId }).select().single();
192
211
  if (error) throw error;
193
212
  return data;
194
213
  }
214
+ async function removeTagEdge(client, parentId, childId) {
215
+ const { error } = await client.from("tag_edges").delete().eq("parent_id", parentId).eq("child_id", childId);
216
+ if (error) throw error;
217
+ }
218
+ async function getTagChildren(client, tagId) {
219
+ const { data, error } = await client.from("tag_edges").select("child_id").eq("parent_id", tagId);
220
+ if (error) throw error;
221
+ if (!data || data.length === 0) return [];
222
+ const childIds = data.map((e) => e.child_id);
223
+ const { data: tags, error: tagErr } = await client.from("tags").select("*").in("id", childIds);
224
+ if (tagErr) throw tagErr;
225
+ return tags ?? [];
226
+ }
195
227
  async function getTagDescendants(client, tagId) {
196
228
  const { data, error } = await client.rpc("get_tag_descendants", {
197
229
  root_tag_id: tagId
@@ -241,6 +273,29 @@ async function getBlocksForNote(client, noteId) {
241
273
  return data ?? [];
242
274
  }
243
275
  var saveLocks = /* @__PURE__ */ new Map();
276
+ var tagPathCache = /* @__PURE__ */ new Map();
277
+ var TAG_PATH_REGEX = /#([a-zA-Z][a-zA-Z0-9_-]*(?:\/[a-zA-Z][a-zA-Z0-9_-]*)*)/g;
278
+ function extractAllTagPaths(blockRows) {
279
+ const paths = /* @__PURE__ */ new Set();
280
+ for (const block of blockRows) {
281
+ let m;
282
+ while ((m = TAG_PATH_REGEX.exec(block.content)) !== null) {
283
+ paths.add(m[1].toLowerCase());
284
+ }
285
+ }
286
+ return paths;
287
+ }
288
+ function setsAreEqual(a, b) {
289
+ if (a.size !== b.size) return false;
290
+ for (const item of a) {
291
+ if (!b.has(item)) return false;
292
+ }
293
+ return true;
294
+ }
295
+ async function getNoteTagPathsFromDb(client, noteId) {
296
+ const { data } = await client.from("note_tags").select("tag_path").eq("note_id", noteId);
297
+ return new Set((data ?? []).map((r) => r.tag_path));
298
+ }
244
299
  async function saveNoteContent(client, noteId, userId, tiptapJson) {
245
300
  const existing = saveLocks.get(noteId);
246
301
  const doSave = async () => {
@@ -268,35 +323,73 @@ async function _saveNoteContentInner(client, noteId, userId, tiptapJson) {
268
323
  metadata: { tiptap: node }
269
324
  }));
270
325
  if (blocksToInsert.length === 0) return;
271
- const { error } = await client.from("blocks").delete().eq("note_id", noteId);
272
- if (error) throw error;
326
+ const { data: existingBlocks } = await client.from("blocks").select("id").eq("note_id", noteId);
327
+ const oldBlockIds = (existingBlocks ?? []).map((b) => b.id);
273
328
  const { error: insertError } = await client.from("blocks").insert(blocksToInsert);
274
329
  if (insertError) throw insertError;
330
+ if (oldBlockIds.length > 0) {
331
+ const { error: deleteError } = await client.from("blocks").delete().in("id", oldBlockIds);
332
+ if (deleteError) throw deleteError;
333
+ }
275
334
  const { data: insertedBlocks } = await client.from("blocks").select("id, content, type").eq("note_id", noteId).order("position");
276
335
  const blockRows = insertedBlocks ?? [];
277
- await client.from("block_tags").delete().in("block_id", blockRows.map((b) => b.id));
278
- await client.from("note_tags").delete().eq("note_id", noteId);
279
- const noteTagIds = /* @__PURE__ */ new Set();
280
- const SKIP_TAG_TYPES = /* @__PURE__ */ new Set(["code", "mermaid", "math", "image", "divider", "chart", "file"]);
281
- for (const block of blockRows) {
282
- if (SKIP_TAG_TYPES.has(block.type)) continue;
283
- const leafTags = extractLeafTags(block.content);
284
- for (const tagName of leafTags) {
285
- const tag = await getOrCreateTag(client, userId, tagName);
286
- await addBlockTag(client, block.id, tag.id).catch(() => {
287
- });
288
- noteTagIds.add(tag.id);
336
+ const currentPaths = extractAllTagPaths(blockRows);
337
+ const prevPaths = tagPathCache.get(noteId);
338
+ if (prevPaths && setsAreEqual(currentPaths, prevPaths)) {
339
+ } else {
340
+ let oldPathsForCleanup = prevPaths;
341
+ if (!oldPathsForCleanup) {
342
+ oldPathsForCleanup = await getNoteTagPathsFromDb(client, noteId);
289
343
  }
290
- const scopedTags = extractScopedTags(block.content);
291
- for (const { parent, child } of scopedTags) {
292
- const parentTag = await getOrCreateTag(client, userId, parent);
293
- const childTag = await getOrCreateTag(client, userId, child);
294
- await addTagEdge(client, userId, parentTag.id, childTag.id).catch(() => {
295
- });
344
+ await client.from("block_tags").delete().in("block_id", blockRows.map((b) => b.id));
345
+ await client.from("note_tags").delete().eq("note_id", noteId);
346
+ const SKIP_TAG_TYPES = /* @__PURE__ */ new Set(["code", "mermaid", "math", "image", "divider", "chart", "file", "summary"]);
347
+ const insertedNoteTags = [];
348
+ for (const block of blockRows) {
349
+ if (SKIP_TAG_TYPES.has(block.type)) continue;
350
+ const tagPaths = extractTagPaths(block.content);
351
+ for (const tagPath of tagPaths) {
352
+ const segments = tagPath.split("/");
353
+ const leafName = segments[segments.length - 1];
354
+ const tag = await getOrCreateTag(client, userId, leafName);
355
+ await addBlockTag(client, block.id, tag.id).catch(() => {
356
+ });
357
+ insertedNoteTags.push({ note_id: noteId, tag_id: tag.id, tag_path: tagPath });
358
+ }
359
+ const scopedTags = extractScopedTags(block.content);
360
+ for (const { parent, child } of scopedTags) {
361
+ const parentTag = await getOrCreateTag(client, userId, parent);
362
+ const childTag = await getOrCreateTag(client, userId, child);
363
+ await addTagEdge(client, userId, parentTag.id, childTag.id).catch(() => {
364
+ });
365
+ }
366
+ }
367
+ const seen = /* @__PURE__ */ new Set();
368
+ for (const nt of insertedNoteTags) {
369
+ if (seen.has(nt.tag_path)) continue;
370
+ seen.add(nt.tag_path);
371
+ await client.from("note_tags").upsert({ note_id: nt.note_id, tag_id: nt.tag_id, tag_path: nt.tag_path }, { onConflict: "note_id,tag_path" });
372
+ }
373
+ tagPathCache.set(noteId, currentPaths);
374
+ if (oldPathsForCleanup) {
375
+ const oldEdges = extractEdgesFromPaths(oldPathsForCleanup);
376
+ const newEdges = extractEdgesFromPaths(currentPaths);
377
+ const edgesToRemove = oldEdges.filter(
378
+ (oe) => !newEdges.some((ne) => ne.parent === oe.parent && ne.child === oe.child)
379
+ );
380
+ for (const { parent: parentName, child: childName } of edgesToRemove) {
381
+ const parentTag = await getOrCreateTag(client, userId, parentName);
382
+ const childTag = await getOrCreateTag(client, userId, childName);
383
+ await removeTagEdge(client, parentTag.id, childTag.id).catch(() => {
384
+ });
385
+ const { count: parentNoteTagCount } = await client.from("note_tags").select("*", { count: "exact", head: true }).eq("tag_id", parentTag.id);
386
+ const children = await getTagChildren(client, parentTag.id);
387
+ if (parentNoteTagCount === 0 && children.length === 0) {
388
+ await deleteTag(client, parentTag.id).catch(() => {
389
+ });
390
+ }
391
+ }
296
392
  }
297
- }
298
- for (const tagId of noteTagIds) {
299
- await client.from("note_tags").upsert({ note_id: noteId, tag_id: tagId }, { onConflict: "note_id,tag_id" });
300
393
  }
301
394
  const fullText = blockRows.map((b) => b.content).join(" ");
302
395
  const wikiLinkTitles = extractWikiLinks(fullText);
@@ -319,10 +412,15 @@ function mapTiptapTypeToBlockType(tiptapType) {
319
412
  mathBlock: "math",
320
413
  calloutBlock: "callout",
321
414
  queryBlock: "query",
415
+ summaryBlock: "summary",
322
416
  horizontalRule: "divider",
323
417
  fileBlock: "file",
324
418
  linkPreview: "text",
325
- inlineMath: "text"
419
+ inlineMath: "text",
420
+ table: "text",
421
+ tableRow: "text",
422
+ tableCell: "text",
423
+ tableHeader: "text"
326
424
  };
327
425
  return MAP[tiptapType] ?? "text";
328
426
  }
@@ -332,21 +430,26 @@ function extractPlainText(node) {
332
430
  if (!content) return "";
333
431
  return content.map(extractPlainText).join("");
334
432
  }
335
- function extractLeafTags(text) {
433
+ function extractTagPaths(text) {
336
434
  const matches = text.match(/#([a-zA-Z][a-zA-Z0-9_-]*(?:\/[a-zA-Z][a-zA-Z0-9_-]*)*)/g);
337
435
  if (!matches) return [];
338
436
  const tags = /* @__PURE__ */ new Set();
339
437
  for (const m of matches) {
340
- const full = m.slice(1).toLowerCase();
341
- if (full.includes("/")) {
342
- const parts = full.split("/");
343
- tags.add(parts[parts.length - 1]);
344
- } else {
345
- tags.add(full);
346
- }
438
+ tags.add(m.slice(1).toLowerCase());
347
439
  }
348
440
  return [...tags];
349
441
  }
442
+ function extractEdgesFromPaths(paths) {
443
+ const edges = [];
444
+ for (const path of paths) {
445
+ if (!path.includes("/")) continue;
446
+ const segments = path.split("/");
447
+ for (let i = 0; i < segments.length - 1; i++) {
448
+ edges.push({ parent: segments[i], child: segments[i + 1] });
449
+ }
450
+ }
451
+ return edges;
452
+ }
350
453
  function extractScopedTags(text) {
351
454
  const matches = text.match(/#([a-zA-Z][a-zA-Z0-9_-]*(?:\/[a-zA-Z][a-zA-Z0-9_-]*)+)/g);
352
455
  if (!matches) return [];
@@ -365,7 +468,7 @@ function extractWikiLinks(text) {
365
468
  return [...new Set(matches.map((m) => m.slice(2, -2)))];
366
469
  }
367
470
  async function fullTextSearch(client, userId, query, limit = 30) {
368
- const { data, error } = await client.from("blocks").select("*, notes!inner(id, title)").eq("user_id", userId).is("deleted_at", null).textSearch("content", query, { type: "websearch" }).limit(limit);
471
+ const { data, error } = await client.from("blocks").select("*, notes!inner(id, title)").eq("user_id", userId).is("deleted_at", null).ilike("content", `%${query}%`).limit(limit);
369
472
  if (error) throw error;
370
473
  return (data ?? []).map((row) => {
371
474
  const notes = row.notes;
@@ -378,15 +481,35 @@ async function fullTextSearch(client, userId, query, limit = 30) {
378
481
  });
379
482
  }
380
483
  async function searchByTag(client, userId, tagName, includeDescendants = true) {
381
- const { data: tags } = await client.from("tags").select("id").eq("user_id", userId).eq("name", tagName.toLowerCase()).single();
382
- if (!tags) return [];
383
- const tagId = tags.id;
384
- let tagIds;
385
- if (includeDescendants) {
386
- tagIds = await getTagDescendants(client, tagId);
387
- } else {
388
- tagIds = [tagId];
484
+ if (tagName.includes("/")) {
485
+ const matchPattern = includeDescendants ? `${tagName}%` : tagName;
486
+ const { data: noteTagRows } = await client.from("note_tags").select("note_id").ilike("tag_path", matchPattern);
487
+ if (!noteTagRows || noteTagRows.length === 0) return [];
488
+ const noteIds = [...new Set(noteTagRows.map((r) => r.note_id))];
489
+ const { data: blocks } = await client.from("blocks").select("*, notes!inner(id, title)").in("note_id", noteIds).eq("user_id", userId).is("deleted_at", null);
490
+ return (blocks ?? []).map((row) => {
491
+ const notes = row.notes;
492
+ return {
493
+ block: row,
494
+ noteId: notes.id,
495
+ noteTitle: notes.title,
496
+ matchType: "tag"
497
+ };
498
+ });
499
+ }
500
+ const { data: matchedTags } = await client.from("tags").select("id").eq("user_id", userId).ilike("name", `${tagName}%`);
501
+ if (!matchedTags || matchedTags.length === 0) return [];
502
+ const allTagIds = /* @__PURE__ */ new Set();
503
+ for (const tag of matchedTags) {
504
+ if (includeDescendants) {
505
+ const descendants = await getTagDescendants(client, tag.id);
506
+ for (const id of descendants) allTagIds.add(id);
507
+ } else {
508
+ allTagIds.add(tag.id);
509
+ }
389
510
  }
511
+ const tagIds = [...allTagIds];
512
+ if (tagIds.length === 0) return [];
390
513
  const { data, error } = await client.from("block_tags").select("block_id, blocks!inner(*, notes!inner(id, title))").in("tag_id", tagIds);
391
514
  if (error) throw error;
392
515
  return (data ?? []).map((row) => {
@@ -419,8 +542,7 @@ function parseQuery(input) {
419
542
  const textParts = [];
420
543
  for (const part of parts) {
421
544
  if (part.startsWith("#")) {
422
- const raw = part.slice(1).toLowerCase();
423
- filter.tag = raw.includes("/") ? raw.split("/").pop() : raw;
545
+ filter.tag = part.slice(1).toLowerCase();
424
546
  } else if (part.startsWith("type:")) {
425
547
  filter.type = part.slice(5);
426
548
  } else if (part.startsWith("status:")) {
@@ -439,7 +561,7 @@ function parseQuery(input) {
439
561
  async function executeQuery(client, userId, input, limit = 30) {
440
562
  const filter = parseQuery(input);
441
563
  let results = [];
442
- if (filter.tag) {
564
+ if (filter.tag !== void 0) {
443
565
  results = await searchByTag(client, userId, filter.tag);
444
566
  } else if (filter.type) {
445
567
  results = await searchByType(client, userId, filter.type, limit);
@@ -458,6 +580,344 @@ async function executeQuery(client, userId, input, limit = 30) {
458
580
  }
459
581
  return results;
460
582
  }
583
+ var HELP_SECTIONS = {
584
+ "shortcuts": `# Keyboard Shortcuts
585
+
586
+ ## Global
587
+ - Cmd+B \u2014 Toggle sidebar
588
+ - Cmd+K \u2014 Command palette
589
+ - Cmd+P \u2014 Global search
590
+ - Cmd+G \u2014 Graph view
591
+ - Cmd+O \u2014 New note
592
+ - Cmd+S \u2014 Save snapshot (version checkpoint)
593
+ - Cmd+Shift+D \u2014 Open/create today's daily note
594
+ - F2 \u2014 Rename current note
595
+ - Cmd+Backspace \u2014 Delete current note (to Trash)
596
+ - Cmd+W \u2014 Close current tab
597
+ - Alt+Right \u2014 Next tab
598
+ - Alt+Left \u2014 Previous tab
599
+ - Cmd+\\ \u2014 Toggle focus mode
600
+ - Alt+Space \u2014 Quick capture to Inbox
601
+ - Cmd+/ \u2014 Open help
602
+ - Cmd+Shift+A \u2014 Toggle AI Chat panel
603
+ - Alt+Up \u2014 Move block up
604
+ - Alt+Down \u2014 Move block down
605
+ - Cmd+Z \u2014 Undo
606
+ - Cmd+Shift+Z \u2014 Redo
607
+
608
+ ## Multi-Pane
609
+ - Cmd+D \u2014 Split right (side by side)
610
+ - Cmd+Shift+E \u2014 Split down (top/bottom)
611
+ - Cmd+J \u2014 Close active pane
612
+ - Cmd+] \u2014 Focus next pane
613
+ - Cmd+[ \u2014 Focus previous pane
614
+
615
+ ## Text Formatting
616
+ - Cmd+B \u2014 Bold
617
+ - Cmd+I \u2014 Italic
618
+ - Cmd+E \u2014 Inline code
619
+ - Cmd+Shift+X \u2014 Strikethrough
620
+ - Cmd+Shift+M \u2014 Insert math block
621
+
622
+ ## Block Navigation
623
+ - Cmd+Enter \u2014 Exit code/math/mermaid block \u2192 preview + new paragraph
624
+ - / \u2014 Open slash command menu
625
+ - # \u2014 Tag autocomplete (type to filter)
626
+ - [[ \u2014 Wiki link autocomplete (type to filter)
627
+
628
+ ## Reminders
629
+ - Click clock icon on a todo \u2014 Set reminder
630
+ - Amber clock means a reminder is already set
631
+ - Sidebar \u2192 Reminders section \u2014 View all active reminders`,
632
+ "editor": `# Editor
633
+
634
+ ## Markdown Shortcuts (type at start of line)
635
+ - # \u2014 Heading 1
636
+ - ## \u2014 Heading 2
637
+ - ### \u2014 Heading 3
638
+ - #### \u2014 Heading 4
639
+ - - \u2014 Bullet list
640
+ - 1. \u2014 Numbered list
641
+ - [ ] \u2014 Task/checkbox
642
+ - > \u2014 Blockquote
643
+ - --- \u2014 Horizontal divider
644
+ - \`\`\` \u2014 Code block
645
+
646
+ ## Inline Syntax
647
+ - **text** \u2014 Bold
648
+ - *text* \u2014 Italic
649
+ - \`code\` \u2014 Inline code
650
+ - #tagname \u2014 Tag (renders as purple pill)
651
+ - #parent/child \u2014 Scoped tag (creates hierarchy)
652
+ - [[Note Name]] \u2014 Wiki link to another note
653
+ - $...$ \u2014 Inline math (LaTeX)
654
+
655
+ ## Note Title
656
+ The first H1 heading in a note automatically becomes the note title. You can also rename via F2, double-click the title in the status bar, or Cmd+K \u2192 Rename Note.`,
657
+ "blocks": `# Block Types
658
+
659
+ ## Slash Menu (type / at start of a line)
660
+
661
+ Available blocks:
662
+ - Text \u2014 Plain paragraph
663
+ - Heading 1\u20133 \u2014 Section headings
664
+ - Bullet List \u2014 Unordered list
665
+ - Numbered List \u2014 Ordered list
666
+ - Todo \u2014 Checkbox task item (supports nesting, has a clock icon for reminders)
667
+ - Code Block \u2014 Syntax-highlighted code with language selector and copy button
668
+ - Quote \u2014 Blockquote
669
+ - Divider \u2014 Horizontal rule
670
+ - Math \u2014 LaTeX equation. Edit the source, then press Cmd+Enter to preview the rendered formula (uses KaTeX).
671
+ - Callout \u2014 Info/Warning/Error/Tip box with a dropdown to switch type. Has colored backgrounds per variant.
672
+ - Mermaid \u2014 Diagram (flowchart, sequence, class, state, Gantt, pie, etc.). Edit the source, then press Cmd+Enter to preview.
673
+ - Table \u2014 Insert a 3x3 table with header row. Use Tab to navigate cells.
674
+ - Image \u2014 Upload, paste, or drag an image. Stored in Supabase Storage. Supports common formats.
675
+ - Excalidraw \u2014 Embedded whiteboard canvas. Opens a full drawing interface inline.
676
+ - File \u2014 PDF or audio file. Upload and view inline. Stored in R2 (Cloudflare).
677
+ - Summary \u2014 Collapsible preview block. Shows a snippet with a "Show more" toggle.
678
+ - Query \u2014 Live search block. Type a query and click Run to see matching results inline within the note.
679
+
680
+ ## Query Block Syntax
681
+ - type:todo \u2014 All todo blocks
682
+ - type:todo status:open \u2014 Open todos only
683
+ - type:todo status:done \u2014 Completed todos
684
+ - type:code \u2014 All code blocks
685
+ - #tagname \u2014 Blocks tagged with that tag
686
+ - keyword \u2014 Full-text search
687
+
688
+ ## Exiting Blocks
689
+ Press Cmd+Enter inside a code block, math block, or mermaid block to exit it, switch to preview mode, and create a new paragraph below.`,
690
+ "search": `# Search
691
+
692
+ ## Global Search (Cmd+P)
693
+ Search across all your notes and blocks. Results show the block content with a link to the source note. Click a result to navigate.
694
+
695
+ ### Filter Syntax
696
+ - keyword \u2014 Full-text search across all blocks
697
+ - #tagname \u2014 Find all blocks tagged with that tag (includes child tags via DAG)
698
+ - #parent/child \u2014 Searches by the leaf tag (e.g. #dsa/dp/tabular searches tabular)
699
+ - type:todo \u2014 All todo/checkbox blocks
700
+ - type:code \u2014 All code blocks
701
+ - type:heading \u2014 All headings
702
+ - status:open \u2014 Unchecked todos
703
+ - status:done \u2014 Checked todos
704
+ - Combine filters: type:todo status:open #work
705
+
706
+ ## Command Palette (Cmd+K)
707
+ Quick access to actions and note switching. Type to fuzzy-search.
708
+
709
+ Available actions:
710
+ - New Note \u2014 Create a blank note
711
+ - Today's Daily Note \u2014 Open or create today's daily note
712
+ - Rename Note \u2014 Rename the current note
713
+ - Delete Note \u2014 Soft-delete to Trash
714
+ - Tag Extend \u2014 Set parent\u2192child relationship between tags
715
+ - Graph View \u2014 Open the visual knowledge graph
716
+ - [note titles] \u2014 Switch to any note by typing its name`,
717
+ "tags": `# Tags
718
+
719
+ Tags are a core organizational feature of Vertex. They work like folders/tags in other tools but are more flexible.
720
+
721
+ ## Basic Usage
722
+ Type #tagname anywhere in your note text. Tags render as purple pills inline.
723
+
724
+ ## Hierarchical / Scoped Tags
725
+ Use #parent/child to create hierarchical tags:
726
+ - #work/projects \u2014 creates a "projects" tag with "work" as its parent
727
+ - #work/projects/alpha \u2014 deeper nesting
728
+ - Multi-parent and multi-level are supported (it's a DAG, not a tree)
729
+
730
+ ## Folder Structure
731
+ Tags create a folder-like hierarchy in the sidebar. Each tag path (e.g. "company/projects") gets its own folder node with correct note counts. This lets you organize notes like a file system \u2014 think of tags as folders:
732
+ - #design/mockups \u2014 all design mockup notes
733
+ - #engineering/frontend \u2014 frontend engineering notes
734
+ - #personal/finances \u2014 personal finance notes
735
+
736
+ ## How Tags Work
737
+ - Tags are stored per-block (not per-note), so different blocks in the same note can have different tags
738
+ - The sidebar shows a "Tags" section listing all tags with color dots and lock toggles
739
+ - Tag autocomplete: type # and start typing to see suggestions from existing tags
740
+ - Tag Extend: Cmd+K \u2192 "Tag Extend" to manually create parent\u2192child edges between existing tags
741
+ - The tag DAG (directed acyclic graph) supports complex hierarchies \u2014 a child can have multiple parents
742
+
743
+ ## Tag Locking
744
+ Lock any tag from the sidebar to prevent AI from reading notes under that tag. Locking walks the entire DAG \u2014 all descendant tags are also locked.
745
+
746
+ ## Tag Colors
747
+ Each tag can have a custom color (shown as a dot in the sidebar).`,
748
+ "wiki-links": `# Wiki Links & Backlinks
749
+
750
+ ## Creating Wiki Links
751
+ Type [[Note Name]] to create a link to another note. An autocomplete dropdown suggests existing notes as you type.
752
+
753
+ ## Navigation
754
+ Click any wiki link to navigate to the linked note.
755
+
756
+ ## Backlinks
757
+ The backlinks panel at the bottom of the editor shows all notes that link to the current note. Each backlink shows the source note title and you can click to navigate.`,
758
+ "version-history": `# Version History (Snapshots)
759
+
760
+ ## Saving Snapshots
761
+ - Manual: Press Cmd+S to save a version checkpoint
762
+ - Auto: Snapshots are automatically created every 5 saves
763
+
764
+ ## Viewing History
765
+ Click "Show version history" below the editor to see all snapshots. Each snapshot shows the timestamp.
766
+
767
+ ## Comparing Versions
768
+ Select any two versions to see a side-by-side diff view (opens as a new tab). Additions and removals are highlighted at the block level.
769
+
770
+ ## Rollback
771
+ Click "Restore" on any snapshot to rollback the note to that version with one click.`,
772
+ "graph-view": `# Graph View
773
+
774
+ Press Cmd+G to open the knowledge graph.
775
+
776
+ ## What You See
777
+ - Gray dots represent notes
778
+ - Purple dots represent tags
779
+ - Lines show wiki link connections (between notes) and tag relationships
780
+
781
+ ## Controls
782
+ - Hover over any dot to enlarge its label
783
+ - Click a note to navigate to it
784
+ - Mouse wheel to zoom
785
+ - Drag to pan
786
+
787
+ The graph uses ForceAtlas2 layout (powered by graphology + sigma.js) for natural-looking node positioning.`,
788
+ "quick-capture": `# Quick Capture
789
+
790
+ Press Alt+Space from anywhere in the app to open a quick capture input dialog.
791
+
792
+ Type anything and press Enter \u2014 the text is saved directly to your Inbox note. You stay on your current note (no navigation). This is useful for quickly jotting down ideas without context switching.`,
793
+ "multi-pane": `# Multi-Pane Layout
794
+
795
+ ## Splitting
796
+ - Cmd+D \u2014 Split the editor side-by-side (horizontal split)
797
+ - Cmd+Shift+E \u2014 Split top/bottom (vertical split)
798
+
799
+ ## Each Pane
800
+ Each pane has its own:
801
+ - Tab bar with open notes
802
+ - Editor with full functionality
803
+ - Status bar
804
+
805
+ ## Controls
806
+ - Drag the purple divider to resize panes
807
+ - Cmd+J \u2014 Close the active pane
808
+ - Cmd+] \u2014 Focus the next pane
809
+ - Cmd+[ \u2014 Focus the previous pane
810
+
811
+ The active pane's editor is shared with the AI Chat panel, so the AI knows which note you're currently editing.`,
812
+ "reminders": `# Reminders & Notifications
813
+
814
+ ## Setting Reminders
815
+ Every todo item (checkbox task) has a clock icon next to it. Click the clock to open the reminder picker. Set:
816
+ - Due date and time
817
+ - Repeat: once, daily, weekly, monthly, yearly, or custom
818
+ - "Custom" lets you pick specific days of the week (Mon, Tue, Wed, etc.) that fire every week, or specific dates of the month (1st\u201331st) that fire every month
819
+ - "Keep notifying until done": re-fires the reminder every N minutes until the task is checked off
820
+
821
+ ## Visual Indicator
822
+ An amber-colored clock icon means a reminder is already set on that todo. Gray means no reminder.
823
+
824
+ ## Delivery
825
+ - Push notifications are delivered via the browser's Web Push API (works on Chrome, Firefox, Edge)
826
+ - A Cloudflare R2 worker checks for due reminders every 5 minutes
827
+ - When a reminder fires, you get a browser notification \u2014 click it to open the note
828
+ - Notification title shows the task text
829
+
830
+ ## Repeating Reminders
831
+ For daily/weekly/monthly/yearly reminders:
832
+ - After firing, the reminder auto-reschedules to the next interval
833
+ - The task is automatically unchecked so you get reminded again
834
+ - For example, a daily reminder at 9 AM will fire every day at 9 AM
835
+
836
+ ## Notify Until Done
837
+ The reminder re-fires every N minutes (configurable delay) until you mark the task as done. Marking it done deletes the reminder and checks the task.
838
+
839
+ ## Viewing Reminders
840
+ - Sidebar \u2192 Reminders section lists all active reminders
841
+ - Each entry shows: task text, note title, and relative due date
842
+ - You can mark a reminder done directly from the sidebar
843
+
844
+ ## Stable Block IDs
845
+ Each task item has a persistent UUID (crypto.randomUUID()) that survives page reloads. This ensures reminders stay matched to their todo even after editing.`,
846
+ "ai-integration": `# AI Integration
847
+
848
+ ## AI Chat Panel
849
+ Press Cmd+Shift+A or click the AI button in the status bar to toggle the AI Chat panel.
850
+
851
+ ### Two Modes
852
+ 1. Ask mode \u2014 Q&A and research. The AI can search your notes, list todos, find tagged blocks, read daily notes, and answer questions based on your data.
853
+ 2. Edit mode \u2014 Structured inline content editing. The AI returns actions (insert, replace, delete, etc.) that appear as green/red suggestions in the editor. Accept or reject each change individually.
854
+
855
+ ### How Ask Mode Works
856
+ The AI has 7 tools it can use to fetch information:
857
+ 1. get_current_note \u2014 Read the currently open note's content
858
+ 2. search_notes \u2014 Full-text search across all notes
859
+ 3. get_note_by_title \u2014 Find a note by its title
860
+ 4. get_note_content \u2014 Get full content of a specific note by its ID
861
+ 5. get_recent_daily_notes \u2014 Get recent daily journal entries
862
+ 6. list_all_notes \u2014 List all note titles and dates
863
+ 7. list_todos \u2014 List tasks from notes, scoped to the current note + latest daily note by default
864
+
865
+ ### How Edit Mode Works
866
+ The AI understands the block structure of your note. It can:
867
+ - Insert new blocks after/before a specific block
868
+ - Replace a block's content
869
+ - Delete a block
870
+ - Use special syntax: {summary}...{/summary}, {callout info}...{/callout}, {query value="..."}{/query}
871
+
872
+ Changes appear as inline suggestions (green for inserts, red strikethrough for deletions) with accept/reject buttons. The editor is locked while changes are pending.
873
+
874
+ ### Providers & Settings
875
+ Supported AI providers: OpenRouter, Groq, OpenAI, Gemini, Cerebras
876
+ Configure API keys via the status bar gear icon \u2192 AI Settings. Keys are encrypted using AES-256-GCM and stored in the database.
877
+
878
+ ## AI Privacy (Tag Locking)
879
+ Lock any tag from the sidebar to prevent AI from reading notes under that tag. The AI will receive "locked: true" with no content and will stop trying to access that note. Locking walks the full tag DAG \u2014 locking a parent locks all descendants.`,
880
+ "share-links": `# Share Links
881
+
882
+ Click the share button in the status bar to generate a public share link. Anyone with the link can view the note without signing in. The link uses an unguessable random token.
883
+
884
+ You can revoke share links at any time from the share dialog. Shared notes appear in a "Shared Notes" section in the sidebar.`,
885
+ "tag-locking": `# Tag Locking (AI Privacy)
886
+
887
+ ## What It Does
888
+ Locking prevents AI from reading any note that has a locked tag. This is useful for sensitive notes that you don't want AI to access.
889
+
890
+ ## How It Works
891
+ - Note-level: Toggle the lock in the status bar \u2014 locks the current note
892
+ - Tag-level: Lock a tag from the sidebar \u2014 ALL notes under that tag are locked
893
+ - The locking walks the entire tag DAG: if you lock #work, every note tagged with #work/projects, #work/meetings, etc. is also locked
894
+ - Locked notes show a lock icon in the tab bar, sidebar, and status bar
895
+ - The AI Chat panel shows a banner for locked notes
896
+ - When AI tries to access a locked note, it receives "NOTE LOCKED" and is instructed to stop immediately`,
897
+ "mobile": `# Mobile
898
+
899
+ On mobile devices, Vertex provides:
900
+ - A bottom toolbar with quick access to AI chat, undo, redo, quick capture, and search
901
+ - Touch-friendly interface with adjusted hit targets
902
+ - Sidebar slides in as a full-width overlay with backdrop
903
+ - Responsive design adapts to smaller screens`,
904
+ "trash": `# Trash
905
+
906
+ Deleted notes go to Trash (soft delete \u2014 they can be recovered).
907
+
908
+ - Open the sidebar \u2192 Trash section to see trashed notes
909
+ - Hover over a trashed note to reveal:
910
+ - \u21BA Restore \u2014 brings the note back
911
+ - \u2297 Permanently delete \u2014 deletes the note and all associated blocks, tags, links, and snapshots
912
+ - "Empty" button deletes all trashed notes permanently
913
+ - Notes in trash still count toward your total note count`,
914
+ "themes": `# Themes
915
+
916
+ Click the \u2600\uFE0F/\u{1F319} button in the top bar to toggle between dark and light mode. Your preference is saved in localStorage and persists across sessions.
917
+
918
+ The app uses CSS variables for theming (--foreground, --background, --accent, --border, etc.) giving a consistent look across all UI elements.`
919
+ };
920
+ var HELP_TOPICS = Object.keys(HELP_SECTIONS).sort();
461
921
  function extractTextFromNode(node) {
462
922
  if (typeof node.text === "string") return node.text;
463
923
  const content = node.content;
@@ -571,6 +1031,25 @@ function tiptapNodeToMarkdown(node) {
571
1031
  lines.push(`![${alt}](${src})`);
572
1032
  break;
573
1033
  }
1034
+ case "table": {
1035
+ const rows = content.map((row) => row.content ?? []);
1036
+ const colCount = Math.max(...rows.map((r) => r.length));
1037
+ const colWidths = Array.from(
1038
+ { length: colCount },
1039
+ (_, ci) => Math.max(3, ...rows.map((r) => (r[ci] ? extractTextFromNode(r[ci]) : "").length))
1040
+ );
1041
+ rows.forEach((cells, ri) => {
1042
+ const line = "| " + colWidths.map((w, ci) => {
1043
+ const text = cells[ci] ? extractTextFromNode(cells[ci]) : "";
1044
+ return text.padEnd(w);
1045
+ }).join(" | ") + " |";
1046
+ lines.push(line);
1047
+ if (ri === 0) {
1048
+ lines.push("| " + colWidths.map((w) => "-".repeat(w)).join(" | ") + " |");
1049
+ }
1050
+ });
1051
+ break;
1052
+ }
574
1053
  case "linkPreview": {
575
1054
  const url = attrs.url ?? "";
576
1055
  lines.push(`> [!embed]`, `> ${url}`);
@@ -751,6 +1230,33 @@ function markdownToTiptap(md) {
751
1230
  i++;
752
1231
  continue;
753
1232
  }
1233
+ const trimmedLine = line.trimStart();
1234
+ if (trimmedLine.startsWith("|") && lines[i + 1]?.trimStart().match(/^\|[\s\-|:]+\|/)) {
1235
+ const parseRow = (raw) => raw.trimStart().split("|").slice(1, -1).map((c) => c.trim());
1236
+ const headers = parseRow(lines[i]);
1237
+ i += 2;
1238
+ const rows = [];
1239
+ while (i < lines.length && lines[i].trimStart().startsWith("|")) {
1240
+ rows.push(parseRow(lines[i]));
1241
+ i++;
1242
+ }
1243
+ const makeCell = (text, isHeader) => ({
1244
+ type: isHeader ? "tableHeader" : "tableCell",
1245
+ attrs: { colspan: 1, rowspan: 1, colwidth: null },
1246
+ content: [{ type: "paragraph", content: parseInline(text) }]
1247
+ });
1248
+ nodes.push({
1249
+ type: "table",
1250
+ content: [
1251
+ { type: "tableRow", content: headers.map((h) => makeCell(h, true)) },
1252
+ ...rows.map((row) => ({
1253
+ type: "tableRow",
1254
+ content: headers.map((_, ci) => makeCell(row[ci] ?? "", false))
1255
+ }))
1256
+ ]
1257
+ });
1258
+ continue;
1259
+ }
754
1260
  if (line.match(/^!\[.*?\]\(.*?\)$/)) {
755
1261
  const imgMatch = line.match(/^!\[(.*?)\]\((.*?)\)$/);
756
1262
  if (imgMatch) {
@@ -809,7 +1315,7 @@ function markdownToTiptap(md) {
809
1315
  continue;
810
1316
  }
811
1317
  const paraLines = [];
812
- while (i < lines.length && lines[i].trim() !== "" && !lines[i].match(/^#{1,4}\s/) && !lines[i].match(/^```/) && !lines[i].match(/^\$\$/) && !lines[i].match(/^---\s*$/) && !lines[i].match(/^[-*]\s/) && !lines[i].match(/^\d+\.\s/) && !lines[i].match(/^>\s/) && !lines[i].match(/^!\[/)) {
1318
+ while (i < lines.length && lines[i].trim() !== "" && !lines[i].match(/^#{1,4}\s/) && !lines[i].match(/^```/) && !lines[i].match(/^\$\$/) && !lines[i].match(/^---\s*$/) && !lines[i].match(/^[-*]\s/) && !lines[i].match(/^\d+\.\s/) && !lines[i].match(/^>\s/) && !lines[i].match(/^!\[/) && !lines[i].startsWith("|")) {
813
1319
  paraLines.push(lines[i]);
814
1320
  i++;
815
1321
  }
@@ -858,7 +1364,7 @@ function parseInline(text) {
858
1364
  }
859
1365
  return nodes.length > 0 ? nodes : [{ type: "text", text: text || " " }];
860
1366
  }
861
- var VERTEX_VERSION = "0.1.0";
1367
+ var VERTEX_VERSION = "0.3.4";
862
1368
 
863
1369
  export {
864
1370
  createSupabaseClient,
@@ -879,4 +1385,3 @@ export {
879
1385
  markdownToTiptap,
880
1386
  VERTEX_VERSION
881
1387
  };
882
- //# sourceMappingURL=chunk-QEOOUDO3.js.map