claude-home 1.5.36 → 1.6.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 +18 -0
- package/package.json +1 -1
- package/public/index.html +693 -44
- package/server.js +172 -26
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ A daily task list that lives alongside your Claude work. Add tasks, provide cont
|
|
|
22
22
|
|
|
23
23
|
Claude can add tasks directly from any session — just say "add this to today" or "remind me tomorrow to…"
|
|
24
24
|
|
|
25
|
+
Tasks created from notes show a **↗ Note title** chip that navigates directly to the source note.
|
|
26
|
+
|
|
25
27
|
### Notes
|
|
26
28
|
Your personal notepad — separate from Claude's memory. Notes are for you, not for Claude's context. Capture decisions, TILs, bug solutions, runbooks, snippets, or anything worth keeping.
|
|
27
29
|
|
|
@@ -29,6 +31,22 @@ Claude can save notes directly from any session — just say "save this as a not
|
|
|
29
31
|
|
|
30
32
|
Direct links (`#/note/filename`) let you open a specific note instantly.
|
|
31
33
|
|
|
34
|
+
**Note linking** — write `#slug` inside a note to link it to another, creating a network of connected notes. Backlinks appear automatically at the bottom of each note. An autocomplete dropdown appears as you type `#` in the editor.
|
|
35
|
+
|
|
36
|
+
**Related notes** — notes with similar vocabulary are suggested below backlinks, using cosine similarity on tokenized content.
|
|
37
|
+
|
|
38
|
+
**Scratch pad** — a session-only notepad (⚡) pinned at the top of the sidebar. Nothing is persisted until you hit *Save as note…*
|
|
39
|
+
|
|
40
|
+
**Clipboard capture** — opening *New note* automatically pre-fills the content with whatever is in your clipboard.
|
|
41
|
+
|
|
42
|
+
**Folders** — organize notes in folders (per project or custom). Create folders from the sidebar, move notes between folders via the toolbar dropdown, and rename folders inline with a hover button (✎).
|
|
43
|
+
|
|
44
|
+
**Tags** — add `tags: [bug, idea]` in frontmatter. Filter by multiple tags in the sidebar. Hover any tag chip to rename or delete it globally across all notes.
|
|
45
|
+
|
|
46
|
+
**Pinned notes** — mark important notes with 📌 to keep them always at the top of the list, both in root and inside folders.
|
|
47
|
+
|
|
48
|
+
**Note → Today task** — hover any paragraph or list item in a note to reveal a **+** button that adds it directly to today's task list. A **+ Today** button in the toolbar adds the note title as a task. All tasks created from notes carry a link back to the source note.
|
|
49
|
+
|
|
32
50
|
### Projects
|
|
33
51
|
Visual overview of all your Claude projects with session count, token usage, cost, and memory files. Drill into any project to browse its sessions, memory entries, and `CLAUDE.md` files.
|
|
34
52
|
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -484,6 +484,164 @@
|
|
|
484
484
|
.md-content th { background: var(--canvas); font-weight: 600; }
|
|
485
485
|
.md-content hr { border: none; border-top: 1px solid var(--rule); margin: 10px 0; }
|
|
486
486
|
|
|
487
|
+
/* Note folders */
|
|
488
|
+
.note-folder-row {
|
|
489
|
+
display: flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
gap: 7px;
|
|
492
|
+
padding: 7px 12px;
|
|
493
|
+
cursor: pointer;
|
|
494
|
+
font-size: 12.5px;
|
|
495
|
+
color: var(--ink);
|
|
496
|
+
}
|
|
497
|
+
.note-folder-row:hover { background: var(--canvas); }
|
|
498
|
+
.note-folder-row:hover .note-folder-rename-btn { opacity: 1 !important; }
|
|
499
|
+
.note-folder-icon { font-size: 8px; color: var(--ink-3); }
|
|
500
|
+
.note-folder-name { flex: 1; font-weight: 500; }
|
|
501
|
+
.note-folder-count { font-size: 10px; color: var(--ink-3); background: var(--canvas); border: 1px solid var(--rule); border-radius: 10px; padding: 0 5px; }
|
|
502
|
+
.note-folder-create-row { padding: 4px 10px 6px; }
|
|
503
|
+
.note-folder-add { font-size: 11px; color: var(--ink-3); cursor: pointer; }
|
|
504
|
+
.note-folder-add:hover { color: var(--blue); }
|
|
505
|
+
.note-folder-back {
|
|
506
|
+
display: flex;
|
|
507
|
+
align-items: center;
|
|
508
|
+
padding: 7px 12px;
|
|
509
|
+
font-size: 12px;
|
|
510
|
+
cursor: pointer;
|
|
511
|
+
border-bottom: 1px solid var(--rule);
|
|
512
|
+
}
|
|
513
|
+
.note-folder-back:hover { background: var(--canvas); }
|
|
514
|
+
|
|
515
|
+
/* Note tags */
|
|
516
|
+
.note-tag-filter {
|
|
517
|
+
display: flex;
|
|
518
|
+
flex-wrap: wrap;
|
|
519
|
+
gap: 4px;
|
|
520
|
+
padding: 6px 10px;
|
|
521
|
+
border-bottom: 1px solid var(--rule);
|
|
522
|
+
}
|
|
523
|
+
.note-tag-chip {
|
|
524
|
+
font-size: 10px;
|
|
525
|
+
padding: 2px 7px;
|
|
526
|
+
border-radius: 20px;
|
|
527
|
+
border: 1px solid var(--rule-2);
|
|
528
|
+
background: var(--canvas);
|
|
529
|
+
color: var(--ink-3);
|
|
530
|
+
cursor: pointer;
|
|
531
|
+
user-select: none;
|
|
532
|
+
}
|
|
533
|
+
.note-tag-chip:hover { border-color: var(--ink-3); color: var(--ink); }
|
|
534
|
+
.note-tag-chip.active { background: var(--ink); color: var(--white); border-color: var(--ink); }
|
|
535
|
+
.note-tag-mgmt-btn { font-size: 12px; color: var(--ink-3); cursor: pointer; padding: 0 2px; opacity: 0; transition: opacity .15s; line-height: 1; }
|
|
536
|
+
.note-tag-chip-wrap:hover .note-tag-mgmt-btn { opacity: 1; }
|
|
537
|
+
.note-tag-menu { position: absolute; top: 100%; left: 0; z-index: 50; background: var(--canvas); border: 1px solid var(--rule-2); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,.15); min-width: 160px; padding: 4px 0; margin-top: 2px; }
|
|
538
|
+
.note-tag-menu-item { font-size: 12px; padding: 5px 12px; cursor: pointer; color: var(--ink-2); }
|
|
539
|
+
.note-tag-menu-item:hover { background: var(--canvas-2); color: var(--ink); }
|
|
540
|
+
.note-tag-menu-danger { color: var(--red) !important; }
|
|
541
|
+
.note-tag-menu-danger:hover { background: var(--red-dim, #3a1a1a) !important; }
|
|
542
|
+
.note-row-tags { display: flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
|
|
543
|
+
.note-row-tag {
|
|
544
|
+
font-size: 9px;
|
|
545
|
+
padding: 1px 5px;
|
|
546
|
+
border-radius: 20px;
|
|
547
|
+
background: color-mix(in srgb, var(--blue) 10%, transparent);
|
|
548
|
+
color: var(--blue);
|
|
549
|
+
cursor: pointer;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.md-content p, .md-content li { position: relative; }
|
|
553
|
+
.para-add-task-btn {
|
|
554
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
555
|
+
position: absolute; left: -22px; top: 2px;
|
|
556
|
+
width: 16px; height: 16px; border-radius: 50%;
|
|
557
|
+
background: var(--blue); color: #fff;
|
|
558
|
+
border: none; cursor: pointer; font-size: 12px; line-height: 1;
|
|
559
|
+
opacity: 0; transition: opacity .15s; padding: 0;
|
|
560
|
+
font-weight: 700;
|
|
561
|
+
}
|
|
562
|
+
.md-content p:hover .para-add-task-btn,
|
|
563
|
+
.md-content li:hover .para-add-task-btn { opacity: 1; }
|
|
564
|
+
.note-task-toast {
|
|
565
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
566
|
+
background: var(--ink); color: var(--white);
|
|
567
|
+
padding: 8px 16px; border-radius: 20px; font-size: 12px;
|
|
568
|
+
z-index: 300; pointer-events: none;
|
|
569
|
+
animation: toastIn .2s ease;
|
|
570
|
+
}
|
|
571
|
+
@keyframes toastIn { from { opacity:0; transform: translateX(-50%) translateY(8px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
|
|
572
|
+
.note-ref-chip { display:inline-flex;align-items:center;gap:3px;font-size:10px;color:var(--blue);background:color-mix(in srgb,var(--blue) 10%,transparent);border-radius:4px;padding:1px 5px;cursor:pointer;text-decoration:none; }
|
|
573
|
+
.note-row-tag:hover { background: color-mix(in srgb, var(--blue) 20%, transparent); }
|
|
574
|
+
|
|
575
|
+
/* Scratch row */
|
|
576
|
+
.scratch-row {
|
|
577
|
+
border-bottom: 1px solid var(--rule);
|
|
578
|
+
background: color-mix(in srgb, var(--canvas) 60%, var(--white));
|
|
579
|
+
}
|
|
580
|
+
.scratch-row.active { background: color-mix(in srgb, var(--blue) 6%, var(--white)); }
|
|
581
|
+
|
|
582
|
+
/* Note-to-note links */
|
|
583
|
+
.note-ref-link {
|
|
584
|
+
color: var(--blue);
|
|
585
|
+
background: color-mix(in srgb, var(--blue) 8%, transparent);
|
|
586
|
+
border: 1px solid color-mix(in srgb, var(--blue) 20%, transparent);
|
|
587
|
+
border-radius: 3px;
|
|
588
|
+
padding: 0 4px;
|
|
589
|
+
font-size: 0.92em;
|
|
590
|
+
text-decoration: none;
|
|
591
|
+
cursor: pointer;
|
|
592
|
+
}
|
|
593
|
+
.note-ref-link:hover { background: color-mix(in srgb, var(--blue) 15%, transparent); }
|
|
594
|
+
|
|
595
|
+
/* Backlinks */
|
|
596
|
+
.note-backlinks {
|
|
597
|
+
display: flex;
|
|
598
|
+
align-items: center;
|
|
599
|
+
gap: 6px;
|
|
600
|
+
flex-wrap: wrap;
|
|
601
|
+
padding: 14px 20px;
|
|
602
|
+
border-top: 1px solid var(--rule);
|
|
603
|
+
margin-top: 8px;
|
|
604
|
+
}
|
|
605
|
+
.note-backlinks-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--ink-3); margin-right: 2px; }
|
|
606
|
+
.note-backlink-chip {
|
|
607
|
+
font-size: 11px;
|
|
608
|
+
background: var(--canvas);
|
|
609
|
+
border: 1px solid var(--rule-2);
|
|
610
|
+
border-radius: 20px;
|
|
611
|
+
padding: 2px 9px;
|
|
612
|
+
cursor: pointer;
|
|
613
|
+
color: var(--ink-2);
|
|
614
|
+
}
|
|
615
|
+
.note-backlink-chip:hover { border-color: var(--blue); color: var(--blue); }
|
|
616
|
+
.note-related-chip { color: var(--ink-3); border-style: dashed; }
|
|
617
|
+
.note-related-chip:hover { border-color: var(--ink-2); color: var(--ink); border-style: solid; }
|
|
618
|
+
|
|
619
|
+
/* Note editor autocomplete */
|
|
620
|
+
.note-autocomplete {
|
|
621
|
+
position: absolute;
|
|
622
|
+
bottom: calc(100% + 4px);
|
|
623
|
+
left: 0;
|
|
624
|
+
background: var(--white);
|
|
625
|
+
border: 1px solid var(--rule-2);
|
|
626
|
+
border-radius: 6px;
|
|
627
|
+
box-shadow: 0 4px 12px rgba(0,0,0,.12);
|
|
628
|
+
z-index: 200;
|
|
629
|
+
min-width: 220px;
|
|
630
|
+
overflow: hidden;
|
|
631
|
+
}
|
|
632
|
+
.note-autocomplete-item {
|
|
633
|
+
display: flex;
|
|
634
|
+
align-items: center;
|
|
635
|
+
gap: 4px;
|
|
636
|
+
padding: 6px 10px;
|
|
637
|
+
font-size: 12px;
|
|
638
|
+
cursor: pointer;
|
|
639
|
+
color: var(--ink);
|
|
640
|
+
}
|
|
641
|
+
.note-autocomplete-item.active, .note-autocomplete-item:hover { background: var(--canvas); }
|
|
642
|
+
.note-ac-hash { color: var(--blue); font-weight: 600; }
|
|
643
|
+
.note-ac-title { color: var(--ink-3); font-size: 11px; margin-left: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
644
|
+
|
|
487
645
|
/* Collapsibles */
|
|
488
646
|
.collapsible {
|
|
489
647
|
border: 1px solid var(--rule);
|
|
@@ -2581,7 +2739,10 @@
|
|
|
2581
2739
|
<template x-for="task in todayData.tasks.filter(t => t.carriedOver && !t.done)" :key="task.id">
|
|
2582
2740
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;margin-bottom:4px;background:var(--canvas-2);border:1px solid var(--rule);border-left:3px solid var(--ink-3);border-radius:4px">
|
|
2583
2741
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2584
|
-
<span
|
|
2742
|
+
<span style="flex:1;font-size:13px;color:var(--ink-2)">
|
|
2743
|
+
<span x-text="task.text"></span>
|
|
2744
|
+
<template x-if="task.noteRef"><a class="note-ref-chip" @click.prevent="view='notes';selectedNote=personalNotes.find(n=>n.path===task.noteRef.path)||null" href="#" x-text="'↗ '+task.noteRef.title"></a></template>
|
|
2745
|
+
</span>
|
|
2585
2746
|
<button @click="deleteTodayTask(task.id)" style="background:none;border:none;color:var(--ink-3);cursor:pointer;font-size:14px;padding:0 2px;line-height:1" title="Remove">×</button>
|
|
2586
2747
|
</div>
|
|
2587
2748
|
</template>
|
|
@@ -2592,7 +2753,10 @@
|
|
|
2592
2753
|
<template x-for="task in todayData.tasks.filter(t => !t.carriedOver && !t.done)" :key="task.id">
|
|
2593
2754
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule)">
|
|
2594
2755
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2595
|
-
<span
|
|
2756
|
+
<span style="flex:1;font-size:13px;color:var(--ink)">
|
|
2757
|
+
<span x-text="task.text"></span>
|
|
2758
|
+
<template x-if="task.noteRef"><a class="note-ref-chip" @click.prevent="view='notes';selectedNote=personalNotes.find(n=>n.path===task.noteRef.path)||null" href="#" x-text="'↗ '+task.noteRef.title"></a></template>
|
|
2759
|
+
</span>
|
|
2596
2760
|
<button @click="deleteTodayTask(task.id)" style="background:none;border:none;color:var(--ink-3);cursor:pointer;font-size:14px;padding:0 2px;line-height:1" title="Remove">×</button>
|
|
2597
2761
|
</div>
|
|
2598
2762
|
</template>
|
|
@@ -2604,7 +2768,10 @@
|
|
|
2604
2768
|
<template x-for="task in todayData.tasks.filter(t => t.done)" :key="task.id">
|
|
2605
2769
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule);opacity:0.5">
|
|
2606
2770
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2607
|
-
<span
|
|
2771
|
+
<span style="flex:1;font-size:13px;text-decoration:line-through;color:var(--ink-3)">
|
|
2772
|
+
<span x-text="task.text"></span>
|
|
2773
|
+
<template x-if="task.noteRef"><a class="note-ref-chip" @click.prevent="view='notes';selectedNote=personalNotes.find(n=>n.path===task.noteRef.path)||null" href="#" x-text="'↗ '+task.noteRef.title"></a></template>
|
|
2774
|
+
</span>
|
|
2608
2775
|
<button @click="deleteTodayTask(task.id)" style="background:none;border:none;color:var(--ink-3);cursor:pointer;font-size:14px;padding:0 2px;line-height:1" title="Remove">×</button>
|
|
2609
2776
|
</div>
|
|
2610
2777
|
</template>
|
|
@@ -2704,21 +2871,44 @@
|
|
|
2704
2871
|
</template>
|
|
2705
2872
|
<template x-if="selectedNote && !noteEditing">
|
|
2706
2873
|
<div style="display:flex;align-items:center;gap:8px">
|
|
2707
|
-
<button class="btn btn-sm btn-outline"
|
|
2874
|
+
<button class="btn btn-sm btn-outline" :style="selectedNote.pinned ? 'color:var(--yellow,#f59e0b);border-color:var(--yellow,#f59e0b)' : ''" @click="togglePinNote()" :title="selectedNote.pinned ? 'Unpin' : 'Pin note'">📌</button>
|
|
2875
|
+
<button class="btn btn-sm btn-outline" @click="addNoteTask(selectedNote.title, selectedNote)" title="Add note title as Today task">+ Today</button>
|
|
2876
|
+
<button class="btn btn-sm btn-outline" @click="noteEditing=true;noteTitleDraft=selectedNote.title;noteBodyDraft=selectedNote.content;noteTagsDraft=(selectedNote.tags||[]).join(', ')">Edit</button>
|
|
2877
|
+
<select class="filter-select" style="font-size:12px" @change="movePersonalNote($event.target.value);$event.target.value='__current__'" x-init="">
|
|
2878
|
+
<option value="__current__">Move to…</option>
|
|
2879
|
+
<option value="">/ root</option>
|
|
2880
|
+
<template x-for="f in noteAllFolders().filter(f=>f!==(selectedNote.folder??''))" :key="f">
|
|
2881
|
+
<option :value="f" x-text="'/ '+f"></option>
|
|
2882
|
+
</template>
|
|
2883
|
+
</select>
|
|
2708
2884
|
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePersonalNote()">Delete</button>
|
|
2709
2885
|
</div>
|
|
2710
2886
|
</template>
|
|
2711
|
-
<button class="btn btn-primary btn-sm" @click="
|
|
2887
|
+
<button class="btn btn-primary btn-sm" @click="openNewNote()">+ New note</button>
|
|
2712
2888
|
</div>
|
|
2713
2889
|
</div>
|
|
2714
2890
|
|
|
2715
2891
|
<!-- New note form -->
|
|
2716
2892
|
<template x-if="noteCreating">
|
|
2717
2893
|
<div style="padding:16px 20px;border-bottom:1px solid var(--rule);background:var(--canvas-2)">
|
|
2718
|
-
<input class="settings-input" type="text" placeholder="Note title…" x-model="noteNewTitle" style="width:100%;margin-bottom:8px;font-size:13px" @keydown.enter="createPersonalNote()" />
|
|
2894
|
+
<input class="settings-input" type="text" placeholder="Note title…" x-model="noteNewTitle" style="width:100%;margin-bottom:8px;font-size:13px" @keydown.enter="createPersonalNote()" x-init="$nextTick(()=>$el.focus())" />
|
|
2895
|
+
<div style="position:relative">
|
|
2896
|
+
<div style="display:flex;gap:8px;margin-bottom:8px">
|
|
2897
|
+
<input class="settings-input" type="text" placeholder="Tags (bug, idea, trabajo)…" x-model="noteNewTags" style="flex:1;font-size:12px" />
|
|
2898
|
+
<select class="filter-select" x-model="noteNewFolder" style="font-size:12px;min-width:100px">
|
|
2899
|
+
<option value="">/ root</option>
|
|
2900
|
+
<template x-for="f in noteAllFolders()" :key="f">
|
|
2901
|
+
<option :value="f" x-text="'/ '+f"></option>
|
|
2902
|
+
</template>
|
|
2903
|
+
</select>
|
|
2904
|
+
</div>
|
|
2719
2905
|
<textarea class="settings-input" placeholder="Content (optional)…" x-model="noteNewBody" rows="3" style="width:100%;resize:vertical;font-size:12px;font-family:inherit"></textarea>
|
|
2906
|
+
<template x-if="noteFromClipboard">
|
|
2907
|
+
<span style="position:absolute;top:6px;right:8px;font-size:10px;background:var(--canvas);border:1px solid var(--rule-2);border-radius:3px;padding:1px 5px;color:var(--ink-3);pointer-events:none">from clipboard</span>
|
|
2908
|
+
</template>
|
|
2909
|
+
</div>
|
|
2720
2910
|
<div style="display:flex;gap:8px;margin-top:8px;justify-content:flex-end">
|
|
2721
|
-
<button class="btn btn-sm btn-outline" @click="noteCreating=false">Cancel</button>
|
|
2911
|
+
<button class="btn btn-sm btn-outline" @click="noteCreating=false;noteFromClipboard=false">Cancel</button>
|
|
2722
2912
|
<button class="btn btn-primary btn-sm" @click="createPersonalNote()" :disabled="noteSaving||!noteNewTitle.trim()">Create</button>
|
|
2723
2913
|
</div>
|
|
2724
2914
|
</div>
|
|
@@ -2738,42 +2928,143 @@
|
|
|
2738
2928
|
</div>
|
|
2739
2929
|
</div>
|
|
2740
2930
|
</template>
|
|
2741
|
-
<template x-if="!personalNotesLoading
|
|
2742
|
-
<div style="max-width:480px;margin:40px auto;padding:0 24px;text-align:center">
|
|
2743
|
-
<div style="font-size:28px;margin-bottom:12px">✎</div>
|
|
2744
|
-
<div style="font-size:15px;font-weight:600;margin-bottom:8px">Your personal notepad</div>
|
|
2745
|
-
<div style="font-size:13px;color:var(--ink-3);margin-bottom:24px;line-height:1.6">Notes are <strong>for you</strong>, not for Claude. Claude won't read them as context or memory — they're a place to capture what matters to you across your sessions.</div>
|
|
2746
|
-
<div style="background:var(--canvas-2);border:1px solid var(--rule);border-radius:8px;padding:16px;text-align:left;margin-bottom:20px">
|
|
2747
|
-
<div style="font-size:11px;font-weight:600;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:10px">Try saying to Claude</div>
|
|
2748
|
-
<div style="display:flex;flex-direction:column;gap:7px">
|
|
2749
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Save this as a note"</span></div>
|
|
2750
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Save this last output as a note"</span></div>
|
|
2751
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Add this decision to my notes"</span></div>
|
|
2752
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Save this as a TIL"</span></div>
|
|
2753
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Show me my last note"</span></div>
|
|
2754
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"What notes do I have?"</span></div>
|
|
2755
|
-
<div style="font-size:12px"><span style="font-family:monospace;background:var(--canvas);border:1px solid var(--rule);padding:2px 6px;border-radius:4px">"Show me my notes"</span> <span style="font-size:11px;color:var(--ink-3)">→ http://localhost:3141</span></div>
|
|
2756
|
-
</div>
|
|
2757
|
-
</div>
|
|
2758
|
-
<div style="font-size:11px;color:var(--ink-3)">Or create a note manually with the <strong>+ New note</strong> button above.</div>
|
|
2759
|
-
</div>
|
|
2760
|
-
</template>
|
|
2761
|
-
<template x-if="!personalNotesLoading && personalNotes.length > 0">
|
|
2931
|
+
<template x-if="!personalNotesLoading">
|
|
2762
2932
|
<div class="memory-layout">
|
|
2763
2933
|
<div class="memory-sidebar" :style="'width:'+sidebarW+'px'">
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2934
|
+
<!-- Scratch — always pinned at top -->
|
|
2935
|
+
<div class="plan-row scratch-row" :class="{active: scratchActive}" @click="scratchActive=true;selectedNote=null;noteEditing=false;window.location.hash=''">
|
|
2936
|
+
<div style="display:flex;align-items:center;gap:5px">
|
|
2937
|
+
<span style="font-size:11px;opacity:.7">⚡</span>
|
|
2938
|
+
<span style="font-size:12.5px;font-weight:500">Scratch</span>
|
|
2939
|
+
<span style="font-size:10px;color:var(--ink-3);margin-left:2px">session only</span>
|
|
2940
|
+
</div>
|
|
2941
|
+
<div x-show="scratchContent.length > 0" style="font-size:10px;color:var(--ink-3)" x-text="scratchContent.slice(0,40) + (scratchContent.length>40?'…':'')"></div>
|
|
2942
|
+
</div>
|
|
2943
|
+
<div style="border-top:1px solid var(--rule)"></div>
|
|
2944
|
+
<!-- Folder nav: at root show folders + create; inside folder show back -->
|
|
2945
|
+
<template x-if="noteFolderPath === ''">
|
|
2946
|
+
<div>
|
|
2947
|
+
<template x-for="f in noteAllFolders()" :key="f">
|
|
2948
|
+
<div class="note-folder-row" @click="if(noteRenamingFolder!==f)noteFolderPath=f;selectedNote=null;scratchActive=false">
|
|
2949
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2950
|
+
<span class="note-folder-icon">▶</span>
|
|
2951
|
+
</template>
|
|
2952
|
+
<template x-if="noteRenamingFolder === f">
|
|
2953
|
+
<input class="settings-input" style="flex:1;font-size:11px;padding:2px 5px;margin-right:4px" x-model="noteRenameFolderDraft"
|
|
2954
|
+
@click.stop
|
|
2955
|
+
x-init="$nextTick(()=>$el.focus())"
|
|
2956
|
+
@keydown.enter.stop="renameFolderConfirm(f)"
|
|
2957
|
+
@keydown.escape.stop="noteRenamingFolder=null;noteRenameFolderDraft=''" />
|
|
2958
|
+
</template>
|
|
2959
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2960
|
+
<span class="note-folder-name" x-text="f"></span>
|
|
2961
|
+
</template>
|
|
2962
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2963
|
+
<span class="note-folder-count" x-text="personalNotes.filter(n=>(n.folder??'')===f).length"></span>
|
|
2964
|
+
</template>
|
|
2965
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2966
|
+
<span style="font-size:10px;color:var(--ink-3);padding:0 2px;opacity:0;transition:opacity .15s" class="note-folder-rename-btn"
|
|
2967
|
+
@click.stop="noteRenamingFolder=f;noteRenameFolderDraft=f" title="Rename">✎</span>
|
|
2968
|
+
</template>
|
|
2969
|
+
<template x-if="noteRenamingFolder === f">
|
|
2970
|
+
<button class="btn btn-sm btn-primary" style="padding:1px 7px;font-size:11px" @click.stop="renameFolderConfirm(f)">OK</button>
|
|
2971
|
+
</template>
|
|
2972
|
+
</div>
|
|
2973
|
+
</template>
|
|
2974
|
+
<div class="note-folder-create-row">
|
|
2975
|
+
<template x-if="!noteCreatingFolder">
|
|
2976
|
+
<span class="note-folder-add" @click="noteCreatingFolder=true">+ New folder</span>
|
|
2977
|
+
</template>
|
|
2978
|
+
<template x-if="noteCreatingFolder">
|
|
2979
|
+
<div style="display:flex;gap:4px;padding:4px 10px">
|
|
2980
|
+
<input class="settings-input" style="flex:1;font-size:11px;padding:3px 6px" x-model="newFolderName" placeholder="folder name…" x-init="$nextTick(()=>$el.focus())" @keydown.enter="createFolder()" @keydown.escape="noteCreatingFolder=false;newFolderName=''" />
|
|
2981
|
+
<button class="btn btn-sm btn-primary" style="padding:2px 8px;font-size:11px" @click="createFolder()">Create</button>
|
|
2982
|
+
</div>
|
|
2983
|
+
</template>
|
|
2984
|
+
</div>
|
|
2985
|
+
<template x-if="noteAllFolders().length > 0">
|
|
2986
|
+
<div style="border-top:1px solid var(--rule);margin:2px 0"></div>
|
|
2987
|
+
</template>
|
|
2988
|
+
</div>
|
|
2989
|
+
</template>
|
|
2990
|
+
<template x-if="noteFolderPath !== ''">
|
|
2991
|
+
<div class="note-folder-back" @click="noteFolderPath='';selectedNote=null">
|
|
2992
|
+
<span style="color:var(--blue)">← All notes</span>
|
|
2993
|
+
<span style="color:var(--ink-3)"> / </span>
|
|
2994
|
+
<span style="font-weight:500" x-text="noteFolderPath"></span>
|
|
2995
|
+
</div>
|
|
2996
|
+
</template>
|
|
2997
|
+
<!-- Tag filter (scoped to current folder) -->
|
|
2998
|
+
<template x-if="noteAllTags().length > 0">
|
|
2999
|
+
<div class="note-tag-filter" @click.window="noteTagMenu=null;noteTagRenaming=false">
|
|
3000
|
+
<template x-for="t in noteAllTags()" :key="t.tag">
|
|
3001
|
+
<span class="note-tag-chip-wrap" style="position:relative;display:inline-flex;align-items:center">
|
|
3002
|
+
<span class="note-tag-chip" :class="{active: noteTagFilter.includes(t.tag)}"
|
|
3003
|
+
@click="noteTagFilter.includes(t.tag) ? noteTagFilter.splice(noteTagFilter.indexOf(t.tag),1) : noteTagFilter.push(t.tag)"
|
|
3004
|
+
x-text="t.tag + ' (' + t.count + ')'"></span>
|
|
3005
|
+
<span class="note-tag-mgmt-btn" @click.stop="noteTagMenu===t.tag ? noteTagMenu=null : (noteTagMenu=t.tag, noteTagRenameDraft=t.tag)" title="Manage tag">⋮</span>
|
|
3006
|
+
<template x-if="noteTagMenu === t.tag">
|
|
3007
|
+
<div class="note-tag-menu" @click.stop>
|
|
3008
|
+
<template x-if="!noteTagRenaming">
|
|
3009
|
+
<div>
|
|
3010
|
+
<div class="note-tag-menu-item" @click="noteTagRenaming=true;$nextTick(()=>$refs['tagRenameInput_'+t.tag]?.focus())">Rename…</div>
|
|
3011
|
+
<div class="note-tag-menu-item note-tag-menu-danger" @click="deleteTag(t.tag)">Delete from all notes</div>
|
|
3012
|
+
</div>
|
|
3013
|
+
</template>
|
|
3014
|
+
<template x-if="noteTagRenaming">
|
|
3015
|
+
<div style="display:flex;gap:4px;padding:2px">
|
|
3016
|
+
<input class="settings-input" style="font-size:11px;padding:2px 5px;width:100px"
|
|
3017
|
+
x-model="noteTagRenameDraft"
|
|
3018
|
+
:ref="'tagRenameInput_'+t.tag"
|
|
3019
|
+
x-init="$nextTick(()=>$el.focus())"
|
|
3020
|
+
@keydown.enter="renameTag(t.tag, noteTagRenameDraft)"
|
|
3021
|
+
@keydown.escape="noteTagRenaming=false" />
|
|
3022
|
+
<button class="btn btn-sm btn-primary" style="padding:1px 6px;font-size:11px" @click="renameTag(t.tag, noteTagRenameDraft)">OK</button>
|
|
3023
|
+
</div>
|
|
3024
|
+
</template>
|
|
3025
|
+
</div>
|
|
3026
|
+
</template>
|
|
3027
|
+
</span>
|
|
3028
|
+
</template>
|
|
3029
|
+
</div>
|
|
3030
|
+
</template>
|
|
3031
|
+
<!-- Note list (scoped to current folder) -->
|
|
3032
|
+
<template x-for="n in noteFolderNotes()" :key="n.path">
|
|
3033
|
+
<div class="plan-row" :class="{active: selectedNote?.path === n.path}" @click="selectedNote=n;scratchActive=false;noteEditing=false;window.location.hash='#/note/'+n.path"
|
|
3034
|
+
<div class="plan-row-title" style="display:flex;align-items:center;gap:4px">
|
|
3035
|
+
<template x-if="n.pinned"><span style="font-size:10px;line-height:1" title="Pinned">📌</span></template>
|
|
3036
|
+
<span x-text="n.title"></span>
|
|
3037
|
+
</div>
|
|
2767
3038
|
<div class="plan-row-meta">
|
|
2768
3039
|
<span x-text="formatDate(n.modified)"></span>
|
|
2769
|
-
<template x-if="n.session"><span style="color:var(--blue);font-size:10px">session</span></template>
|
|
3040
|
+
<template x-if="n.session && n.session.length>=32"><span style="color:var(--blue);font-size:10px">session</span></template>
|
|
2770
3041
|
</div>
|
|
3042
|
+
<template x-if="(n.tags||[]).length > 0">
|
|
3043
|
+
<div class="note-row-tags">
|
|
3044
|
+
<template x-for="tag in n.tags" :key="tag">
|
|
3045
|
+
<span class="note-row-tag" x-text="tag" @click.stop="noteTagFilter.includes(tag) ? noteTagFilter.splice(noteTagFilter.indexOf(tag),1) : noteTagFilter.push(tag)"></span>
|
|
3046
|
+
</template>
|
|
3047
|
+
</div>
|
|
3048
|
+
</template>
|
|
2771
3049
|
</div>
|
|
2772
3050
|
</template>
|
|
2773
3051
|
</div>
|
|
2774
3052
|
<div class="resize-handle" @mousedown.prevent="startResize($event)"></div>
|
|
2775
3053
|
<div class="memory-detail">
|
|
2776
|
-
|
|
3054
|
+
<!-- Scratch detail -->
|
|
3055
|
+
<template x-if="scratchActive">
|
|
3056
|
+
<div style="display:flex;flex-direction:column;height:100%;gap:10px">
|
|
3057
|
+
<div style="display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
|
3058
|
+
<span style="font-size:11px;color:var(--ink-3)">⚡ Not saved · cleared on page reload</span>
|
|
3059
|
+
<div style="display:flex;gap:8px">
|
|
3060
|
+
<button class="btn btn-sm btn-outline" @click="scratchContent='';sessionStorage.removeItem('cs:scratch')" x-show="scratchContent.length>0">Clear</button>
|
|
3061
|
+
<button class="btn btn-primary btn-sm" @click="promoteScratch()" :disabled="!scratchContent.trim()">Save as note…</button>
|
|
3062
|
+
</div>
|
|
3063
|
+
</div>
|
|
3064
|
+
<textarea class="settings-input" x-model="scratchContent" @input="sessionStorage.setItem('cs:scratch',scratchContent)" style="flex:1;resize:none;font-family:monospace;font-size:12px;min-height:0;width:100%" placeholder="Scratch pad — nothing here is persisted. Use ↑ Save as note to keep it."></textarea>
|
|
3065
|
+
</div>
|
|
3066
|
+
</template>
|
|
3067
|
+
<template x-if="!scratchActive && !selectedNote">
|
|
2777
3068
|
<div style="padding:24px;max-width:380px">
|
|
2778
3069
|
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--ink)">Your personal notepad</div>
|
|
2779
3070
|
<div style="font-size:12px;color:var(--ink-3);margin-bottom:16px;line-height:1.6">Notes are <strong>for you</strong>, not for Claude. He won't read them as context — they're yours to capture, review and revisit.</div>
|
|
@@ -2793,17 +3084,53 @@
|
|
|
2793
3084
|
<div class="memory-detail-title" x-text="selectedNote.title"></div>
|
|
2794
3085
|
<div class="memory-detail-meta">
|
|
2795
3086
|
<span style="font-size:11px;color:var(--ink-3)" x-text="formatDate(selectedNote.modified)"></span>
|
|
2796
|
-
<template x-if="selectedNote.session">
|
|
3087
|
+
<template x-if="selectedNote.session && selectedNote.session.length >= 32">
|
|
2797
3088
|
<span style="font-size:11px;color:var(--blue);cursor:pointer" @click="openSessionById(selectedNote.session)" x-text="'→ Session ' + selectedNote.session.slice(0,8) + '…'"></span>
|
|
2798
3089
|
</template>
|
|
2799
3090
|
</div>
|
|
2800
|
-
<
|
|
3091
|
+
<template x-if="(selectedNote.tags||[]).length > 0">
|
|
3092
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;padding:0 20px 10px">
|
|
3093
|
+
<template x-for="tag in selectedNote.tags" :key="tag">
|
|
3094
|
+
<span class="note-tag-chip active" style="cursor:pointer" @click="noteTagFilter.includes(tag) ? noteTagFilter.splice(noteTagFilter.indexOf(tag),1) : noteTagFilter.push(tag)" x-text="tag"></span>
|
|
3095
|
+
</template>
|
|
3096
|
+
</div>
|
|
3097
|
+
</template>
|
|
3098
|
+
<div class="memory-content md-content" @click="handleNoteLinkClick($event)" x-html="selectedNote.content ? renderNotesMd(selectedNote.content) : '<span style=\'color:var(--ink-3)\'>Empty note. Click Edit to add content.</span>'"></div>
|
|
3099
|
+
<template x-if="noteBacklinks(selectedNote).length > 0">
|
|
3100
|
+
<div class="note-backlinks">
|
|
3101
|
+
<span class="note-backlinks-label">Referenced by</span>
|
|
3102
|
+
<template x-for="bl in noteBacklinks(selectedNote)" :key="bl.filename">
|
|
3103
|
+
<span class="note-backlink-chip" @click="selectedNote=bl;noteEditing=false;window.location.hash='#/note/'+bl.path" x-text="bl.title"></span>
|
|
3104
|
+
</template>
|
|
3105
|
+
</div>
|
|
3106
|
+
</template>
|
|
3107
|
+
<template x-if="noteRelated(selectedNote).length > 0">
|
|
3108
|
+
<div class="note-backlinks">
|
|
3109
|
+
<span class="note-backlinks-label">Related</span>
|
|
3110
|
+
<template x-for="rn in noteRelated(selectedNote)" :key="rn.filename">
|
|
3111
|
+
<span class="note-backlink-chip note-related-chip" @click="selectedNote=rn;noteEditing=false;window.location.hash='#/note/'+rn.path" x-text="rn.title"></span>
|
|
3112
|
+
</template>
|
|
3113
|
+
</div>
|
|
3114
|
+
</template>
|
|
2801
3115
|
</div>
|
|
2802
3116
|
</template>
|
|
2803
3117
|
<template x-if="selectedNote && noteEditing">
|
|
2804
|
-
<div style="display:flex;flex-direction:column;height:100%;gap:10px">
|
|
3118
|
+
<div style="display:flex;flex-direction:column;height:100%;gap:10px;position:relative">
|
|
2805
3119
|
<input class="settings-input" type="text" x-model="noteTitleDraft" style="font-size:14px;font-weight:600;width:100%" />
|
|
2806
|
-
<
|
|
3120
|
+
<input class="settings-input" type="text" x-model="noteTagsDraft" placeholder="Tags (comma separated: bug, idea, trabajo)…" style="font-size:12px;width:100%" />
|
|
3121
|
+
<div style="position:relative;flex:1;display:flex;flex-direction:column">
|
|
3122
|
+
<textarea class="settings-input note-body-ta" x-model="noteBodyDraft" @input="noteEditorInput($event)" @keydown="noteEditorKeydown($event)" @blur="setTimeout(()=>noteAutocomplete.visible=false,150)" style="flex:1;resize:none;font-family:monospace;font-size:12px;min-height:300px;width:100%"></textarea>
|
|
3123
|
+
<template x-if="noteAutocomplete.visible">
|
|
3124
|
+
<div class="note-autocomplete">
|
|
3125
|
+
<template x-for="(n, i) in noteAutocomplete.results" :key="n.filename">
|
|
3126
|
+
<div class="note-autocomplete-item" :class="{active: i===noteAutocomplete.pos}" @mousedown.prevent="noteAutocompleteSelect(n)">
|
|
3127
|
+
<span class="note-ac-hash">#</span><span x-text="n.filename.replace(/\.md$/,'').replace(/^\d{4}-\d{2}-\d{2}-/,'')"></span>
|
|
3128
|
+
<span class="note-ac-title" x-text="n.title"></span>
|
|
3129
|
+
</div>
|
|
3130
|
+
</template>
|
|
3131
|
+
</div>
|
|
3132
|
+
</template>
|
|
3133
|
+
</div>
|
|
2807
3134
|
</div>
|
|
2808
3135
|
</template>
|
|
2809
3136
|
</div>
|
|
@@ -4478,6 +4805,23 @@
|
|
|
4478
4805
|
noteNewBody: '',
|
|
4479
4806
|
noteClaudeInstalled: null,
|
|
4480
4807
|
noteSetupMsg: '',
|
|
4808
|
+
noteAutocomplete: { visible: false, query: '', results: [], pos: 0 },
|
|
4809
|
+
noteFromClipboard: false,
|
|
4810
|
+
scratchContent: sessionStorage.getItem('cs:scratch') || '',
|
|
4811
|
+
scratchActive: false,
|
|
4812
|
+
noteTagFilter: [],
|
|
4813
|
+
noteTagsDraft: '',
|
|
4814
|
+
noteNewTags: '',
|
|
4815
|
+
noteFolderPath: '',
|
|
4816
|
+
noteNewFolder: '',
|
|
4817
|
+
noteCreatingFolder: false,
|
|
4818
|
+
newFolderName: '',
|
|
4819
|
+
noteFolders: [],
|
|
4820
|
+
noteRenamingFolder: null,
|
|
4821
|
+
noteRenameFolderDraft: '',
|
|
4822
|
+
noteTagMenu: null,
|
|
4823
|
+
noteTagRenaming: false,
|
|
4824
|
+
noteTagRenameDraft: '',
|
|
4481
4825
|
sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
|
|
4482
4826
|
historyEntries: [],
|
|
4483
4827
|
historyLoading: false,
|
|
@@ -4626,7 +4970,11 @@
|
|
|
4626
4970
|
if (savedFrom) this.filterFrom = savedFrom;
|
|
4627
4971
|
if (savedTo) this.filterTo = savedTo;
|
|
4628
4972
|
|
|
4629
|
-
this.$watch('view',
|
|
4973
|
+
this.$watch('view', v => {
|
|
4974
|
+
localStorage.setItem('cs:view', v);
|
|
4975
|
+
if (v !== 'notes' && window.location.hash.startsWith('#/note/')) window.location.hash = '';
|
|
4976
|
+
if (v !== 'sessions' && window.location.hash.startsWith('#/session/')) window.location.hash = '';
|
|
4977
|
+
});
|
|
4630
4978
|
this.$watch('filterProject', v => localStorage.setItem('cs:project', v));
|
|
4631
4979
|
this.$watch('filterBranch', v => localStorage.setItem('cs:branch', v));
|
|
4632
4980
|
this.$watch('filterText', v => localStorage.setItem('cs:filterText', v));
|
|
@@ -4658,7 +5006,7 @@
|
|
|
4658
5006
|
if (mNote) {
|
|
4659
5007
|
const notes = await fetch('/api/notes').then(r => r.json()).catch(() => []);
|
|
4660
5008
|
this.personalNotes = notes;
|
|
4661
|
-
const note = this.personalNotes.find(n => n.
|
|
5009
|
+
const note = this.personalNotes.find(n => n.path === mNote[1]);
|
|
4662
5010
|
if (note) { this.view = 'notes'; this.selectedNote = note; }
|
|
4663
5011
|
}
|
|
4664
5012
|
// Clear hash when closing
|
|
@@ -5043,6 +5391,229 @@
|
|
|
5043
5391
|
|
|
5044
5392
|
renderMd(text) { try { return marked.parse(text); } catch { return text; } },
|
|
5045
5393
|
|
|
5394
|
+
renderNotesMd(text) {
|
|
5395
|
+
const processed = text.replace(/(^|[^&\w])#([a-zA-Z][\w-]+)/g, (match, pre, slug) => {
|
|
5396
|
+
const note = this.personalNotes.find(n => {
|
|
5397
|
+
const base = n.filename.replace(/\.md$/, '');
|
|
5398
|
+
const slugPart = base.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
5399
|
+
return slugPart === slug || base === slug;
|
|
5400
|
+
});
|
|
5401
|
+
if (!note) return match;
|
|
5402
|
+
return `${pre}[#${slug}](#/note/${note.path})`;
|
|
5403
|
+
});
|
|
5404
|
+
let html;
|
|
5405
|
+
try { html = marked.parse(processed); } catch { html = processed; }
|
|
5406
|
+
// Mark note-ref links so we can style/intercept them
|
|
5407
|
+
html = html.replace(/href="#\/note\//g, 'class="note-ref-link" href="#/note/');
|
|
5408
|
+
// Inject + buttons into paragraphs and list items
|
|
5409
|
+
const btn = '<button class="para-add-task-btn" title="Add to Today">+</button>';
|
|
5410
|
+
html = html.replace(/<p>/g, `<p>${btn}`);
|
|
5411
|
+
html = html.replace(/<li>/g, `<li>${btn}`);
|
|
5412
|
+
return html;
|
|
5413
|
+
},
|
|
5414
|
+
|
|
5415
|
+
handleNoteLinkClick(e) {
|
|
5416
|
+
const btn = e.target.closest('.para-add-task-btn');
|
|
5417
|
+
if (btn) {
|
|
5418
|
+
e.preventDefault();
|
|
5419
|
+
const block = btn.closest('p,li');
|
|
5420
|
+
const text = block ? block.innerText.replace('+', '').trim() : '';
|
|
5421
|
+
if (text) this.addNoteTask(text, this.selectedNote || this.noteHovered);
|
|
5422
|
+
return;
|
|
5423
|
+
}
|
|
5424
|
+
const a = e.target.closest('a.note-ref-link');
|
|
5425
|
+
if (!a) return;
|
|
5426
|
+
e.preventDefault();
|
|
5427
|
+
const notepath = a.getAttribute('href').replace('#/note/', '');
|
|
5428
|
+
const note = this.personalNotes.find(n => n.path === notepath);
|
|
5429
|
+
if (note) { this.selectedNote = note; this.noteEditing = false; window.location.hash = '#/note/' + note.path; }
|
|
5430
|
+
},
|
|
5431
|
+
|
|
5432
|
+
noteAllFolders() {
|
|
5433
|
+
const fromNotes = this.personalNotes.map(n => n.folder ?? '').filter(Boolean);
|
|
5434
|
+
return [...new Set([...this.noteFolders, ...fromNotes])].sort();
|
|
5435
|
+
},
|
|
5436
|
+
|
|
5437
|
+
noteFolderNotes() {
|
|
5438
|
+
const filtered = this.personalNotes.filter(n => {
|
|
5439
|
+
if ((n.folder ?? '') !== this.noteFolderPath) return false;
|
|
5440
|
+
if (this.noteTagFilter.length > 0 && !this.noteTagFilter.some(t => (n.tags||[]).includes(t))) return false;
|
|
5441
|
+
if (!this.noteSearch) return true;
|
|
5442
|
+
return n.title.toLowerCase().includes(this.noteSearch.toLowerCase()) || (n.content||'').toLowerCase().includes(this.noteSearch.toLowerCase());
|
|
5443
|
+
});
|
|
5444
|
+
return filtered.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
|
5445
|
+
},
|
|
5446
|
+
|
|
5447
|
+
async createFolder() {
|
|
5448
|
+
if (!this.newFolderName.trim()) return;
|
|
5449
|
+
const res = await fetch('/api/notes/folders', {
|
|
5450
|
+
method: 'POST',
|
|
5451
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5452
|
+
body: JSON.stringify({ name: this.newFolderName.trim() }),
|
|
5453
|
+
}).then(r => r.json()).catch(() => null);
|
|
5454
|
+
if (res?.name && !this.noteFolders.includes(res.name)) {
|
|
5455
|
+
this.noteFolders.push(res.name);
|
|
5456
|
+
this.noteFolders.sort();
|
|
5457
|
+
}
|
|
5458
|
+
this.noteCreatingFolder = false;
|
|
5459
|
+
this.newFolderName = '';
|
|
5460
|
+
},
|
|
5461
|
+
|
|
5462
|
+
async renameFolderConfirm(oldName) {
|
|
5463
|
+
const newName = this.noteRenameFolderDraft.trim();
|
|
5464
|
+
if (!newName || newName === oldName) { this.noteRenamingFolder = null; return; }
|
|
5465
|
+
const res = await fetch(`/api/notes/folders/${encodeURIComponent(oldName)}/rename`, {
|
|
5466
|
+
method: 'PATCH',
|
|
5467
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5468
|
+
body: JSON.stringify({ newName }),
|
|
5469
|
+
}).then(r => r.json()).catch(() => null);
|
|
5470
|
+
if (!res?.name) { alert(res?.error || 'Rename failed'); return; }
|
|
5471
|
+
// Update noteFolders list
|
|
5472
|
+
const idx = this.noteFolders.indexOf(oldName);
|
|
5473
|
+
if (idx !== -1) this.noteFolders.splice(idx, 1, res.name);
|
|
5474
|
+
else { this.noteFolders.push(res.name); }
|
|
5475
|
+
this.noteFolders.sort();
|
|
5476
|
+
// Update folder field in all affected notes
|
|
5477
|
+
for (const n of this.personalNotes) {
|
|
5478
|
+
if ((n.folder ?? '') === oldName) {
|
|
5479
|
+
n.folder = res.name;
|
|
5480
|
+
n.path = res.name + '/' + n.filename;
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
if (this.noteFolderPath === oldName) this.noteFolderPath = res.name;
|
|
5484
|
+
if (this.selectedNote?.folder === oldName) {
|
|
5485
|
+
this.selectedNote.folder = res.name;
|
|
5486
|
+
this.selectedNote.path = res.name + '/' + this.selectedNote.filename;
|
|
5487
|
+
}
|
|
5488
|
+
this.noteRenamingFolder = null;
|
|
5489
|
+
this.noteRenameFolderDraft = '';
|
|
5490
|
+
},
|
|
5491
|
+
|
|
5492
|
+
noteAllTags() {
|
|
5493
|
+
const counts = {};
|
|
5494
|
+
const scoped = this.personalNotes.filter(n => (n.folder ?? '') === this.noteFolderPath);
|
|
5495
|
+
for (const n of scoped) {
|
|
5496
|
+
for (const t of (n.tags || [])) counts[t] = (counts[t] || 0) + 1;
|
|
5497
|
+
}
|
|
5498
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => ({ tag, count }));
|
|
5499
|
+
},
|
|
5500
|
+
|
|
5501
|
+
async renameTag(oldName, newName) {
|
|
5502
|
+
newName = newName.trim();
|
|
5503
|
+
if (!newName || newName === oldName) { this.noteTagMenu = null; this.noteTagRenaming = false; return; }
|
|
5504
|
+
const res = await fetch(`/api/notes/tags/${encodeURIComponent(oldName)}/rename`, {
|
|
5505
|
+
method: 'PATCH',
|
|
5506
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5507
|
+
body: JSON.stringify({ newName }),
|
|
5508
|
+
}).then(r => r.json()).catch(() => null);
|
|
5509
|
+
if (!res || res.error) { alert(res?.error || 'Rename failed'); return; }
|
|
5510
|
+
for (const n of this.personalNotes) {
|
|
5511
|
+
if ((n.tags || []).includes(oldName)) {
|
|
5512
|
+
n.tags = n.tags.map(t => t === oldName ? res.to : t);
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
5515
|
+
if (this.noteTagFilter.includes(oldName)) {
|
|
5516
|
+
this.noteTagFilter.splice(this.noteTagFilter.indexOf(oldName), 1, res.to);
|
|
5517
|
+
}
|
|
5518
|
+
if (this.selectedNote?.tags?.includes(oldName)) {
|
|
5519
|
+
this.selectedNote.tags = this.selectedNote.tags.map(t => t === oldName ? res.to : t);
|
|
5520
|
+
}
|
|
5521
|
+
this.noteTagMenu = null;
|
|
5522
|
+
this.noteTagRenaming = false;
|
|
5523
|
+
},
|
|
5524
|
+
|
|
5525
|
+
async deleteTag(name) {
|
|
5526
|
+
if (!confirm(`Remove tag "${name}" from all notes?`)) return;
|
|
5527
|
+
const res = await fetch(`/api/notes/tags/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
|
5528
|
+
.then(r => r.json()).catch(() => null);
|
|
5529
|
+
if (!res || res.error) { alert(res?.error || 'Delete failed'); return; }
|
|
5530
|
+
for (const n of this.personalNotes) {
|
|
5531
|
+
if ((n.tags || []).includes(name)) n.tags = n.tags.filter(t => t !== name);
|
|
5532
|
+
}
|
|
5533
|
+
const fi = this.noteTagFilter.indexOf(name);
|
|
5534
|
+
if (fi !== -1) this.noteTagFilter.splice(fi, 1);
|
|
5535
|
+
if (this.selectedNote?.tags?.includes(name)) {
|
|
5536
|
+
this.selectedNote.tags = this.selectedNote.tags.filter(t => t !== name);
|
|
5537
|
+
}
|
|
5538
|
+
this.noteTagMenu = null;
|
|
5539
|
+
},
|
|
5540
|
+
|
|
5541
|
+
noteTokens(note) {
|
|
5542
|
+
const STOP = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','by','from','is','are','was','were','be','been','have','has','had','do','does','did','will','would','could','should','may','might','that','this','it','its','as','if','not','no','so','up','out','de','la','el','en','que','se','los','las','un','una','con','por','para','como','más','una','sus','del','al','lo','le','les','nos','pero','fue','si','ya','también','este','esta','estos','estas','hay','una']);
|
|
5543
|
+
const text = (note.title + ' ' + (note.content || '')).toLowerCase();
|
|
5544
|
+
return new Set(text.match(/[a-záéíóúüñ]{3,}/g)?.filter(w => !STOP.has(w)) || []);
|
|
5545
|
+
},
|
|
5546
|
+
|
|
5547
|
+
noteRelated(note, limit = 4) {
|
|
5548
|
+
if (!note) return [];
|
|
5549
|
+
const ta = this.noteTokens(note);
|
|
5550
|
+
if (ta.size === 0) return [];
|
|
5551
|
+
const backlinked = new Set(this.noteBacklinks(note).map(n => n.filename));
|
|
5552
|
+
return this.personalNotes
|
|
5553
|
+
.filter(n => n.filename !== note.filename && !backlinked.has(n.filename))
|
|
5554
|
+
.map(n => {
|
|
5555
|
+
const tb = this.noteTokens(n);
|
|
5556
|
+
const shared = [...ta].filter(w => tb.has(w)).length;
|
|
5557
|
+
const score = shared / Math.sqrt(ta.size * tb.size);
|
|
5558
|
+
return { note: n, score };
|
|
5559
|
+
})
|
|
5560
|
+
.filter(x => x.score > 0.08)
|
|
5561
|
+
.sort((a, b) => b.score - a.score)
|
|
5562
|
+
.slice(0, limit)
|
|
5563
|
+
.map(x => x.note);
|
|
5564
|
+
},
|
|
5565
|
+
|
|
5566
|
+
noteBacklinks(note) {
|
|
5567
|
+
if (!note) return [];
|
|
5568
|
+
const slug = note.filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
5569
|
+
const fullBase = note.filename.replace(/\.md$/, '');
|
|
5570
|
+
return this.personalNotes.filter(n => {
|
|
5571
|
+
if (n.filename === note.filename) return false;
|
|
5572
|
+
return n.content && (n.content.includes('#' + slug) || n.content.includes('#' + fullBase));
|
|
5573
|
+
});
|
|
5574
|
+
},
|
|
5575
|
+
|
|
5576
|
+
noteEditorInput(e) {
|
|
5577
|
+
const ta = e.target;
|
|
5578
|
+
const val = ta.value;
|
|
5579
|
+
const pos = ta.selectionStart;
|
|
5580
|
+
// Find # trigger: look back from cursor for #word (no spaces)
|
|
5581
|
+
const before = val.slice(0, pos);
|
|
5582
|
+
const m = before.match(/#([a-zA-Z][\w-]*)$/);
|
|
5583
|
+
if (m) {
|
|
5584
|
+
const query = m[1].toLowerCase();
|
|
5585
|
+
const results = this.personalNotes.filter(n => {
|
|
5586
|
+
const base = n.filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
5587
|
+
return base.includes(query) || n.title.toLowerCase().includes(query);
|
|
5588
|
+
}).slice(0, 6);
|
|
5589
|
+
this.noteAutocomplete = { visible: results.length > 0, query: m[1], results, pos: 0 };
|
|
5590
|
+
} else {
|
|
5591
|
+
this.noteAutocomplete.visible = false;
|
|
5592
|
+
}
|
|
5593
|
+
},
|
|
5594
|
+
|
|
5595
|
+
noteEditorKeydown(e) {
|
|
5596
|
+
const ac = this.noteAutocomplete;
|
|
5597
|
+
if (!ac.visible) return;
|
|
5598
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); ac.pos = Math.min(ac.pos + 1, ac.results.length - 1); }
|
|
5599
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); ac.pos = Math.max(ac.pos - 1, 0); }
|
|
5600
|
+
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); this.noteAutocompleteSelect(ac.results[ac.pos]); }
|
|
5601
|
+
else if (e.key === 'Escape') { ac.visible = false; }
|
|
5602
|
+
},
|
|
5603
|
+
|
|
5604
|
+
noteAutocompleteSelect(note) {
|
|
5605
|
+
const slug = note.filename.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
5606
|
+
const ta = document.querySelector('textarea.note-body-ta');
|
|
5607
|
+
if (!ta) return;
|
|
5608
|
+
const pos = ta.selectionStart;
|
|
5609
|
+
const before = ta.value.slice(0, pos);
|
|
5610
|
+
const after = ta.value.slice(pos);
|
|
5611
|
+
const replaced = before.replace(/#([a-zA-Z][\w-]*)$/, '#' + slug);
|
|
5612
|
+
this.noteBodyDraft = replaced + after;
|
|
5613
|
+
this.noteAutocomplete.visible = false;
|
|
5614
|
+
this.$nextTick(() => { ta.focus(); ta.selectionStart = ta.selectionEnd = replaced.length; });
|
|
5615
|
+
},
|
|
5616
|
+
|
|
5046
5617
|
renderInlineCode(text) {
|
|
5047
5618
|
const esc = text
|
|
5048
5619
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
@@ -5530,15 +6101,41 @@
|
|
|
5530
6101
|
setTimeout(() => this.planShareMsg = '', 5000);
|
|
5531
6102
|
},
|
|
5532
6103
|
|
|
6104
|
+
async openNewNote() {
|
|
6105
|
+
this.noteNewTitle = '';
|
|
6106
|
+
this.noteNewBody = '';
|
|
6107
|
+
this.noteNewTags = '';
|
|
6108
|
+
this.noteNewFolder = this.noteFolderPath;
|
|
6109
|
+
this.noteFromClipboard = false;
|
|
6110
|
+
this.noteCreating = true;
|
|
6111
|
+
try {
|
|
6112
|
+
const text = await navigator.clipboard.readText();
|
|
6113
|
+
if (text && text.trim().length > 0) {
|
|
6114
|
+
this.noteNewBody = text.trim();
|
|
6115
|
+
this.noteFromClipboard = true;
|
|
6116
|
+
}
|
|
6117
|
+
} catch (e) { /* clipboard access denied or empty */ }
|
|
6118
|
+
},
|
|
6119
|
+
|
|
6120
|
+
promoteScratch() {
|
|
6121
|
+
this.noteNewTitle = '';
|
|
6122
|
+
this.noteNewBody = this.scratchContent;
|
|
6123
|
+
this.noteFromClipboard = false;
|
|
6124
|
+
this.scratchActive = false;
|
|
6125
|
+
this.noteCreating = true;
|
|
6126
|
+
},
|
|
6127
|
+
|
|
5533
6128
|
async loadPersonalNotes() {
|
|
5534
6129
|
if (this.personalNotesLoading) return;
|
|
5535
6130
|
this.personalNotesLoading = true;
|
|
5536
|
-
const [notes, status] = await Promise.all([
|
|
6131
|
+
const [notes, status, folders] = await Promise.all([
|
|
5537
6132
|
fetch('/api/notes').then(r => r.json()).catch(() => []),
|
|
5538
6133
|
fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({})),
|
|
6134
|
+
fetch('/api/notes/folders').then(r => r.json()).catch(() => []),
|
|
5539
6135
|
]);
|
|
5540
6136
|
this.personalNotes = notes;
|
|
5541
6137
|
this.noteClaudeInstalled = status.installed ?? null;
|
|
6138
|
+
this.noteFolders = Array.isArray(folders) ? folders : [];
|
|
5542
6139
|
this.personalNotesLoading = false;
|
|
5543
6140
|
},
|
|
5544
6141
|
|
|
@@ -5565,13 +6162,14 @@
|
|
|
5565
6162
|
const note = await fetch('/api/notes', {
|
|
5566
6163
|
method: 'POST',
|
|
5567
6164
|
headers: { 'Content-Type': 'application/json' },
|
|
5568
|
-
body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody }),
|
|
6165
|
+
body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody, tags: this.noteNewTags ? this.noteNewTags.split(',').map(t=>t.trim()).filter(Boolean) : [], folder: this.noteNewFolder }),
|
|
5569
6166
|
}).then(r => r.json());
|
|
5570
6167
|
this.personalNotes.unshift(note);
|
|
5571
6168
|
this.selectedNote = note;
|
|
5572
6169
|
this.noteCreating = false;
|
|
5573
6170
|
this.noteNewTitle = '';
|
|
5574
6171
|
this.noteNewBody = '';
|
|
6172
|
+
this.noteNewTags = '';
|
|
5575
6173
|
this.noteEditing = false;
|
|
5576
6174
|
} catch (e) { this.noteMsg = 'Error: ' + e.message; }
|
|
5577
6175
|
this.noteSaving = false;
|
|
@@ -5581,10 +6179,10 @@
|
|
|
5581
6179
|
if (!this.selectedNote) return;
|
|
5582
6180
|
this.noteSaving = true; this.noteMsg = '';
|
|
5583
6181
|
try {
|
|
5584
|
-
const updated = await fetch(`/api/notes/${this.selectedNote.
|
|
6182
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}`, {
|
|
5585
6183
|
method: 'PUT',
|
|
5586
6184
|
headers: { 'Content-Type': 'application/json' },
|
|
5587
|
-
body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft }),
|
|
6185
|
+
body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft, tags: this.noteTagsDraft.split(',').map(t=>t.trim()).filter(Boolean) }),
|
|
5588
6186
|
}).then(r => r.json());
|
|
5589
6187
|
const idx = this.personalNotes.findIndex(n => n.filename === this.selectedNote.filename);
|
|
5590
6188
|
if (idx !== -1) this.personalNotes[idx] = updated;
|
|
@@ -5598,12 +6196,44 @@
|
|
|
5598
6196
|
|
|
5599
6197
|
async deletePersonalNote() {
|
|
5600
6198
|
if (!this.selectedNote || !confirm(`Delete "${this.selectedNote.title}"?`)) return;
|
|
5601
|
-
await fetch(`/api/notes/${this.selectedNote.
|
|
6199
|
+
await fetch(`/api/notes/${this.selectedNote.path}`, { method: 'DELETE' });
|
|
5602
6200
|
this.personalNotes = this.personalNotes.filter(n => n.filename !== this.selectedNote.filename);
|
|
5603
6201
|
this.selectedNote = null;
|
|
5604
6202
|
this.noteEditing = false;
|
|
5605
6203
|
},
|
|
5606
6204
|
|
|
6205
|
+
async togglePinNote() {
|
|
6206
|
+
if (!this.selectedNote) return;
|
|
6207
|
+
const newPinned = !this.selectedNote.pinned;
|
|
6208
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}`, {
|
|
6209
|
+
method: 'PUT',
|
|
6210
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6211
|
+
body: JSON.stringify({ pinned: newPinned }),
|
|
6212
|
+
}).then(r => r.json());
|
|
6213
|
+
if (updated.error) { alert(updated.error); return; }
|
|
6214
|
+
const idx = this.personalNotes.findIndex(n => n.path === this.selectedNote.path);
|
|
6215
|
+
if (idx !== -1) this.personalNotes.splice(idx, 1, updated);
|
|
6216
|
+
this.selectedNote = updated;
|
|
6217
|
+
},
|
|
6218
|
+
|
|
6219
|
+
|
|
6220
|
+
async movePersonalNote(targetFolder) {
|
|
6221
|
+
if (!this.selectedNote || targetFolder === '__current__') return;
|
|
6222
|
+
const currentFolder = this.selectedNote.folder ?? '';
|
|
6223
|
+
if (targetFolder === currentFolder) return;
|
|
6224
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}/move`, {
|
|
6225
|
+
method: 'PATCH',
|
|
6226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6227
|
+
body: JSON.stringify({ folder: targetFolder }),
|
|
6228
|
+
}).then(r => r.json());
|
|
6229
|
+
if (updated.error) { alert(updated.error); return; }
|
|
6230
|
+
const idx = this.personalNotes.findIndex(n => n.path === this.selectedNote.path);
|
|
6231
|
+
if (idx !== -1) this.personalNotes.splice(idx, 1, updated);
|
|
6232
|
+
this.selectedNote = updated;
|
|
6233
|
+
this.noteFolderPath = targetFolder;
|
|
6234
|
+
window.location.hash = `#/note/${updated.path}`;
|
|
6235
|
+
},
|
|
6236
|
+
|
|
5607
6237
|
async saveNoteFromSession() {
|
|
5608
6238
|
const title = this.selectedSession?.firstPrompt?.slice(0, 60) || 'Session note';
|
|
5609
6239
|
const sessionId = this.selectedSession?.sessionId || '';
|
|
@@ -5654,6 +6284,25 @@
|
|
|
5654
6284
|
this.saveToday();
|
|
5655
6285
|
},
|
|
5656
6286
|
|
|
6287
|
+
addNoteTask(text, note) {
|
|
6288
|
+
if (!text || !this.todayData) return;
|
|
6289
|
+
this.todayData.tasks.push({
|
|
6290
|
+
id: Math.random().toString(36).slice(2),
|
|
6291
|
+
text: text.slice(0, 200),
|
|
6292
|
+
done: false,
|
|
6293
|
+
carriedOver: false,
|
|
6294
|
+
createdAt: new Date().toISOString(),
|
|
6295
|
+
noteRef: note ? { title: note.title, path: note.path } : undefined,
|
|
6296
|
+
});
|
|
6297
|
+
this.saveToday();
|
|
6298
|
+
// Toast
|
|
6299
|
+
const el = document.createElement('div');
|
|
6300
|
+
el.className = 'note-task-toast';
|
|
6301
|
+
el.textContent = '✓ Added to Today';
|
|
6302
|
+
document.body.appendChild(el);
|
|
6303
|
+
setTimeout(() => el.remove(), 2000);
|
|
6304
|
+
},
|
|
6305
|
+
|
|
5657
6306
|
toggleTodayTask(id) {
|
|
5658
6307
|
const task = this.todayData?.tasks.find(t => t.id === id);
|
|
5659
6308
|
if (task) { task.done = !task.done; this.saveToday(); }
|
package/server.js
CHANGED
|
@@ -399,7 +399,12 @@ function parseFrontmatter(raw) {
|
|
|
399
399
|
const kv = line.match(/^([\w-]+):\s*(.*)$/);
|
|
400
400
|
if (kv) {
|
|
401
401
|
currentKey = kv[1];
|
|
402
|
-
|
|
402
|
+
const val = kv[2].trim();
|
|
403
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
404
|
+
meta[currentKey] = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
405
|
+
} else {
|
|
406
|
+
meta[currentKey] = val;
|
|
407
|
+
}
|
|
403
408
|
} else if (currentKey && line.match(/^\s+-\s+(.+)$/)) {
|
|
404
409
|
const val = line.match(/^\s+-\s+(.+)$/)[1].trim();
|
|
405
410
|
if (!Array.isArray(meta[currentKey])) meta[currentKey] = meta[currentKey] ? [meta[currentKey]] : [];
|
|
@@ -1902,21 +1907,47 @@ app.post('/api/share/plan/:filename', async (req, res) => {
|
|
|
1902
1907
|
const NOTES_DIR = path.join(DATA_DIR, 'notes');
|
|
1903
1908
|
function ensureNotesDir() { if (!fs.existsSync(NOTES_DIR)) fs.mkdirSync(NOTES_DIR, { recursive: true }); }
|
|
1904
1909
|
|
|
1905
|
-
function parseNoteFile(
|
|
1906
|
-
const filePath = path.join(NOTES_DIR,
|
|
1910
|
+
function parseNoteFile(notepath) {
|
|
1911
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
1907
1912
|
const stat = fs.statSync(filePath);
|
|
1908
1913
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1909
1914
|
const { meta, content: body } = parseFrontmatter(raw);
|
|
1915
|
+
const tags = Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []);
|
|
1916
|
+
const parts = notepath.replace(/\\/g, '/').split('/');
|
|
1917
|
+
const filename = parts[parts.length - 1];
|
|
1918
|
+
const folder = parts.slice(0, -1).join('/');
|
|
1910
1919
|
return {
|
|
1911
1920
|
filename,
|
|
1921
|
+
path: notepath,
|
|
1922
|
+
folder,
|
|
1912
1923
|
title: meta.title || filename.replace('.md', ''),
|
|
1913
1924
|
date: meta.date || stat.mtimeMs,
|
|
1914
1925
|
session: meta.session || '',
|
|
1926
|
+
tags,
|
|
1927
|
+
pinned: meta.pinned === true || meta.pinned === 'true',
|
|
1915
1928
|
content: body.trim(),
|
|
1916
1929
|
modified: new Date(stat.mtimeMs).toISOString(),
|
|
1917
1930
|
};
|
|
1918
1931
|
}
|
|
1919
1932
|
|
|
1933
|
+
function scanNotes(dir, base) {
|
|
1934
|
+
if (dir === undefined) dir = NOTES_DIR;
|
|
1935
|
+
if (base === undefined) base = '';
|
|
1936
|
+
const results = [];
|
|
1937
|
+
try {
|
|
1938
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1939
|
+
const fullPath = path.join(dir, entry);
|
|
1940
|
+
const relPath = base ? `${base}/${entry}` : entry;
|
|
1941
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
1942
|
+
results.push(...scanNotes(fullPath, relPath));
|
|
1943
|
+
} else if (entry.endsWith('.md')) {
|
|
1944
|
+
results.push(parseNoteFile(relPath));
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
} catch (e) { /* ignore unreadable dirs */ }
|
|
1948
|
+
return results;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1920
1951
|
const NOTES_CLAUDE_MD_SNIPPET = `
|
|
1921
1952
|
## Personal Notes
|
|
1922
1953
|
|
|
@@ -1928,13 +1959,18 @@ When the user asks you to "save a note", "add to notes", "guarda esto como nota"
|
|
|
1928
1959
|
---
|
|
1929
1960
|
title: <descriptive title>
|
|
1930
1961
|
date: <current ISO date>
|
|
1931
|
-
session: <
|
|
1962
|
+
session: <run: ls -t ~/.claude/projects/$(pwd | sed 's|/|-|g' | sed 's|^-||')/*.jsonl 2>/dev/null | head -1 | xargs basename 2>/dev/null | sed 's/\.jsonl//'>
|
|
1932
1963
|
---
|
|
1933
1964
|
|
|
1934
1965
|
<the content the user wants to save>
|
|
1935
1966
|
\`\`\`
|
|
1936
|
-
2.
|
|
1937
|
-
|
|
1967
|
+
2. **Folder**: Save in a subfolder when appropriate:
|
|
1968
|
+
- Project-specific note → \`~/.claude/claude-home/notes/<basename-of-pwd>/\` (e.g. working in \`mono-genially\` → folder \`mono-genially\`)
|
|
1969
|
+
- Global/cross-project note → root \`~/.claude/claude-home/notes/\`
|
|
1970
|
+
- User-specified folder → use that folder name
|
|
1971
|
+
3. **Note linking**: If the note references concepts in other notes, use \`#slug\` syntax (\`2026-03-31-my-slug.md\` → \`#my-slug\`). Glob \`~/.claude/claude-home/notes/**/*.md\` to discover existing slugs.
|
|
1972
|
+
4. Use the Write tool to create the file (not Bash).
|
|
1973
|
+
5. Confirm with: "Saved to Notes: http://localhost:3141/#/note/<folder/filename or filename>"
|
|
1938
1974
|
|
|
1939
1975
|
The notes directory may not exist yet — the app creates it automatically on first load.
|
|
1940
1976
|
|
|
@@ -2002,51 +2038,161 @@ app.post('/api/notes/setup-claude', (req, res) => {
|
|
|
2002
2038
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2003
2039
|
});
|
|
2004
2040
|
|
|
2041
|
+
app.get('/api/notes/folders', (req, res) => {
|
|
2042
|
+
ensureNotesDir();
|
|
2043
|
+
try {
|
|
2044
|
+
const folders = fs.readdirSync(NOTES_DIR).filter(e => fs.statSync(path.join(NOTES_DIR, e)).isDirectory());
|
|
2045
|
+
res.json(folders.sort());
|
|
2046
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
app.post('/api/notes/folders', (req, res) => {
|
|
2050
|
+
ensureNotesDir();
|
|
2051
|
+
const { name } = req.body;
|
|
2052
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
2053
|
+
const safeName = name.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
|
|
2054
|
+
if (!safeName) return res.status(400).json({ error: 'invalid name' });
|
|
2055
|
+
const folderPath = path.join(NOTES_DIR, safeName);
|
|
2056
|
+
if (!folderPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid name' });
|
|
2057
|
+
try {
|
|
2058
|
+
if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
|
|
2059
|
+
res.json({ name: safeName });
|
|
2060
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
app.patch('/api/notes/folders/:name/rename', (req, res) => {
|
|
2064
|
+
ensureNotesDir();
|
|
2065
|
+
const { name } = req.params;
|
|
2066
|
+
const { newName } = req.body;
|
|
2067
|
+
if (!name || !newName) return res.status(400).json({ error: 'name and newName required' });
|
|
2068
|
+
const safeName = newName.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
|
|
2069
|
+
if (!safeName) return res.status(400).json({ error: 'invalid newName' });
|
|
2070
|
+
const srcPath = path.join(NOTES_DIR, name);
|
|
2071
|
+
const destPath = path.join(NOTES_DIR, safeName);
|
|
2072
|
+
if (!srcPath.startsWith(NOTES_DIR) || !destPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2073
|
+
try {
|
|
2074
|
+
if (!fs.existsSync(srcPath)) return res.status(404).json({ error: 'folder not found' });
|
|
2075
|
+
if (fs.existsSync(destPath)) return res.status(409).json({ error: 'folder already exists' });
|
|
2076
|
+
fs.renameSync(srcPath, destPath);
|
|
2077
|
+
res.json({ name: safeName });
|
|
2078
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
function rewriteNoteTags(notepath, updatedTags) {
|
|
2082
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2083
|
+
const existing = parseNoteFile(notepath);
|
|
2084
|
+
const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
|
|
2085
|
+
const tagsLine = updatedTags.length > 0 ? `\ntags: [${updatedTags.join(', ')}]` : '';
|
|
2086
|
+
const pinnedLine = existing.pinned ? `\npinned: true` : '';
|
|
2087
|
+
const raw = `---\ntitle: ${existing.title}\ndate: ${existing.date}${sessionLine}${tagsLine}${pinnedLine}\n---\n\n${existing.content}`;
|
|
2088
|
+
fs.writeFileSync(filePath, raw, 'utf8');
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
app.patch('/api/notes/tags/:name/rename', (req, res) => {
|
|
2092
|
+
const { name } = req.params;
|
|
2093
|
+
const { newName } = req.body;
|
|
2094
|
+
if (!name || !newName) return res.status(400).json({ error: 'name and newName required' });
|
|
2095
|
+
const safe = newName.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
2096
|
+
if (!safe) return res.status(400).json({ error: 'invalid newName' });
|
|
2097
|
+
ensureNotesDir();
|
|
2098
|
+
try {
|
|
2099
|
+
const affected = scanNotes().filter(n => (n.tags || []).includes(name));
|
|
2100
|
+
for (const n of affected) {
|
|
2101
|
+
const tags = n.tags.map(t => t === name ? safe : t);
|
|
2102
|
+
rewriteNoteTags(n.path, tags);
|
|
2103
|
+
}
|
|
2104
|
+
res.json({ renamed: name, to: safe, count: affected.length });
|
|
2105
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
app.delete('/api/notes/tags/:name', (req, res) => {
|
|
2109
|
+
const { name } = req.params;
|
|
2110
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
2111
|
+
ensureNotesDir();
|
|
2112
|
+
try {
|
|
2113
|
+
const affected = scanNotes().filter(n => (n.tags || []).includes(name));
|
|
2114
|
+
for (const n of affected) {
|
|
2115
|
+
const tags = n.tags.filter(t => t !== name);
|
|
2116
|
+
rewriteNoteTags(n.path, tags);
|
|
2117
|
+
}
|
|
2118
|
+
res.json({ deleted: name, count: affected.length });
|
|
2119
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2005
2122
|
app.get('/api/notes', (req, res) => {
|
|
2006
2123
|
ensureNotesDir();
|
|
2007
2124
|
try {
|
|
2008
|
-
const
|
|
2009
|
-
const notes = files.map(f => parseNoteFile(f)).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
2125
|
+
const notes = scanNotes().sort((a, b) => b.modified.localeCompare(a.modified));
|
|
2010
2126
|
res.json(notes);
|
|
2011
2127
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2012
2128
|
});
|
|
2013
2129
|
|
|
2014
2130
|
app.post('/api/notes', (req, res) => {
|
|
2015
2131
|
ensureNotesDir();
|
|
2016
|
-
const { title, content, session } = req.body;
|
|
2132
|
+
const { title, content, session, tags, folder } = req.body;
|
|
2017
2133
|
if (!title) return res.status(400).json({ error: 'title required' });
|
|
2018
2134
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'note';
|
|
2019
2135
|
const date = new Date().toISOString();
|
|
2020
2136
|
const datePrefix = date.slice(0, 10);
|
|
2137
|
+
const safeFolder = folder ? folder.trim().replace(/[^a-zA-Z0-9_/-]/g, '-').replace(/^\/+|\/+$/g, '') : '';
|
|
2138
|
+
const noteDir = safeFolder ? path.join(NOTES_DIR, safeFolder) : NOTES_DIR;
|
|
2139
|
+
if (!noteDir.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid folder' });
|
|
2140
|
+
if (!fs.existsSync(noteDir)) fs.mkdirSync(noteDir, { recursive: true });
|
|
2021
2141
|
let filename = `${datePrefix}-${slug}.md`;
|
|
2022
|
-
// avoid collisions
|
|
2023
2142
|
let i = 1;
|
|
2024
|
-
while (fs.existsSync(path.join(
|
|
2143
|
+
while (fs.existsSync(path.join(noteDir, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
|
|
2144
|
+
const notepath = safeFolder ? `${safeFolder}/${filename}` : filename;
|
|
2025
2145
|
const sessionLine = session ? `\nsession: ${session}` : '';
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2146
|
+
const tagsArr = Array.isArray(tags) ? tags.filter(Boolean) : [];
|
|
2147
|
+
const tagsLine = tagsArr.length > 0 ? `\ntags: [${tagsArr.join(', ')}]` : '';
|
|
2148
|
+
const raw = `---\ntitle: ${title}\ndate: ${date}${sessionLine}${tagsLine}\n---\n\n${content || ''}`;
|
|
2149
|
+
fs.writeFileSync(path.join(NOTES_DIR, notepath), raw, 'utf8');
|
|
2150
|
+
res.json(parseNoteFile(notepath));
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
app.patch('/api/notes/*/move', (req, res) => {
|
|
2154
|
+
const notepath = req.params[0];
|
|
2155
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2156
|
+
const srcPath = path.join(NOTES_DIR, notepath);
|
|
2157
|
+
if (!srcPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2158
|
+
const { folder } = req.body;
|
|
2159
|
+
const filename = path.basename(notepath);
|
|
2160
|
+
const destDir = folder ? path.join(NOTES_DIR, folder) : NOTES_DIR;
|
|
2161
|
+
const destPath = path.join(destDir, filename);
|
|
2162
|
+
if (!destPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid destination' });
|
|
2163
|
+
try {
|
|
2164
|
+
if (!fs.existsSync(srcPath)) return res.status(404).json({ error: 'note not found' });
|
|
2165
|
+
if (destPath === srcPath) return res.json(parseNoteFile(notepath));
|
|
2166
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
2167
|
+
fs.renameSync(srcPath, destPath);
|
|
2168
|
+
const newRelPath = folder ? `${folder}/${filename}` : filename;
|
|
2169
|
+
res.json(parseNoteFile(newRelPath));
|
|
2170
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2029
2171
|
});
|
|
2030
2172
|
|
|
2031
|
-
app.put('/api/notes
|
|
2032
|
-
const
|
|
2033
|
-
if (!
|
|
2034
|
-
const filePath = path.join(NOTES_DIR,
|
|
2173
|
+
app.put('/api/notes/*', (req, res) => {
|
|
2174
|
+
const notepath = req.params[0];
|
|
2175
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2176
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2035
2177
|
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2036
|
-
const { title, content } = req.body;
|
|
2178
|
+
const { title, content, tags, pinned } = req.body;
|
|
2037
2179
|
try {
|
|
2038
|
-
const existing = parseNoteFile(
|
|
2180
|
+
const existing = parseNoteFile(notepath);
|
|
2181
|
+
const resolvedTags = Array.isArray(tags) ? tags : existing.tags;
|
|
2182
|
+
const resolvedPinned = pinned !== undefined ? pinned : existing.pinned;
|
|
2039
2183
|
const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
|
|
2040
|
-
const
|
|
2184
|
+
const tagsLine = resolvedTags.length > 0 ? `\ntags: [${resolvedTags.join(', ')}]` : '';
|
|
2185
|
+
const pinnedLine = resolvedPinned ? `\npinned: true` : '';
|
|
2186
|
+
const raw = `---\ntitle: ${title || existing.title}\ndate: ${existing.date}${sessionLine}${tagsLine}${pinnedLine}\n---\n\n${content ?? existing.content}`;
|
|
2041
2187
|
fs.writeFileSync(filePath, raw, 'utf8');
|
|
2042
|
-
res.json(parseNoteFile(
|
|
2188
|
+
res.json(parseNoteFile(notepath));
|
|
2043
2189
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2044
2190
|
});
|
|
2045
2191
|
|
|
2046
|
-
app.delete('/api/notes
|
|
2047
|
-
const
|
|
2048
|
-
if (!
|
|
2049
|
-
const filePath = path.join(NOTES_DIR,
|
|
2192
|
+
app.delete('/api/notes/*', (req, res) => {
|
|
2193
|
+
const notepath = req.params[0];
|
|
2194
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2195
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2050
2196
|
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2051
2197
|
try {
|
|
2052
2198
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|