claude-home 1.5.37 → 1.6.1
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 +12 -0
- package/package.json +1 -1
- package/public/index.html +512 -42
- package/server.js +248 -26
package/README.md
CHANGED
|
@@ -17,11 +17,15 @@ Claude Code is powerful but headless. Everything lives in files scattered across
|
|
|
17
17
|
### Sessions
|
|
18
18
|
Browse every conversation you've had with Claude. Search by content, filter by project or branch, resume from where you left off, export as Markdown, or publish as a public GitHub Gist. Direct shareable links (`#/session/...`) let you bookmark or share specific conversations.
|
|
19
19
|
|
|
20
|
+
**Session snapshot** — save a structured summary of any conversation as a note. Opens the **Export** menu and click *Snapshot → Note*. The note includes project/branch/date metadata, a numbered list of all your prompts, and an empty *Notes* section for your annotations. Tagged `snapshot` automatically with a backlink to the original session.
|
|
21
|
+
|
|
20
22
|
### Today
|
|
21
23
|
A daily task list that lives alongside your Claude work. Add tasks, provide context (hours available, meetings, energy), and hit **Copy for Claude** to get a formatted prompt ready to paste for prioritization. Uncompleted tasks carry over automatically to the next day.
|
|
22
24
|
|
|
23
25
|
Claude can add tasks directly from any session — just say "add this to today" or "remind me tomorrow to…"
|
|
24
26
|
|
|
27
|
+
Tasks created from notes show a **↗ Note title** chip that navigates directly to the source note.
|
|
28
|
+
|
|
25
29
|
### Notes
|
|
26
30
|
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
31
|
|
|
@@ -37,6 +41,14 @@ Direct links (`#/note/filename`) let you open a specific note instantly.
|
|
|
37
41
|
|
|
38
42
|
**Clipboard capture** — opening *New note* automatically pre-fills the content with whatever is in your clipboard.
|
|
39
43
|
|
|
44
|
+
**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 (✎).
|
|
45
|
+
|
|
46
|
+
**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.
|
|
47
|
+
|
|
48
|
+
**Pinned notes** — mark important notes with 📌 to keep them always at the top of the list, both in root and inside folders.
|
|
49
|
+
|
|
50
|
+
**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.
|
|
51
|
+
|
|
40
52
|
### Projects
|
|
41
53
|
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.
|
|
42
54
|
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -484,6 +484,96 @@
|
|
|
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-row:hover .note-folder-delete-btn { opacity: 1 !important; }
|
|
500
|
+
.note-folder-delete-btn:hover { color: var(--red) !important; }
|
|
501
|
+
.note-folder-icon { font-size: 8px; color: var(--ink-3); }
|
|
502
|
+
.note-folder-name { flex: 1; font-weight: 500; }
|
|
503
|
+
.note-folder-count { font-size: 10px; color: var(--ink-3); background: var(--canvas); border: 1px solid var(--rule); border-radius: 10px; padding: 0 5px; }
|
|
504
|
+
.note-folder-create-row { padding: 4px 10px 6px; }
|
|
505
|
+
.note-folder-add { font-size: 11px; color: var(--ink-3); cursor: pointer; }
|
|
506
|
+
.note-folder-add:hover { color: var(--blue); }
|
|
507
|
+
.note-folder-back {
|
|
508
|
+
display: flex;
|
|
509
|
+
align-items: center;
|
|
510
|
+
padding: 7px 12px;
|
|
511
|
+
font-size: 12px;
|
|
512
|
+
cursor: pointer;
|
|
513
|
+
border-bottom: 1px solid var(--rule);
|
|
514
|
+
}
|
|
515
|
+
.note-folder-back:hover { background: var(--canvas); }
|
|
516
|
+
|
|
517
|
+
/* Note tags */
|
|
518
|
+
.note-tag-filter {
|
|
519
|
+
display: flex;
|
|
520
|
+
flex-wrap: wrap;
|
|
521
|
+
gap: 4px;
|
|
522
|
+
padding: 6px 10px;
|
|
523
|
+
border-bottom: 1px solid var(--rule);
|
|
524
|
+
}
|
|
525
|
+
.note-tag-chip {
|
|
526
|
+
font-size: 10px;
|
|
527
|
+
padding: 2px 7px;
|
|
528
|
+
border-radius: 20px;
|
|
529
|
+
border: 1px solid var(--rule-2);
|
|
530
|
+
background: var(--canvas);
|
|
531
|
+
color: var(--ink-3);
|
|
532
|
+
cursor: pointer;
|
|
533
|
+
user-select: none;
|
|
534
|
+
}
|
|
535
|
+
.note-tag-chip:hover { border-color: var(--ink-3); color: var(--ink); }
|
|
536
|
+
.note-tag-chip.active { background: var(--ink); color: var(--white); border-color: var(--ink); }
|
|
537
|
+
.note-tag-mgmt-btn { font-size: 12px; color: var(--ink-3); cursor: pointer; padding: 0 2px; opacity: 0; transition: opacity .15s; line-height: 1; }
|
|
538
|
+
.note-tag-chip-wrap:hover .note-tag-mgmt-btn { opacity: 1; }
|
|
539
|
+
.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; }
|
|
540
|
+
.note-tag-menu-item { font-size: 12px; padding: 5px 12px; cursor: pointer; color: var(--ink-2); }
|
|
541
|
+
.note-tag-menu-item:hover { background: var(--canvas-2); color: var(--ink); }
|
|
542
|
+
.note-tag-menu-danger { color: var(--red) !important; }
|
|
543
|
+
.note-tag-menu-danger:hover { background: var(--red-dim, #3a1a1a) !important; }
|
|
544
|
+
.note-row-tags { display: flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
|
|
545
|
+
.note-row-tag {
|
|
546
|
+
font-size: 9px;
|
|
547
|
+
padding: 1px 5px;
|
|
548
|
+
border-radius: 20px;
|
|
549
|
+
background: color-mix(in srgb, var(--blue) 10%, transparent);
|
|
550
|
+
color: var(--blue);
|
|
551
|
+
cursor: pointer;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.md-content p, .md-content li { position: relative; }
|
|
555
|
+
.para-add-task-btn {
|
|
556
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
557
|
+
position: absolute; left: -22px; top: 2px;
|
|
558
|
+
width: 16px; height: 16px; border-radius: 50%;
|
|
559
|
+
background: var(--blue); color: #fff;
|
|
560
|
+
border: none; cursor: pointer; font-size: 12px; line-height: 1;
|
|
561
|
+
opacity: 0; transition: opacity .15s; padding: 0;
|
|
562
|
+
font-weight: 700;
|
|
563
|
+
}
|
|
564
|
+
.md-content p:hover .para-add-task-btn,
|
|
565
|
+
.md-content li:hover .para-add-task-btn { opacity: 1; }
|
|
566
|
+
.note-task-toast {
|
|
567
|
+
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
568
|
+
background: var(--ink); color: var(--white);
|
|
569
|
+
padding: 8px 16px; border-radius: 20px; font-size: 12px;
|
|
570
|
+
z-index: 300; pointer-events: none;
|
|
571
|
+
animation: toastIn .2s ease;
|
|
572
|
+
}
|
|
573
|
+
@keyframes toastIn { from { opacity:0; transform: translateX(-50%) translateY(8px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
|
|
574
|
+
.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; }
|
|
575
|
+
.note-row-tag:hover { background: color-mix(in srgb, var(--blue) 20%, transparent); }
|
|
576
|
+
|
|
487
577
|
/* Scratch row */
|
|
488
578
|
.scratch-row {
|
|
489
579
|
border-bottom: 1px solid var(--rule);
|
|
@@ -2362,7 +2452,7 @@
|
|
|
2362
2452
|
<button class="export-drop-item" @click="copySessionMd(); exportDropOpen=false">Copy as Markdown</button>
|
|
2363
2453
|
<button class="export-drop-item" @click="shareSession(); exportDropOpen=false" :disabled="!!shareMsg"><span x-text="shareMsg || 'Publish to Gist'"></span></button>
|
|
2364
2454
|
<button class="export-drop-item" @click="navigator.clipboard.writeText(location.origin+location.pathname+'#/session/'+selectedSession.projectDir+'/'+selectedSession.sessionId);exportDropOpen=false">Copy local link</button>
|
|
2365
|
-
<button class="export-drop-item" @click="
|
|
2455
|
+
<button class="export-drop-item" @click="snapshotSession();exportDropOpen=false" :disabled="snapshottingSession" x-text="snapshottingSession ? 'Saving…' : 'Snapshot → Note'"></button>
|
|
2366
2456
|
<div style="height:1px;background:var(--rule);margin:4px 0"></div>
|
|
2367
2457
|
<button class="export-drop-item" style="color:var(--red)" @click="deleteSession();exportDropOpen=false" x-show="!deletingSession">Delete session</button>
|
|
2368
2458
|
</div>
|
|
@@ -2651,7 +2741,10 @@
|
|
|
2651
2741
|
<template x-for="task in todayData.tasks.filter(t => t.carriedOver && !t.done)" :key="task.id">
|
|
2652
2742
|
<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">
|
|
2653
2743
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2654
|
-
<span
|
|
2744
|
+
<span style="flex:1;font-size:13px;color:var(--ink-2)">
|
|
2745
|
+
<span x-text="task.text"></span>
|
|
2746
|
+
<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>
|
|
2747
|
+
</span>
|
|
2655
2748
|
<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>
|
|
2656
2749
|
</div>
|
|
2657
2750
|
</template>
|
|
@@ -2662,7 +2755,10 @@
|
|
|
2662
2755
|
<template x-for="task in todayData.tasks.filter(t => !t.carriedOver && !t.done)" :key="task.id">
|
|
2663
2756
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule)">
|
|
2664
2757
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2665
|
-
<span
|
|
2758
|
+
<span style="flex:1;font-size:13px;color:var(--ink)">
|
|
2759
|
+
<span x-text="task.text"></span>
|
|
2760
|
+
<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>
|
|
2761
|
+
</span>
|
|
2666
2762
|
<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>
|
|
2667
2763
|
</div>
|
|
2668
2764
|
</template>
|
|
@@ -2674,7 +2770,10 @@
|
|
|
2674
2770
|
<template x-for="task in todayData.tasks.filter(t => t.done)" :key="task.id">
|
|
2675
2771
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule);opacity:0.5">
|
|
2676
2772
|
<input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
|
|
2677
|
-
<span
|
|
2773
|
+
<span style="flex:1;font-size:13px;text-decoration:line-through;color:var(--ink-3)">
|
|
2774
|
+
<span x-text="task.text"></span>
|
|
2775
|
+
<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>
|
|
2776
|
+
</span>
|
|
2678
2777
|
<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>
|
|
2679
2778
|
</div>
|
|
2680
2779
|
</template>
|
|
@@ -2774,7 +2873,16 @@
|
|
|
2774
2873
|
</template>
|
|
2775
2874
|
<template x-if="selectedNote && !noteEditing">
|
|
2776
2875
|
<div style="display:flex;align-items:center;gap:8px">
|
|
2777
|
-
<button class="btn btn-sm btn-outline"
|
|
2876
|
+
<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>
|
|
2877
|
+
<button class="btn btn-sm btn-outline" @click="addNoteTask(selectedNote.title, selectedNote)" title="Add note title as Today task">+ Today</button>
|
|
2878
|
+
<button class="btn btn-sm btn-outline" @click="noteEditing=true;noteTitleDraft=selectedNote.title;noteBodyDraft=selectedNote.content;noteTagsDraft=(selectedNote.tags||[]).join(', ')">Edit</button>
|
|
2879
|
+
<select class="filter-select" style="font-size:12px" @change="movePersonalNote($event.target.value);$event.target.value='__current__'" x-init="">
|
|
2880
|
+
<option value="__current__">Move to…</option>
|
|
2881
|
+
<option value="">/ root</option>
|
|
2882
|
+
<template x-for="f in noteAllFolders().filter(f=>f!==(selectedNote.folder??''))" :key="f">
|
|
2883
|
+
<option :value="f" x-text="'/ '+f"></option>
|
|
2884
|
+
</template>
|
|
2885
|
+
</select>
|
|
2778
2886
|
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePersonalNote()">Delete</button>
|
|
2779
2887
|
</div>
|
|
2780
2888
|
</template>
|
|
@@ -2787,7 +2895,16 @@
|
|
|
2787
2895
|
<div style="padding:16px 20px;border-bottom:1px solid var(--rule);background:var(--canvas-2)">
|
|
2788
2896
|
<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())" />
|
|
2789
2897
|
<div style="position:relative">
|
|
2790
|
-
<
|
|
2898
|
+
<div style="display:flex;gap:8px;margin-bottom:8px">
|
|
2899
|
+
<input class="settings-input" type="text" placeholder="Tags (bug, idea, trabajo)…" x-model="noteNewTags" style="flex:1;font-size:12px" />
|
|
2900
|
+
<select class="filter-select" x-model="noteNewFolder" style="font-size:12px;min-width:100px">
|
|
2901
|
+
<option value="">/ root</option>
|
|
2902
|
+
<template x-for="f in noteAllFolders()" :key="f">
|
|
2903
|
+
<option :value="f" x-text="'/ '+f"></option>
|
|
2904
|
+
</template>
|
|
2905
|
+
</select>
|
|
2906
|
+
</div>
|
|
2907
|
+
<textarea class="settings-input" placeholder="Content (optional)…" x-model="noteNewBody" rows="3" style="width:100%;resize:vertical;font-size:12px;font-family:inherit"></textarea>
|
|
2791
2908
|
<template x-if="noteFromClipboard">
|
|
2792
2909
|
<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>
|
|
2793
2910
|
</template>
|
|
@@ -2825,16 +2942,116 @@
|
|
|
2825
2942
|
</div>
|
|
2826
2943
|
<div x-show="scratchContent.length > 0" style="font-size:10px;color:var(--ink-3)" x-text="scratchContent.slice(0,40) + (scratchContent.length>40?'…':'')"></div>
|
|
2827
2944
|
</div>
|
|
2828
|
-
<
|
|
2829
|
-
|
|
2945
|
+
<div style="border-top:1px solid var(--rule)"></div>
|
|
2946
|
+
<!-- Folder nav: at root show folders + create; inside folder show back -->
|
|
2947
|
+
<template x-if="noteFolderPath === ''">
|
|
2948
|
+
<div>
|
|
2949
|
+
<template x-for="f in noteAllFolders()" :key="f">
|
|
2950
|
+
<div class="note-folder-row" @click="if(noteRenamingFolder!==f)noteFolderPath=f;selectedNote=null;scratchActive=false">
|
|
2951
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2952
|
+
<span class="note-folder-icon">▶</span>
|
|
2953
|
+
</template>
|
|
2954
|
+
<template x-if="noteRenamingFolder === f">
|
|
2955
|
+
<input class="settings-input" style="flex:1;font-size:11px;padding:2px 5px;margin-right:4px" x-model="noteRenameFolderDraft"
|
|
2956
|
+
@click.stop
|
|
2957
|
+
x-init="$nextTick(()=>$el.focus())"
|
|
2958
|
+
@keydown.enter.stop="renameFolderConfirm(f)"
|
|
2959
|
+
@keydown.escape.stop="noteRenamingFolder=null;noteRenameFolderDraft=''" />
|
|
2960
|
+
</template>
|
|
2961
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2962
|
+
<span class="note-folder-name" x-text="f"></span>
|
|
2963
|
+
</template>
|
|
2964
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2965
|
+
<span class="note-folder-count" x-text="personalNotes.filter(n=>(n.folder??'')===f).length"></span>
|
|
2966
|
+
</template>
|
|
2967
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2968
|
+
<span style="font-size:10px;color:var(--ink-3);padding:0 2px;opacity:0;transition:opacity .15s" class="note-folder-rename-btn"
|
|
2969
|
+
@click.stop="noteRenamingFolder=f;noteRenameFolderDraft=f" title="Rename">✎</span>
|
|
2970
|
+
</template>
|
|
2971
|
+
<template x-if="noteRenamingFolder !== f">
|
|
2972
|
+
<span style="font-size:10px;color:var(--ink-3);padding:0 1px;opacity:0;transition:opacity .15s" class="note-folder-delete-btn"
|
|
2973
|
+
@click.stop="deletingFolder=f" title="Delete folder">✕</span>
|
|
2974
|
+
</template>
|
|
2975
|
+
<template x-if="noteRenamingFolder === f">
|
|
2976
|
+
<button class="btn btn-sm btn-primary" style="padding:1px 7px;font-size:11px" @click.stop="renameFolderConfirm(f)">OK</button>
|
|
2977
|
+
</template>
|
|
2978
|
+
</div>
|
|
2979
|
+
</template>
|
|
2980
|
+
<div class="note-folder-create-row">
|
|
2981
|
+
<template x-if="!noteCreatingFolder">
|
|
2982
|
+
<span class="note-folder-add" @click="noteCreatingFolder=true">+ New folder</span>
|
|
2983
|
+
</template>
|
|
2984
|
+
<template x-if="noteCreatingFolder">
|
|
2985
|
+
<div style="display:flex;gap:4px;padding:4px 10px">
|
|
2986
|
+
<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=''" />
|
|
2987
|
+
<button class="btn btn-sm btn-primary" style="padding:2px 8px;font-size:11px" @click="createFolder()">Create</button>
|
|
2988
|
+
</div>
|
|
2989
|
+
</template>
|
|
2990
|
+
</div>
|
|
2991
|
+
<template x-if="noteAllFolders().length > 0">
|
|
2992
|
+
<div style="border-top:1px solid var(--rule);margin:2px 0"></div>
|
|
2993
|
+
</template>
|
|
2994
|
+
</div>
|
|
2995
|
+
</template>
|
|
2996
|
+
<template x-if="noteFolderPath !== ''">
|
|
2997
|
+
<div class="note-folder-back" @click="noteFolderPath='';selectedNote=null">
|
|
2998
|
+
<span style="color:var(--blue)">← All notes</span>
|
|
2999
|
+
<span style="color:var(--ink-3)"> / </span>
|
|
3000
|
+
<span style="font-weight:500" x-text="noteFolderPath"></span>
|
|
3001
|
+
</div>
|
|
3002
|
+
</template>
|
|
3003
|
+
<!-- Tag filter (scoped to current folder) -->
|
|
3004
|
+
<template x-if="noteAllTags().length > 0">
|
|
3005
|
+
<div class="note-tag-filter" @click.window="noteTagMenu=null;noteTagRenaming=false">
|
|
3006
|
+
<template x-for="t in noteAllTags()" :key="t.tag">
|
|
3007
|
+
<span class="note-tag-chip-wrap" style="position:relative;display:inline-flex;align-items:center">
|
|
3008
|
+
<span class="note-tag-chip" :class="{active: noteTagFilter.includes(t.tag)}"
|
|
3009
|
+
@click="noteTagFilter.includes(t.tag) ? noteTagFilter.splice(noteTagFilter.indexOf(t.tag),1) : noteTagFilter.push(t.tag)"
|
|
3010
|
+
x-text="t.tag + ' (' + t.count + ')'"></span>
|
|
3011
|
+
<span class="note-tag-mgmt-btn" @click.stop="noteTagMenu===t.tag ? noteTagMenu=null : (noteTagMenu=t.tag, noteTagRenameDraft=t.tag)" title="Manage tag">⋮</span>
|
|
3012
|
+
<template x-if="noteTagMenu === t.tag">
|
|
3013
|
+
<div class="note-tag-menu" @click.stop>
|
|
3014
|
+
<template x-if="!noteTagRenaming">
|
|
3015
|
+
<div>
|
|
3016
|
+
<div class="note-tag-menu-item" @click="noteTagRenaming=true;$nextTick(()=>$refs['tagRenameInput_'+t.tag]?.focus())">Rename…</div>
|
|
3017
|
+
<div class="note-tag-menu-item note-tag-menu-danger" @click="deleteTag(t.tag)">Delete from all notes</div>
|
|
3018
|
+
</div>
|
|
3019
|
+
</template>
|
|
3020
|
+
<template x-if="noteTagRenaming">
|
|
3021
|
+
<div style="display:flex;gap:4px;padding:2px">
|
|
3022
|
+
<input class="settings-input" style="font-size:11px;padding:2px 5px;width:100px"
|
|
3023
|
+
x-model="noteTagRenameDraft"
|
|
3024
|
+
:ref="'tagRenameInput_'+t.tag"
|
|
3025
|
+
x-init="$nextTick(()=>$el.focus())"
|
|
3026
|
+
@keydown.enter="renameTag(t.tag, noteTagRenameDraft)"
|
|
3027
|
+
@keydown.escape="noteTagRenaming=false" />
|
|
3028
|
+
<button class="btn btn-sm btn-primary" style="padding:1px 6px;font-size:11px" @click="renameTag(t.tag, noteTagRenameDraft)">OK</button>
|
|
3029
|
+
</div>
|
|
3030
|
+
</template>
|
|
3031
|
+
</div>
|
|
3032
|
+
</template>
|
|
3033
|
+
</span>
|
|
3034
|
+
</template>
|
|
3035
|
+
</div>
|
|
2830
3036
|
</template>
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
3037
|
+
<!-- Note list (scoped to current folder) -->
|
|
3038
|
+
<template x-for="n in noteFolderNotes()" :key="n.path">
|
|
3039
|
+
<div class="plan-row" :class="{active: selectedNote?.path === n.path}" @click="selectedNote=n;scratchActive=false;noteEditing=false;window.location.hash='#/note/'+n.path"
|
|
3040
|
+
<div class="plan-row-title" style="display:flex;align-items:center;gap:4px">
|
|
3041
|
+
<template x-if="n.pinned"><span style="font-size:10px;line-height:1" title="Pinned">📌</span></template>
|
|
3042
|
+
<span x-text="n.title"></span>
|
|
3043
|
+
</div>
|
|
2834
3044
|
<div class="plan-row-meta">
|
|
2835
3045
|
<span x-text="formatDate(n.modified)"></span>
|
|
2836
|
-
<template x-if="n.session"><span style="color:var(--blue);font-size:10px">session</span></template>
|
|
3046
|
+
<template x-if="n.session && n.session.length>=32"><span style="color:var(--blue);font-size:10px">session</span></template>
|
|
2837
3047
|
</div>
|
|
3048
|
+
<template x-if="(n.tags||[]).length > 0">
|
|
3049
|
+
<div class="note-row-tags">
|
|
3050
|
+
<template x-for="tag in n.tags" :key="tag">
|
|
3051
|
+
<span class="note-row-tag" x-text="tag" @click.stop="noteTagFilter.includes(tag) ? noteTagFilter.splice(noteTagFilter.indexOf(tag),1) : noteTagFilter.push(tag)"></span>
|
|
3052
|
+
</template>
|
|
3053
|
+
</div>
|
|
3054
|
+
</template>
|
|
2838
3055
|
</div>
|
|
2839
3056
|
</template>
|
|
2840
3057
|
</div>
|
|
@@ -2857,14 +3074,24 @@
|
|
|
2857
3074
|
<div style="padding:24px;max-width:380px">
|
|
2858
3075
|
<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:var(--ink)">Your personal notepad</div>
|
|
2859
3076
|
<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>
|
|
2860
|
-
<div style="font-size:11px;font-weight:600;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:
|
|
2861
|
-
<div style="display:flex;flex-direction:column;gap:
|
|
3077
|
+
<div style="font-size:11px;font-weight:600;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Capture</div>
|
|
3078
|
+
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
|
2862
3079
|
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Save this as a note"</code>
|
|
2863
|
-
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Save this
|
|
3080
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Save this as a TIL in my notes"</code>
|
|
2864
3081
|
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Add this decision to my notes"</code>
|
|
2865
|
-
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Save this
|
|
3082
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Save this in my <em>project</em> notes folder"</code>
|
|
3083
|
+
</div>
|
|
3084
|
+
<div style="font-size:11px;font-weight:600;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Organise</div>
|
|
3085
|
+
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
|
|
3086
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Tag my last note as <em>bug</em> and <em>auth</em>"</code>
|
|
3087
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Move the note about X to the <em>project</em> folder"</code>
|
|
3088
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Pin my runbook note"</code>
|
|
3089
|
+
</div>
|
|
3090
|
+
<div style="font-size:11px;font-weight:600;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Retrieve & act</div>
|
|
3091
|
+
<div style="display:flex;flex-direction:column;gap:5px">
|
|
2866
3092
|
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Show me my last note"</code>
|
|
2867
|
-
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"What notes do I have
|
|
3093
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"What notes do I have tagged <em>bug</em>?"</code>
|
|
3094
|
+
<code style="font-size:11px;background:var(--canvas-2);border:1px solid var(--rule);padding:3px 7px;border-radius:4px;display:block">"Add the first step of my deploy note to today's tasks"</code>
|
|
2868
3095
|
</div>
|
|
2869
3096
|
</div>
|
|
2870
3097
|
</template>
|
|
@@ -2877,12 +3104,19 @@
|
|
|
2877
3104
|
<span style="font-size:11px;color:var(--blue);cursor:pointer" @click="openSessionById(selectedNote.session)" x-text="'→ Session ' + selectedNote.session.slice(0,8) + '…'"></span>
|
|
2878
3105
|
</template>
|
|
2879
3106
|
</div>
|
|
3107
|
+
<template x-if="(selectedNote.tags||[]).length > 0">
|
|
3108
|
+
<div style="display:flex;gap:5px;flex-wrap:wrap;padding:0 20px 10px">
|
|
3109
|
+
<template x-for="tag in selectedNote.tags" :key="tag">
|
|
3110
|
+
<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>
|
|
3111
|
+
</template>
|
|
3112
|
+
</div>
|
|
3113
|
+
</template>
|
|
2880
3114
|
<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>
|
|
2881
3115
|
<template x-if="noteBacklinks(selectedNote).length > 0">
|
|
2882
3116
|
<div class="note-backlinks">
|
|
2883
3117
|
<span class="note-backlinks-label">Referenced by</span>
|
|
2884
3118
|
<template x-for="bl in noteBacklinks(selectedNote)" :key="bl.filename">
|
|
2885
|
-
<span class="note-backlink-chip" @click="selectedNote=bl;noteEditing=false;window.location.hash='#/note/'+bl.
|
|
3119
|
+
<span class="note-backlink-chip" @click="selectedNote=bl;noteEditing=false;window.location.hash='#/note/'+bl.path" x-text="bl.title"></span>
|
|
2886
3120
|
</template>
|
|
2887
3121
|
</div>
|
|
2888
3122
|
</template>
|
|
@@ -2890,7 +3124,7 @@
|
|
|
2890
3124
|
<div class="note-backlinks">
|
|
2891
3125
|
<span class="note-backlinks-label">Related</span>
|
|
2892
3126
|
<template x-for="rn in noteRelated(selectedNote)" :key="rn.filename">
|
|
2893
|
-
<span class="note-backlink-chip note-related-chip" @click="selectedNote=rn;noteEditing=false;window.location.hash='#/note/'+rn.
|
|
3127
|
+
<span class="note-backlink-chip note-related-chip" @click="selectedNote=rn;noteEditing=false;window.location.hash='#/note/'+rn.path" x-text="rn.title"></span>
|
|
2894
3128
|
</template>
|
|
2895
3129
|
</div>
|
|
2896
3130
|
</template>
|
|
@@ -2899,6 +3133,7 @@
|
|
|
2899
3133
|
<template x-if="selectedNote && noteEditing">
|
|
2900
3134
|
<div style="display:flex;flex-direction:column;height:100%;gap:10px;position:relative">
|
|
2901
3135
|
<input class="settings-input" type="text" x-model="noteTitleDraft" style="font-size:14px;font-weight:600;width:100%" />
|
|
3136
|
+
<input class="settings-input" type="text" x-model="noteTagsDraft" placeholder="Tags (comma separated: bug, idea, trabajo)…" style="font-size:12px;width:100%" />
|
|
2902
3137
|
<div style="position:relative;flex:1;display:flex;flex-direction:column">
|
|
2903
3138
|
<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>
|
|
2904
3139
|
<template x-if="noteAutocomplete.visible">
|
|
@@ -4465,6 +4700,24 @@
|
|
|
4465
4700
|
|
|
4466
4701
|
</div>
|
|
4467
4702
|
|
|
4703
|
+
<!-- Delete folder modal -->
|
|
4704
|
+
<template x-if="deletingFolder !== null">
|
|
4705
|
+
<div class="memory-new-modal" @click.self="deletingFolder=null">
|
|
4706
|
+
<div class="memory-new-form" style="width:360px">
|
|
4707
|
+
<h3>Borrar carpeta "<span x-text="deletingFolder"></span>"</h3>
|
|
4708
|
+
<p style="font-size:12px;color:var(--ink-2);line-height:1.5;margin-bottom:20px">
|
|
4709
|
+
Contiene <strong x-text="personalNotes.filter(n=>(n.folder??'')===deletingFolder).length"></strong> nota(s).
|
|
4710
|
+
¿Borras también las notas o las dejas sueltas (sin carpeta)?
|
|
4711
|
+
</p>
|
|
4712
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
4713
|
+
<button class="btn btn-sm btn-outline" @click="deletingFolder=null">Cancelar</button>
|
|
4714
|
+
<button class="btn btn-sm" style="background:var(--canvas-2,#f5f5f5);border:1px solid var(--rule);color:var(--ink)" @click="deleteFolderConfirm('orphan')">Soltar notas</button>
|
|
4715
|
+
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deleteFolderConfirm('delete')">Borrar todo</button>
|
|
4716
|
+
</div>
|
|
4717
|
+
</div>
|
|
4718
|
+
</div>
|
|
4719
|
+
</template>
|
|
4720
|
+
|
|
4468
4721
|
<!-- New Memory modal -->
|
|
4469
4722
|
<template x-if="memoryNewModal">
|
|
4470
4723
|
<div class="memory-new-modal" @click.self="memoryNewModal=false">
|
|
@@ -4560,6 +4813,7 @@
|
|
|
4560
4813
|
selectedPlan: null,
|
|
4561
4814
|
plansSearch: '',
|
|
4562
4815
|
deletingSession: false,
|
|
4816
|
+
snapshottingSession: false,
|
|
4563
4817
|
exportDropOpen: false,
|
|
4564
4818
|
exportMsg: '',
|
|
4565
4819
|
cleanMode: true,
|
|
@@ -4590,6 +4844,20 @@
|
|
|
4590
4844
|
noteFromClipboard: false,
|
|
4591
4845
|
scratchContent: sessionStorage.getItem('cs:scratch') || '',
|
|
4592
4846
|
scratchActive: false,
|
|
4847
|
+
noteTagFilter: [],
|
|
4848
|
+
noteTagsDraft: '',
|
|
4849
|
+
noteNewTags: '',
|
|
4850
|
+
noteFolderPath: '',
|
|
4851
|
+
noteNewFolder: '',
|
|
4852
|
+
noteCreatingFolder: false,
|
|
4853
|
+
newFolderName: '',
|
|
4854
|
+
noteFolders: [],
|
|
4855
|
+
noteRenamingFolder: null,
|
|
4856
|
+
noteRenameFolderDraft: '',
|
|
4857
|
+
deletingFolder: null,
|
|
4858
|
+
noteTagMenu: null,
|
|
4859
|
+
noteTagRenaming: false,
|
|
4860
|
+
noteTagRenameDraft: '',
|
|
4593
4861
|
sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
|
|
4594
4862
|
historyEntries: [],
|
|
4595
4863
|
historyLoading: false,
|
|
@@ -4774,7 +5042,7 @@
|
|
|
4774
5042
|
if (mNote) {
|
|
4775
5043
|
const notes = await fetch('/api/notes').then(r => r.json()).catch(() => []);
|
|
4776
5044
|
this.personalNotes = notes;
|
|
4777
|
-
const note = this.personalNotes.find(n => n.
|
|
5045
|
+
const note = this.personalNotes.find(n => n.path === mNote[1]);
|
|
4778
5046
|
if (note) { this.view = 'notes'; this.selectedNote = note; }
|
|
4779
5047
|
}
|
|
4780
5048
|
// Clear hash when closing
|
|
@@ -5167,22 +5435,166 @@
|
|
|
5167
5435
|
return slugPart === slug || base === slug;
|
|
5168
5436
|
});
|
|
5169
5437
|
if (!note) return match;
|
|
5170
|
-
return `${pre}[#${slug}](#/note/${note.
|
|
5438
|
+
return `${pre}[#${slug}](#/note/${note.path})`;
|
|
5171
5439
|
});
|
|
5172
5440
|
let html;
|
|
5173
5441
|
try { html = marked.parse(processed); } catch { html = processed; }
|
|
5174
5442
|
// Mark note-ref links so we can style/intercept them
|
|
5175
5443
|
html = html.replace(/href="#\/note\//g, 'class="note-ref-link" href="#/note/');
|
|
5444
|
+
// Inject + buttons into paragraphs and list items
|
|
5445
|
+
const btn = '<button class="para-add-task-btn" title="Add to Today">+</button>';
|
|
5446
|
+
html = html.replace(/<p>/g, `<p>${btn}`);
|
|
5447
|
+
html = html.replace(/<li>/g, `<li>${btn}`);
|
|
5176
5448
|
return html;
|
|
5177
5449
|
},
|
|
5178
5450
|
|
|
5179
5451
|
handleNoteLinkClick(e) {
|
|
5452
|
+
const btn = e.target.closest('.para-add-task-btn');
|
|
5453
|
+
if (btn) {
|
|
5454
|
+
e.preventDefault();
|
|
5455
|
+
const block = btn.closest('p,li');
|
|
5456
|
+
const text = block ? block.innerText.replace('+', '').trim() : '';
|
|
5457
|
+
if (text) this.addNoteTask(text, this.selectedNote || this.noteHovered);
|
|
5458
|
+
return;
|
|
5459
|
+
}
|
|
5180
5460
|
const a = e.target.closest('a.note-ref-link');
|
|
5181
5461
|
if (!a) return;
|
|
5182
5462
|
e.preventDefault();
|
|
5183
|
-
const
|
|
5184
|
-
const note = this.personalNotes.find(n => n.
|
|
5185
|
-
if (note) { this.selectedNote = note; this.noteEditing = false; window.location.hash = '#/note/' +
|
|
5463
|
+
const notepath = a.getAttribute('href').replace('#/note/', '');
|
|
5464
|
+
const note = this.personalNotes.find(n => n.path === notepath);
|
|
5465
|
+
if (note) { this.selectedNote = note; this.noteEditing = false; window.location.hash = '#/note/' + note.path; }
|
|
5466
|
+
},
|
|
5467
|
+
|
|
5468
|
+
noteAllFolders() {
|
|
5469
|
+
const fromNotes = this.personalNotes.map(n => n.folder ?? '').filter(Boolean);
|
|
5470
|
+
return [...new Set([...this.noteFolders, ...fromNotes])].sort();
|
|
5471
|
+
},
|
|
5472
|
+
|
|
5473
|
+
noteFolderNotes() {
|
|
5474
|
+
const filtered = this.personalNotes.filter(n => {
|
|
5475
|
+
if ((n.folder ?? '') !== this.noteFolderPath) return false;
|
|
5476
|
+
if (this.noteTagFilter.length > 0 && !this.noteTagFilter.some(t => (n.tags||[]).includes(t))) return false;
|
|
5477
|
+
if (!this.noteSearch) return true;
|
|
5478
|
+
return n.title.toLowerCase().includes(this.noteSearch.toLowerCase()) || (n.content||'').toLowerCase().includes(this.noteSearch.toLowerCase());
|
|
5479
|
+
});
|
|
5480
|
+
return filtered.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
|
5481
|
+
},
|
|
5482
|
+
|
|
5483
|
+
async createFolder() {
|
|
5484
|
+
if (!this.newFolderName.trim()) return;
|
|
5485
|
+
const res = await fetch('/api/notes/folders', {
|
|
5486
|
+
method: 'POST',
|
|
5487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5488
|
+
body: JSON.stringify({ name: this.newFolderName.trim() }),
|
|
5489
|
+
}).then(r => r.json()).catch(() => null);
|
|
5490
|
+
if (res?.name && !this.noteFolders.includes(res.name)) {
|
|
5491
|
+
this.noteFolders.push(res.name);
|
|
5492
|
+
this.noteFolders.sort();
|
|
5493
|
+
}
|
|
5494
|
+
this.noteCreatingFolder = false;
|
|
5495
|
+
this.newFolderName = '';
|
|
5496
|
+
},
|
|
5497
|
+
|
|
5498
|
+
async renameFolderConfirm(oldName) {
|
|
5499
|
+
const newName = this.noteRenameFolderDraft.trim();
|
|
5500
|
+
if (!newName || newName === oldName) { this.noteRenamingFolder = null; return; }
|
|
5501
|
+
const res = await fetch(`/api/notes/folders/${encodeURIComponent(oldName)}/rename`, {
|
|
5502
|
+
method: 'PATCH',
|
|
5503
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5504
|
+
body: JSON.stringify({ newName }),
|
|
5505
|
+
}).then(r => r.json()).catch(() => null);
|
|
5506
|
+
if (!res?.name) { alert(res?.error || 'Rename failed'); return; }
|
|
5507
|
+
// Update noteFolders list
|
|
5508
|
+
const idx = this.noteFolders.indexOf(oldName);
|
|
5509
|
+
if (idx !== -1) this.noteFolders.splice(idx, 1, res.name);
|
|
5510
|
+
else { this.noteFolders.push(res.name); }
|
|
5511
|
+
this.noteFolders.sort();
|
|
5512
|
+
// Update folder field in all affected notes
|
|
5513
|
+
for (const n of this.personalNotes) {
|
|
5514
|
+
if ((n.folder ?? '') === oldName) {
|
|
5515
|
+
n.folder = res.name;
|
|
5516
|
+
n.path = res.name + '/' + n.filename;
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
if (this.noteFolderPath === oldName) this.noteFolderPath = res.name;
|
|
5520
|
+
if (this.selectedNote?.folder === oldName) {
|
|
5521
|
+
this.selectedNote.folder = res.name;
|
|
5522
|
+
this.selectedNote.path = res.name + '/' + this.selectedNote.filename;
|
|
5523
|
+
}
|
|
5524
|
+
this.noteRenamingFolder = null;
|
|
5525
|
+
this.noteRenameFolderDraft = '';
|
|
5526
|
+
},
|
|
5527
|
+
|
|
5528
|
+
async deleteFolderConfirm(action) {
|
|
5529
|
+
const folder = this.deletingFolder;
|
|
5530
|
+
if (!folder) return;
|
|
5531
|
+
const res = await fetch(`/api/notes/folders/${encodeURIComponent(folder)}?action=${action}`, {
|
|
5532
|
+
method: 'DELETE',
|
|
5533
|
+
}).then(r => r.json()).catch(() => null);
|
|
5534
|
+
if (!res?.ok) { alert(res?.error || 'Error al borrar la carpeta'); return; }
|
|
5535
|
+
this.noteFolders = this.noteFolders.filter(f => f !== folder);
|
|
5536
|
+
if (action === 'orphan') {
|
|
5537
|
+
for (const n of this.personalNotes) {
|
|
5538
|
+
if ((n.folder ?? '') === folder) { n.folder = ''; n.path = n.filename; }
|
|
5539
|
+
}
|
|
5540
|
+
if (this.selectedNote?.folder === folder) {
|
|
5541
|
+
this.selectedNote.folder = ''; this.selectedNote.path = this.selectedNote.filename;
|
|
5542
|
+
}
|
|
5543
|
+
} else {
|
|
5544
|
+
this.personalNotes = this.personalNotes.filter(n => (n.folder ?? '') !== folder);
|
|
5545
|
+
if (this.selectedNote?.folder === folder) { this.selectedNote = null; this.noteEditing = false; }
|
|
5546
|
+
}
|
|
5547
|
+
if (this.noteFolderPath === folder) { this.noteFolderPath = ''; this.selectedNote = null; }
|
|
5548
|
+
this.deletingFolder = null;
|
|
5549
|
+
},
|
|
5550
|
+
|
|
5551
|
+
noteAllTags() {
|
|
5552
|
+
const counts = {};
|
|
5553
|
+
const scoped = this.personalNotes.filter(n => (n.folder ?? '') === this.noteFolderPath);
|
|
5554
|
+
for (const n of scoped) {
|
|
5555
|
+
for (const t of (n.tags || [])) counts[t] = (counts[t] || 0) + 1;
|
|
5556
|
+
}
|
|
5557
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([tag, count]) => ({ tag, count }));
|
|
5558
|
+
},
|
|
5559
|
+
|
|
5560
|
+
async renameTag(oldName, newName) {
|
|
5561
|
+
newName = newName.trim();
|
|
5562
|
+
if (!newName || newName === oldName) { this.noteTagMenu = null; this.noteTagRenaming = false; return; }
|
|
5563
|
+
const res = await fetch(`/api/notes/tags/${encodeURIComponent(oldName)}/rename`, {
|
|
5564
|
+
method: 'PATCH',
|
|
5565
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5566
|
+
body: JSON.stringify({ newName }),
|
|
5567
|
+
}).then(r => r.json()).catch(() => null);
|
|
5568
|
+
if (!res || res.error) { alert(res?.error || 'Rename failed'); return; }
|
|
5569
|
+
for (const n of this.personalNotes) {
|
|
5570
|
+
if ((n.tags || []).includes(oldName)) {
|
|
5571
|
+
n.tags = n.tags.map(t => t === oldName ? res.to : t);
|
|
5572
|
+
}
|
|
5573
|
+
}
|
|
5574
|
+
if (this.noteTagFilter.includes(oldName)) {
|
|
5575
|
+
this.noteTagFilter.splice(this.noteTagFilter.indexOf(oldName), 1, res.to);
|
|
5576
|
+
}
|
|
5577
|
+
if (this.selectedNote?.tags?.includes(oldName)) {
|
|
5578
|
+
this.selectedNote.tags = this.selectedNote.tags.map(t => t === oldName ? res.to : t);
|
|
5579
|
+
}
|
|
5580
|
+
this.noteTagMenu = null;
|
|
5581
|
+
this.noteTagRenaming = false;
|
|
5582
|
+
},
|
|
5583
|
+
|
|
5584
|
+
async deleteTag(name) {
|
|
5585
|
+
if (!confirm(`Remove tag "${name}" from all notes?`)) return;
|
|
5586
|
+
const res = await fetch(`/api/notes/tags/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
|
5587
|
+
.then(r => r.json()).catch(() => null);
|
|
5588
|
+
if (!res || res.error) { alert(res?.error || 'Delete failed'); return; }
|
|
5589
|
+
for (const n of this.personalNotes) {
|
|
5590
|
+
if ((n.tags || []).includes(name)) n.tags = n.tags.filter(t => t !== name);
|
|
5591
|
+
}
|
|
5592
|
+
const fi = this.noteTagFilter.indexOf(name);
|
|
5593
|
+
if (fi !== -1) this.noteTagFilter.splice(fi, 1);
|
|
5594
|
+
if (this.selectedNote?.tags?.includes(name)) {
|
|
5595
|
+
this.selectedNote.tags = this.selectedNote.tags.filter(t => t !== name);
|
|
5596
|
+
}
|
|
5597
|
+
this.noteTagMenu = null;
|
|
5186
5598
|
},
|
|
5187
5599
|
|
|
5188
5600
|
noteTokens(note) {
|
|
@@ -5751,6 +6163,8 @@
|
|
|
5751
6163
|
async openNewNote() {
|
|
5752
6164
|
this.noteNewTitle = '';
|
|
5753
6165
|
this.noteNewBody = '';
|
|
6166
|
+
this.noteNewTags = '';
|
|
6167
|
+
this.noteNewFolder = this.noteFolderPath;
|
|
5754
6168
|
this.noteFromClipboard = false;
|
|
5755
6169
|
this.noteCreating = true;
|
|
5756
6170
|
try {
|
|
@@ -5773,12 +6187,14 @@
|
|
|
5773
6187
|
async loadPersonalNotes() {
|
|
5774
6188
|
if (this.personalNotesLoading) return;
|
|
5775
6189
|
this.personalNotesLoading = true;
|
|
5776
|
-
const [notes, status] = await Promise.all([
|
|
6190
|
+
const [notes, status, folders] = await Promise.all([
|
|
5777
6191
|
fetch('/api/notes').then(r => r.json()).catch(() => []),
|
|
5778
6192
|
fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({})),
|
|
6193
|
+
fetch('/api/notes/folders').then(r => r.json()).catch(() => []),
|
|
5779
6194
|
]);
|
|
5780
6195
|
this.personalNotes = notes;
|
|
5781
6196
|
this.noteClaudeInstalled = status.installed ?? null;
|
|
6197
|
+
this.noteFolders = Array.isArray(folders) ? folders : [];
|
|
5782
6198
|
this.personalNotesLoading = false;
|
|
5783
6199
|
},
|
|
5784
6200
|
|
|
@@ -5805,13 +6221,14 @@
|
|
|
5805
6221
|
const note = await fetch('/api/notes', {
|
|
5806
6222
|
method: 'POST',
|
|
5807
6223
|
headers: { 'Content-Type': 'application/json' },
|
|
5808
|
-
body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody }),
|
|
6224
|
+
body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody, tags: this.noteNewTags ? this.noteNewTags.split(',').map(t=>t.trim()).filter(Boolean) : [], folder: this.noteNewFolder }),
|
|
5809
6225
|
}).then(r => r.json());
|
|
5810
6226
|
this.personalNotes.unshift(note);
|
|
5811
6227
|
this.selectedNote = note;
|
|
5812
6228
|
this.noteCreating = false;
|
|
5813
6229
|
this.noteNewTitle = '';
|
|
5814
6230
|
this.noteNewBody = '';
|
|
6231
|
+
this.noteNewTags = '';
|
|
5815
6232
|
this.noteEditing = false;
|
|
5816
6233
|
} catch (e) { this.noteMsg = 'Error: ' + e.message; }
|
|
5817
6234
|
this.noteSaving = false;
|
|
@@ -5821,10 +6238,10 @@
|
|
|
5821
6238
|
if (!this.selectedNote) return;
|
|
5822
6239
|
this.noteSaving = true; this.noteMsg = '';
|
|
5823
6240
|
try {
|
|
5824
|
-
const updated = await fetch(`/api/notes/${this.selectedNote.
|
|
6241
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}`, {
|
|
5825
6242
|
method: 'PUT',
|
|
5826
6243
|
headers: { 'Content-Type': 'application/json' },
|
|
5827
|
-
body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft }),
|
|
6244
|
+
body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft, tags: this.noteTagsDraft.split(',').map(t=>t.trim()).filter(Boolean) }),
|
|
5828
6245
|
}).then(r => r.json());
|
|
5829
6246
|
const idx = this.personalNotes.findIndex(n => n.filename === this.selectedNote.filename);
|
|
5830
6247
|
if (idx !== -1) this.personalNotes[idx] = updated;
|
|
@@ -5838,27 +6255,61 @@
|
|
|
5838
6255
|
|
|
5839
6256
|
async deletePersonalNote() {
|
|
5840
6257
|
if (!this.selectedNote || !confirm(`Delete "${this.selectedNote.title}"?`)) return;
|
|
5841
|
-
await fetch(`/api/notes/${this.selectedNote.
|
|
6258
|
+
await fetch(`/api/notes/${this.selectedNote.path}`, { method: 'DELETE' });
|
|
5842
6259
|
this.personalNotes = this.personalNotes.filter(n => n.filename !== this.selectedNote.filename);
|
|
5843
6260
|
this.selectedNote = null;
|
|
5844
6261
|
this.noteEditing = false;
|
|
5845
6262
|
},
|
|
5846
6263
|
|
|
5847
|
-
async
|
|
5848
|
-
|
|
5849
|
-
const
|
|
5850
|
-
const
|
|
5851
|
-
method: '
|
|
6264
|
+
async togglePinNote() {
|
|
6265
|
+
if (!this.selectedNote) return;
|
|
6266
|
+
const newPinned = !this.selectedNote.pinned;
|
|
6267
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}`, {
|
|
6268
|
+
method: 'PUT',
|
|
6269
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6270
|
+
body: JSON.stringify({ pinned: newPinned }),
|
|
6271
|
+
}).then(r => r.json());
|
|
6272
|
+
if (updated.error) { alert(updated.error); return; }
|
|
6273
|
+
const idx = this.personalNotes.findIndex(n => n.path === this.selectedNote.path);
|
|
6274
|
+
if (idx !== -1) this.personalNotes.splice(idx, 1, updated);
|
|
6275
|
+
this.selectedNote = updated;
|
|
6276
|
+
},
|
|
6277
|
+
|
|
6278
|
+
|
|
6279
|
+
async movePersonalNote(targetFolder) {
|
|
6280
|
+
if (!this.selectedNote || targetFolder === '__current__') return;
|
|
6281
|
+
const currentFolder = this.selectedNote.folder ?? '';
|
|
6282
|
+
if (targetFolder === currentFolder) return;
|
|
6283
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.path}/move`, {
|
|
6284
|
+
method: 'PATCH',
|
|
5852
6285
|
headers: { 'Content-Type': 'application/json' },
|
|
5853
|
-
body: JSON.stringify({
|
|
6286
|
+
body: JSON.stringify({ folder: targetFolder }),
|
|
5854
6287
|
}).then(r => r.json());
|
|
5855
|
-
|
|
5856
|
-
this.
|
|
5857
|
-
this.
|
|
5858
|
-
this.
|
|
5859
|
-
this.
|
|
5860
|
-
|
|
5861
|
-
|
|
6288
|
+
if (updated.error) { alert(updated.error); return; }
|
|
6289
|
+
const idx = this.personalNotes.findIndex(n => n.path === this.selectedNote.path);
|
|
6290
|
+
if (idx !== -1) this.personalNotes.splice(idx, 1, updated);
|
|
6291
|
+
this.selectedNote = updated;
|
|
6292
|
+
this.noteFolderPath = targetFolder;
|
|
6293
|
+
window.location.hash = `#/note/${updated.path}`;
|
|
6294
|
+
},
|
|
6295
|
+
|
|
6296
|
+
async snapshotSession() {
|
|
6297
|
+
if (!this.selectedSession || this.snapshottingSession) return;
|
|
6298
|
+
this.snapshottingSession = true;
|
|
6299
|
+
try {
|
|
6300
|
+
const { projectDir, sessionId } = this.selectedSession;
|
|
6301
|
+
const note = await fetch(`/api/sessions/${projectDir}/${sessionId}/snapshot`, {
|
|
6302
|
+
method: 'POST',
|
|
6303
|
+
}).then(r => r.json());
|
|
6304
|
+
if (note.error) { alert(note.error); return; }
|
|
6305
|
+
if (!this.personalNotes.find(n => n.path === note.path)) this.personalNotes.unshift(note);
|
|
6306
|
+
this.selectedNote = note;
|
|
6307
|
+
this.noteEditing = false;
|
|
6308
|
+
this.view = 'notes';
|
|
6309
|
+
this.selectedSession = null;
|
|
6310
|
+
} finally {
|
|
6311
|
+
this.snapshottingSession = false;
|
|
6312
|
+
}
|
|
5862
6313
|
},
|
|
5863
6314
|
|
|
5864
6315
|
async loadToday() {
|
|
@@ -5894,6 +6345,25 @@
|
|
|
5894
6345
|
this.saveToday();
|
|
5895
6346
|
},
|
|
5896
6347
|
|
|
6348
|
+
addNoteTask(text, note) {
|
|
6349
|
+
if (!text || !this.todayData) return;
|
|
6350
|
+
this.todayData.tasks.push({
|
|
6351
|
+
id: Math.random().toString(36).slice(2),
|
|
6352
|
+
text: text.slice(0, 200),
|
|
6353
|
+
done: false,
|
|
6354
|
+
carriedOver: false,
|
|
6355
|
+
createdAt: new Date().toISOString(),
|
|
6356
|
+
noteRef: note ? { title: note.title, path: note.path } : undefined,
|
|
6357
|
+
});
|
|
6358
|
+
this.saveToday();
|
|
6359
|
+
// Toast
|
|
6360
|
+
const el = document.createElement('div');
|
|
6361
|
+
el.className = 'note-task-toast';
|
|
6362
|
+
el.textContent = '✓ Added to Today';
|
|
6363
|
+
document.body.appendChild(el);
|
|
6364
|
+
setTimeout(() => el.remove(), 2000);
|
|
6365
|
+
},
|
|
6366
|
+
|
|
5897
6367
|
toggleTodayTask(id) {
|
|
5898
6368
|
const task = this.todayData?.tasks.find(t => t.id === id);
|
|
5899
6369
|
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]] : [];
|
|
@@ -1794,6 +1799,58 @@ app.get('/api/sessions/:project/:sessionId/export', async (req, res) => {
|
|
|
1794
1799
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1795
1800
|
});
|
|
1796
1801
|
|
|
1802
|
+
// POST /api/sessions/:project/:sessionId/snapshot — create a note with session summary
|
|
1803
|
+
app.post('/api/sessions/:project/:sessionId/snapshot', async (req, res) => {
|
|
1804
|
+
const { project, sessionId } = req.params;
|
|
1805
|
+
const filePath = path.join(PROJECTS_DIR, project, `${sessionId}.jsonl`);
|
|
1806
|
+
if (!filePath.startsWith(PROJECTS_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
1807
|
+
try {
|
|
1808
|
+
const messages = await parseJsonl(filePath);
|
|
1809
|
+
if (!messages.length) return res.status(404).json({ error: 'session not found or empty' });
|
|
1810
|
+
|
|
1811
|
+
// Extract metadata
|
|
1812
|
+
const first = messages[0];
|
|
1813
|
+
const branch = first.gitBranch || '';
|
|
1814
|
+
const date = first.timestamp ? first.timestamp.slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
1815
|
+
|
|
1816
|
+
// Extract user prompts (skip <command-name> tool messages)
|
|
1817
|
+
const prompts = [];
|
|
1818
|
+
for (const m of messages) {
|
|
1819
|
+
if (m.type !== 'user') continue;
|
|
1820
|
+
const c = m.message?.content;
|
|
1821
|
+
let text = '';
|
|
1822
|
+
if (typeof c === 'string') text = c;
|
|
1823
|
+
else if (Array.isArray(c)) {
|
|
1824
|
+
text = c.filter(b => b.type === 'text' && !b.text?.includes('<command-name>')).map(b => b.text).join('\n');
|
|
1825
|
+
}
|
|
1826
|
+
text = text.trim();
|
|
1827
|
+
if (text && text.length > 2) prompts.push(text);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const firstPrompt = prompts[0] || sessionId;
|
|
1831
|
+
const title = `Snapshot: ${firstPrompt.slice(0, 60)}${firstPrompt.length > 60 ? '…' : ''}`;
|
|
1832
|
+
|
|
1833
|
+
const projectLabel = project.replace(/^-Users-[^-]+-/, '').replace(/-/g, '/');
|
|
1834
|
+
const meta = [`**Project:** ${projectLabel}`, branch ? `**Branch:** ${branch}` : '', `**Date:** ${date}`, `**Session:** ${sessionId.slice(0, 8)}…`].filter(Boolean).join(' · ');
|
|
1835
|
+
|
|
1836
|
+
const promptList = prompts.map((p, i) => `${i + 1}. ${p.replace(/\n+/g, ' ').slice(0, 200)}${p.length > 200 ? '…' : ''}`).join('\n');
|
|
1837
|
+
|
|
1838
|
+
const content = `${meta}\n\n## Prompts\n\n${promptList}\n\n## Notes\n\n`;
|
|
1839
|
+
|
|
1840
|
+
ensureNotesDir();
|
|
1841
|
+
const slug = firstPrompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'snapshot';
|
|
1842
|
+
const datePrefix = date;
|
|
1843
|
+
let filename = `${datePrefix}-snapshot-${slug}.md`;
|
|
1844
|
+
let i = 1;
|
|
1845
|
+
while (fs.existsSync(path.join(NOTES_DIR, filename))) { filename = `${datePrefix}-snapshot-${slug}-${i++}.md`; }
|
|
1846
|
+
const tagsLine = `\ntags: [snapshot]`;
|
|
1847
|
+
const sessionLine = `\nsession: ${sessionId}`;
|
|
1848
|
+
const raw = `---\ntitle: ${title}\ndate: ${new Date().toISOString()}${sessionLine}${tagsLine}\n---\n\n${content}`;
|
|
1849
|
+
fs.writeFileSync(path.join(NOTES_DIR, filename), raw, 'utf8');
|
|
1850
|
+
res.json(parseNoteFile(filename));
|
|
1851
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1797
1854
|
// ─── App settings (claude-home specific, not Claude's settings.json) ─────────
|
|
1798
1855
|
const APP_SETTINGS_FILE = path.join(DATA_DIR, 'app-settings.json');
|
|
1799
1856
|
|
|
@@ -1902,21 +1959,47 @@ app.post('/api/share/plan/:filename', async (req, res) => {
|
|
|
1902
1959
|
const NOTES_DIR = path.join(DATA_DIR, 'notes');
|
|
1903
1960
|
function ensureNotesDir() { if (!fs.existsSync(NOTES_DIR)) fs.mkdirSync(NOTES_DIR, { recursive: true }); }
|
|
1904
1961
|
|
|
1905
|
-
function parseNoteFile(
|
|
1906
|
-
const filePath = path.join(NOTES_DIR,
|
|
1962
|
+
function parseNoteFile(notepath) {
|
|
1963
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
1907
1964
|
const stat = fs.statSync(filePath);
|
|
1908
1965
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1909
1966
|
const { meta, content: body } = parseFrontmatter(raw);
|
|
1967
|
+
const tags = Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []);
|
|
1968
|
+
const parts = notepath.replace(/\\/g, '/').split('/');
|
|
1969
|
+
const filename = parts[parts.length - 1];
|
|
1970
|
+
const folder = parts.slice(0, -1).join('/');
|
|
1910
1971
|
return {
|
|
1911
1972
|
filename,
|
|
1973
|
+
path: notepath,
|
|
1974
|
+
folder,
|
|
1912
1975
|
title: meta.title || filename.replace('.md', ''),
|
|
1913
1976
|
date: meta.date || stat.mtimeMs,
|
|
1914
1977
|
session: meta.session || '',
|
|
1978
|
+
tags,
|
|
1979
|
+
pinned: meta.pinned === true || meta.pinned === 'true',
|
|
1915
1980
|
content: body.trim(),
|
|
1916
1981
|
modified: new Date(stat.mtimeMs).toISOString(),
|
|
1917
1982
|
};
|
|
1918
1983
|
}
|
|
1919
1984
|
|
|
1985
|
+
function scanNotes(dir, base) {
|
|
1986
|
+
if (dir === undefined) dir = NOTES_DIR;
|
|
1987
|
+
if (base === undefined) base = '';
|
|
1988
|
+
const results = [];
|
|
1989
|
+
try {
|
|
1990
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1991
|
+
const fullPath = path.join(dir, entry);
|
|
1992
|
+
const relPath = base ? `${base}/${entry}` : entry;
|
|
1993
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
1994
|
+
results.push(...scanNotes(fullPath, relPath));
|
|
1995
|
+
} else if (entry.endsWith('.md')) {
|
|
1996
|
+
results.push(parseNoteFile(relPath));
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
} catch (e) { /* ignore unreadable dirs */ }
|
|
2000
|
+
return results;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1920
2003
|
const NOTES_CLAUDE_MD_SNIPPET = `
|
|
1921
2004
|
## Personal Notes
|
|
1922
2005
|
|
|
@@ -1933,9 +2016,13 @@ When the user asks you to "save a note", "add to notes", "guarda esto como nota"
|
|
|
1933
2016
|
|
|
1934
2017
|
<the content the user wants to save>
|
|
1935
2018
|
\`\`\`
|
|
1936
|
-
2. **
|
|
1937
|
-
|
|
1938
|
-
|
|
2019
|
+
2. **Folder**: Save in a subfolder when appropriate:
|
|
2020
|
+
- Project-specific note → \`~/.claude/claude-home/notes/<basename-of-pwd>/\` (e.g. working in \`mono-genially\` → folder \`mono-genially\`)
|
|
2021
|
+
- Global/cross-project note → root \`~/.claude/claude-home/notes/\`
|
|
2022
|
+
- User-specified folder → use that folder name
|
|
2023
|
+
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.
|
|
2024
|
+
4. Use the Write tool to create the file (not Bash).
|
|
2025
|
+
5. Confirm with: "Saved to Notes: http://localhost:3141/#/note/<folder/filename or filename>"
|
|
1939
2026
|
|
|
1940
2027
|
The notes directory may not exist yet — the app creates it automatically on first load.
|
|
1941
2028
|
|
|
@@ -2003,51 +2090,186 @@ app.post('/api/notes/setup-claude', (req, res) => {
|
|
|
2003
2090
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2004
2091
|
});
|
|
2005
2092
|
|
|
2093
|
+
app.get('/api/notes/folders', (req, res) => {
|
|
2094
|
+
ensureNotesDir();
|
|
2095
|
+
try {
|
|
2096
|
+
const folders = fs.readdirSync(NOTES_DIR).filter(e => fs.statSync(path.join(NOTES_DIR, e)).isDirectory());
|
|
2097
|
+
res.json(folders.sort());
|
|
2098
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
app.post('/api/notes/folders', (req, res) => {
|
|
2102
|
+
ensureNotesDir();
|
|
2103
|
+
const { name } = req.body;
|
|
2104
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
2105
|
+
const safeName = name.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
|
|
2106
|
+
if (!safeName) return res.status(400).json({ error: 'invalid name' });
|
|
2107
|
+
const folderPath = path.join(NOTES_DIR, safeName);
|
|
2108
|
+
if (!folderPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid name' });
|
|
2109
|
+
try {
|
|
2110
|
+
if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
|
|
2111
|
+
res.json({ name: safeName });
|
|
2112
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
app.patch('/api/notes/folders/:name/rename', (req, res) => {
|
|
2116
|
+
ensureNotesDir();
|
|
2117
|
+
const { name } = req.params;
|
|
2118
|
+
const { newName } = req.body;
|
|
2119
|
+
if (!name || !newName) return res.status(400).json({ error: 'name and newName required' });
|
|
2120
|
+
const safeName = newName.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
|
|
2121
|
+
if (!safeName) return res.status(400).json({ error: 'invalid newName' });
|
|
2122
|
+
const srcPath = path.join(NOTES_DIR, name);
|
|
2123
|
+
const destPath = path.join(NOTES_DIR, safeName);
|
|
2124
|
+
if (!srcPath.startsWith(NOTES_DIR) || !destPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2125
|
+
try {
|
|
2126
|
+
if (!fs.existsSync(srcPath)) return res.status(404).json({ error: 'folder not found' });
|
|
2127
|
+
if (fs.existsSync(destPath)) return res.status(409).json({ error: 'folder already exists' });
|
|
2128
|
+
fs.renameSync(srcPath, destPath);
|
|
2129
|
+
res.json({ name: safeName });
|
|
2130
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
app.delete('/api/notes/folders/:name', (req, res) => {
|
|
2134
|
+
ensureNotesDir();
|
|
2135
|
+
const { name } = req.params;
|
|
2136
|
+
const { action } = req.query; // 'delete' | 'orphan'
|
|
2137
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
2138
|
+
if (!['delete', 'orphan'].includes(action)) return res.status(400).json({ error: 'action must be delete or orphan' });
|
|
2139
|
+
const folderPath = path.join(NOTES_DIR, name);
|
|
2140
|
+
if (!folderPath.startsWith(NOTES_DIR + path.sep) && folderPath !== NOTES_DIR) return res.status(400).json({ error: 'invalid name' });
|
|
2141
|
+
try {
|
|
2142
|
+
if (!fs.existsSync(folderPath)) return res.status(404).json({ error: 'folder not found' });
|
|
2143
|
+
const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.md'));
|
|
2144
|
+
if (action === 'orphan') {
|
|
2145
|
+
for (const f of files) {
|
|
2146
|
+
let dest = path.join(NOTES_DIR, f);
|
|
2147
|
+
if (fs.existsSync(dest)) dest = path.join(NOTES_DIR, f.slice(0, -3) + '-orphaned.md');
|
|
2148
|
+
fs.renameSync(path.join(folderPath, f), dest);
|
|
2149
|
+
}
|
|
2150
|
+
} else {
|
|
2151
|
+
for (const f of files) fs.unlinkSync(path.join(folderPath, f));
|
|
2152
|
+
}
|
|
2153
|
+
try { fs.rmdirSync(folderPath); } catch {}
|
|
2154
|
+
res.json({ ok: true, action, count: files.length });
|
|
2155
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
function rewriteNoteTags(notepath, updatedTags) {
|
|
2159
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2160
|
+
const existing = parseNoteFile(notepath);
|
|
2161
|
+
const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
|
|
2162
|
+
const tagsLine = updatedTags.length > 0 ? `\ntags: [${updatedTags.join(', ')}]` : '';
|
|
2163
|
+
const pinnedLine = existing.pinned ? `\npinned: true` : '';
|
|
2164
|
+
const raw = `---\ntitle: ${existing.title}\ndate: ${existing.date}${sessionLine}${tagsLine}${pinnedLine}\n---\n\n${existing.content}`;
|
|
2165
|
+
fs.writeFileSync(filePath, raw, 'utf8');
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
app.patch('/api/notes/tags/:name/rename', (req, res) => {
|
|
2169
|
+
const { name } = req.params;
|
|
2170
|
+
const { newName } = req.body;
|
|
2171
|
+
if (!name || !newName) return res.status(400).json({ error: 'name and newName required' });
|
|
2172
|
+
const safe = newName.trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
2173
|
+
if (!safe) return res.status(400).json({ error: 'invalid newName' });
|
|
2174
|
+
ensureNotesDir();
|
|
2175
|
+
try {
|
|
2176
|
+
const affected = scanNotes().filter(n => (n.tags || []).includes(name));
|
|
2177
|
+
for (const n of affected) {
|
|
2178
|
+
const tags = n.tags.map(t => t === name ? safe : t);
|
|
2179
|
+
rewriteNoteTags(n.path, tags);
|
|
2180
|
+
}
|
|
2181
|
+
res.json({ renamed: name, to: safe, count: affected.length });
|
|
2182
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
app.delete('/api/notes/tags/:name', (req, res) => {
|
|
2186
|
+
const { name } = req.params;
|
|
2187
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
2188
|
+
ensureNotesDir();
|
|
2189
|
+
try {
|
|
2190
|
+
const affected = scanNotes().filter(n => (n.tags || []).includes(name));
|
|
2191
|
+
for (const n of affected) {
|
|
2192
|
+
const tags = n.tags.filter(t => t !== name);
|
|
2193
|
+
rewriteNoteTags(n.path, tags);
|
|
2194
|
+
}
|
|
2195
|
+
res.json({ deleted: name, count: affected.length });
|
|
2196
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2006
2199
|
app.get('/api/notes', (req, res) => {
|
|
2007
2200
|
ensureNotesDir();
|
|
2008
2201
|
try {
|
|
2009
|
-
const
|
|
2010
|
-
const notes = files.map(f => parseNoteFile(f)).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
2202
|
+
const notes = scanNotes().sort((a, b) => b.modified.localeCompare(a.modified));
|
|
2011
2203
|
res.json(notes);
|
|
2012
2204
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2013
2205
|
});
|
|
2014
2206
|
|
|
2015
2207
|
app.post('/api/notes', (req, res) => {
|
|
2016
2208
|
ensureNotesDir();
|
|
2017
|
-
const { title, content, session } = req.body;
|
|
2209
|
+
const { title, content, session, tags, folder } = req.body;
|
|
2018
2210
|
if (!title) return res.status(400).json({ error: 'title required' });
|
|
2019
2211
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'note';
|
|
2020
2212
|
const date = new Date().toISOString();
|
|
2021
2213
|
const datePrefix = date.slice(0, 10);
|
|
2214
|
+
const safeFolder = folder ? folder.trim().replace(/[^a-zA-Z0-9_/-]/g, '-').replace(/^\/+|\/+$/g, '') : '';
|
|
2215
|
+
const noteDir = safeFolder ? path.join(NOTES_DIR, safeFolder) : NOTES_DIR;
|
|
2216
|
+
if (!noteDir.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid folder' });
|
|
2217
|
+
if (!fs.existsSync(noteDir)) fs.mkdirSync(noteDir, { recursive: true });
|
|
2022
2218
|
let filename = `${datePrefix}-${slug}.md`;
|
|
2023
|
-
// avoid collisions
|
|
2024
2219
|
let i = 1;
|
|
2025
|
-
while (fs.existsSync(path.join(
|
|
2220
|
+
while (fs.existsSync(path.join(noteDir, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
|
|
2221
|
+
const notepath = safeFolder ? `${safeFolder}/${filename}` : filename;
|
|
2026
2222
|
const sessionLine = session ? `\nsession: ${session}` : '';
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2029
|
-
|
|
2223
|
+
const tagsArr = Array.isArray(tags) ? tags.filter(Boolean) : [];
|
|
2224
|
+
const tagsLine = tagsArr.length > 0 ? `\ntags: [${tagsArr.join(', ')}]` : '';
|
|
2225
|
+
const raw = `---\ntitle: ${title}\ndate: ${date}${sessionLine}${tagsLine}\n---\n\n${content || ''}`;
|
|
2226
|
+
fs.writeFileSync(path.join(NOTES_DIR, notepath), raw, 'utf8');
|
|
2227
|
+
res.json(parseNoteFile(notepath));
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
app.patch('/api/notes/*/move', (req, res) => {
|
|
2231
|
+
const notepath = req.params[0];
|
|
2232
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2233
|
+
const srcPath = path.join(NOTES_DIR, notepath);
|
|
2234
|
+
if (!srcPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2235
|
+
const { folder } = req.body;
|
|
2236
|
+
const filename = path.basename(notepath);
|
|
2237
|
+
const destDir = folder ? path.join(NOTES_DIR, folder) : NOTES_DIR;
|
|
2238
|
+
const destPath = path.join(destDir, filename);
|
|
2239
|
+
if (!destPath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid destination' });
|
|
2240
|
+
try {
|
|
2241
|
+
if (!fs.existsSync(srcPath)) return res.status(404).json({ error: 'note not found' });
|
|
2242
|
+
if (destPath === srcPath) return res.json(parseNoteFile(notepath));
|
|
2243
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
2244
|
+
fs.renameSync(srcPath, destPath);
|
|
2245
|
+
const newRelPath = folder ? `${folder}/${filename}` : filename;
|
|
2246
|
+
res.json(parseNoteFile(newRelPath));
|
|
2247
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2030
2248
|
});
|
|
2031
2249
|
|
|
2032
|
-
app.put('/api/notes
|
|
2033
|
-
const
|
|
2034
|
-
if (!
|
|
2035
|
-
const filePath = path.join(NOTES_DIR,
|
|
2250
|
+
app.put('/api/notes/*', (req, res) => {
|
|
2251
|
+
const notepath = req.params[0];
|
|
2252
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2253
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2036
2254
|
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2037
|
-
const { title, content } = req.body;
|
|
2255
|
+
const { title, content, tags, pinned } = req.body;
|
|
2038
2256
|
try {
|
|
2039
|
-
const existing = parseNoteFile(
|
|
2257
|
+
const existing = parseNoteFile(notepath);
|
|
2258
|
+
const resolvedTags = Array.isArray(tags) ? tags : existing.tags;
|
|
2259
|
+
const resolvedPinned = pinned !== undefined ? pinned : existing.pinned;
|
|
2040
2260
|
const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
|
|
2041
|
-
const
|
|
2261
|
+
const tagsLine = resolvedTags.length > 0 ? `\ntags: [${resolvedTags.join(', ')}]` : '';
|
|
2262
|
+
const pinnedLine = resolvedPinned ? `\npinned: true` : '';
|
|
2263
|
+
const raw = `---\ntitle: ${title || existing.title}\ndate: ${existing.date}${sessionLine}${tagsLine}${pinnedLine}\n---\n\n${content ?? existing.content}`;
|
|
2042
2264
|
fs.writeFileSync(filePath, raw, 'utf8');
|
|
2043
|
-
res.json(parseNoteFile(
|
|
2265
|
+
res.json(parseNoteFile(notepath));
|
|
2044
2266
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
2045
2267
|
});
|
|
2046
2268
|
|
|
2047
|
-
app.delete('/api/notes
|
|
2048
|
-
const
|
|
2049
|
-
if (!
|
|
2050
|
-
const filePath = path.join(NOTES_DIR,
|
|
2269
|
+
app.delete('/api/notes/*', (req, res) => {
|
|
2270
|
+
const notepath = req.params[0];
|
|
2271
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2272
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
2051
2273
|
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
2052
2274
|
try {
|
|
2053
2275
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|