claude-home 1.5.37 → 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 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
 
@@ -37,6 +39,14 @@ Direct links (`#/note/filename`) let you open a specific note instantly.
37
39
 
38
40
  **Clipboard capture** — opening *New note* automatically pre-fills the content with whatever is in your clipboard.
39
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
+
40
50
  ### Projects
41
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.
42
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-home",
3
- "version": "1.5.37",
3
+ "version": "1.6.0",
4
4
  "description": "Web dashboard for Claude Code — browse sessions, manage skills, hooks, commands, and agents",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -484,6 +484,94 @@
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
+
487
575
  /* Scratch row */
488
576
  .scratch-row {
489
577
  border-bottom: 1px solid var(--rule);
@@ -2651,7 +2739,10 @@
2651
2739
  <template x-for="task in todayData.tasks.filter(t => t.carriedOver && !t.done)" :key="task.id">
2652
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">
2653
2741
  <input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
2654
- <span x-text="task.text" style="flex:1;font-size:13px;color:var(--ink-2)"></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>
2655
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>
2656
2747
  </div>
2657
2748
  </template>
@@ -2662,7 +2753,10 @@
2662
2753
  <template x-for="task in todayData.tasks.filter(t => !t.carriedOver && !t.done)" :key="task.id">
2663
2754
  <div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule)">
2664
2755
  <input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
2665
- <span x-text="task.text" style="flex:1;font-size:13px;color:var(--ink)"></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>
2666
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>
2667
2761
  </div>
2668
2762
  </template>
@@ -2674,7 +2768,10 @@
2674
2768
  <template x-for="task in todayData.tasks.filter(t => t.done)" :key="task.id">
2675
2769
  <div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule);opacity:0.5">
2676
2770
  <input type="checkbox" :checked="task.done" @change="toggleTodayTask(task.id)" style="flex-shrink:0;cursor:pointer" />
2677
- <span x-text="task.text" style="flex:1;font-size:13px;text-decoration:line-through;color:var(--ink-3)"></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>
2678
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>
2679
2776
  </div>
2680
2777
  </template>
@@ -2774,7 +2871,16 @@
2774
2871
  </template>
2775
2872
  <template x-if="selectedNote && !noteEditing">
2776
2873
  <div style="display:flex;align-items:center;gap:8px">
2777
- <button class="btn btn-sm btn-outline" @click="noteEditing=true;noteTitleDraft=selectedNote.title;noteBodyDraft=selectedNote.content">Edit</button>
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>
2778
2884
  <button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePersonalNote()">Delete</button>
2779
2885
  </div>
2780
2886
  </template>
@@ -2787,7 +2893,16 @@
2787
2893
  <div style="padding:16px 20px;border-bottom:1px solid var(--rule);background:var(--canvas-2)">
2788
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())" />
2789
2895
  <div style="position:relative">
2790
- <textarea class="settings-input" placeholder="Content (optional)…" x-model="noteNewBody" rows="3" style="width:100%;resize:vertical;font-size:12px;font-family:inherit"></textarea>
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>
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>
2791
2906
  <template x-if="noteFromClipboard">
2792
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>
2793
2908
  </template>
@@ -2825,16 +2940,112 @@
2825
2940
  </div>
2826
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>
2827
2942
  </div>
2828
- <template x-if="personalNotes.length > 0">
2829
- <div style="border-top:1px solid var(--rule);margin:4px 0"></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>
2830
2989
  </template>
2831
- <template x-for="n in personalNotes.filter(n => !noteSearch || n.title.toLowerCase().includes(noteSearch.toLowerCase()) || n.content.toLowerCase().includes(noteSearch.toLowerCase()))" :key="n.filename">
2832
- <div class="plan-row" :class="{active: selectedNote?.filename === n.filename}" @click="selectedNote=n;scratchActive=false;noteEditing=false;window.location.hash='#/note/'+n.filename">
2833
- <div class="plan-row-title" x-text="n.title"></div>
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)">&nbsp;/&nbsp;</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>
2834
3038
  <div class="plan-row-meta">
2835
3039
  <span x-text="formatDate(n.modified)"></span>
2836
- <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>
2837
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>
2838
3049
  </div>
2839
3050
  </template>
2840
3051
  </div>
@@ -2877,12 +3088,19 @@
2877
3088
  <span style="font-size:11px;color:var(--blue);cursor:pointer" @click="openSessionById(selectedNote.session)" x-text="'→ Session ' + selectedNote.session.slice(0,8) + '…'"></span>
2878
3089
  </template>
2879
3090
  </div>
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>
2880
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>
2881
3099
  <template x-if="noteBacklinks(selectedNote).length > 0">
2882
3100
  <div class="note-backlinks">
2883
3101
  <span class="note-backlinks-label">Referenced by</span>
2884
3102
  <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.filename" x-text="bl.title"></span>
3103
+ <span class="note-backlink-chip" @click="selectedNote=bl;noteEditing=false;window.location.hash='#/note/'+bl.path" x-text="bl.title"></span>
2886
3104
  </template>
2887
3105
  </div>
2888
3106
  </template>
@@ -2890,7 +3108,7 @@
2890
3108
  <div class="note-backlinks">
2891
3109
  <span class="note-backlinks-label">Related</span>
2892
3110
  <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.filename" x-text="rn.title"></span>
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>
2894
3112
  </template>
2895
3113
  </div>
2896
3114
  </template>
@@ -2899,6 +3117,7 @@
2899
3117
  <template x-if="selectedNote && noteEditing">
2900
3118
  <div style="display:flex;flex-direction:column;height:100%;gap:10px;position:relative">
2901
3119
  <input class="settings-input" type="text" x-model="noteTitleDraft" style="font-size:14px;font-weight:600;width:100%" />
3120
+ <input class="settings-input" type="text" x-model="noteTagsDraft" placeholder="Tags (comma separated: bug, idea, trabajo)…" style="font-size:12px;width:100%" />
2902
3121
  <div style="position:relative;flex:1;display:flex;flex-direction:column">
2903
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>
2904
3123
  <template x-if="noteAutocomplete.visible">
@@ -4590,6 +4809,19 @@
4590
4809
  noteFromClipboard: false,
4591
4810
  scratchContent: sessionStorage.getItem('cs:scratch') || '',
4592
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: '',
4593
4825
  sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
4594
4826
  historyEntries: [],
4595
4827
  historyLoading: false,
@@ -4774,7 +5006,7 @@
4774
5006
  if (mNote) {
4775
5007
  const notes = await fetch('/api/notes').then(r => r.json()).catch(() => []);
4776
5008
  this.personalNotes = notes;
4777
- const note = this.personalNotes.find(n => n.filename === mNote[1]);
5009
+ const note = this.personalNotes.find(n => n.path === mNote[1]);
4778
5010
  if (note) { this.view = 'notes'; this.selectedNote = note; }
4779
5011
  }
4780
5012
  // Clear hash when closing
@@ -5167,22 +5399,143 @@
5167
5399
  return slugPart === slug || base === slug;
5168
5400
  });
5169
5401
  if (!note) return match;
5170
- return `${pre}[#${slug}](#/note/${note.filename})`;
5402
+ return `${pre}[#${slug}](#/note/${note.path})`;
5171
5403
  });
5172
5404
  let html;
5173
5405
  try { html = marked.parse(processed); } catch { html = processed; }
5174
5406
  // Mark note-ref links so we can style/intercept them
5175
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}`);
5176
5412
  return html;
5177
5413
  },
5178
5414
 
5179
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
+ }
5180
5424
  const a = e.target.closest('a.note-ref-link');
5181
5425
  if (!a) return;
5182
5426
  e.preventDefault();
5183
- const filename = a.getAttribute('href').replace('#/note/', '');
5184
- const note = this.personalNotes.find(n => n.filename === filename);
5185
- if (note) { this.selectedNote = note; this.noteEditing = false; window.location.hash = '#/note/' + filename; }
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;
5186
5539
  },
5187
5540
 
5188
5541
  noteTokens(note) {
@@ -5751,6 +6104,8 @@
5751
6104
  async openNewNote() {
5752
6105
  this.noteNewTitle = '';
5753
6106
  this.noteNewBody = '';
6107
+ this.noteNewTags = '';
6108
+ this.noteNewFolder = this.noteFolderPath;
5754
6109
  this.noteFromClipboard = false;
5755
6110
  this.noteCreating = true;
5756
6111
  try {
@@ -5773,12 +6128,14 @@
5773
6128
  async loadPersonalNotes() {
5774
6129
  if (this.personalNotesLoading) return;
5775
6130
  this.personalNotesLoading = true;
5776
- const [notes, status] = await Promise.all([
6131
+ const [notes, status, folders] = await Promise.all([
5777
6132
  fetch('/api/notes').then(r => r.json()).catch(() => []),
5778
6133
  fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({})),
6134
+ fetch('/api/notes/folders').then(r => r.json()).catch(() => []),
5779
6135
  ]);
5780
6136
  this.personalNotes = notes;
5781
6137
  this.noteClaudeInstalled = status.installed ?? null;
6138
+ this.noteFolders = Array.isArray(folders) ? folders : [];
5782
6139
  this.personalNotesLoading = false;
5783
6140
  },
5784
6141
 
@@ -5805,13 +6162,14 @@
5805
6162
  const note = await fetch('/api/notes', {
5806
6163
  method: 'POST',
5807
6164
  headers: { 'Content-Type': 'application/json' },
5808
- 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 }),
5809
6166
  }).then(r => r.json());
5810
6167
  this.personalNotes.unshift(note);
5811
6168
  this.selectedNote = note;
5812
6169
  this.noteCreating = false;
5813
6170
  this.noteNewTitle = '';
5814
6171
  this.noteNewBody = '';
6172
+ this.noteNewTags = '';
5815
6173
  this.noteEditing = false;
5816
6174
  } catch (e) { this.noteMsg = 'Error: ' + e.message; }
5817
6175
  this.noteSaving = false;
@@ -5821,10 +6179,10 @@
5821
6179
  if (!this.selectedNote) return;
5822
6180
  this.noteSaving = true; this.noteMsg = '';
5823
6181
  try {
5824
- const updated = await fetch(`/api/notes/${this.selectedNote.filename}`, {
6182
+ const updated = await fetch(`/api/notes/${this.selectedNote.path}`, {
5825
6183
  method: 'PUT',
5826
6184
  headers: { 'Content-Type': 'application/json' },
5827
- 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) }),
5828
6186
  }).then(r => r.json());
5829
6187
  const idx = this.personalNotes.findIndex(n => n.filename === this.selectedNote.filename);
5830
6188
  if (idx !== -1) this.personalNotes[idx] = updated;
@@ -5838,12 +6196,44 @@
5838
6196
 
5839
6197
  async deletePersonalNote() {
5840
6198
  if (!this.selectedNote || !confirm(`Delete "${this.selectedNote.title}"?`)) return;
5841
- await fetch(`/api/notes/${this.selectedNote.filename}`, { method: 'DELETE' });
6199
+ await fetch(`/api/notes/${this.selectedNote.path}`, { method: 'DELETE' });
5842
6200
  this.personalNotes = this.personalNotes.filter(n => n.filename !== this.selectedNote.filename);
5843
6201
  this.selectedNote = null;
5844
6202
  this.noteEditing = false;
5845
6203
  },
5846
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
+
5847
6237
  async saveNoteFromSession() {
5848
6238
  const title = this.selectedSession?.firstPrompt?.slice(0, 60) || 'Session note';
5849
6239
  const sessionId = this.selectedSession?.sessionId || '';
@@ -5894,6 +6284,25 @@
5894
6284
  this.saveToday();
5895
6285
  },
5896
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
+
5897
6306
  toggleTodayTask(id) {
5898
6307
  const task = this.todayData?.tasks.find(t => t.id === id);
5899
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
- meta[currentKey] = kv[2].trim();
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(filename) {
1906
- const filePath = path.join(NOTES_DIR, filename);
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
 
@@ -1933,9 +1964,13 @@ When the user asks you to "save a note", "add to notes", "guarda esto como nota"
1933
1964
 
1934
1965
  <the content the user wants to save>
1935
1966
  \`\`\`
1936
- 2. **Note linking**: If the note references concepts that likely exist in other notes, use \`#slug\` syntax to link them. The slug is the part of the filename after the date (\`2026-03-31-\`**\`my-slug\`**\`.md\` → \`#my-slug\`). To discover existing notes and their slugs, \`Glob ~/.claude/claude-home/notes/*.md\` before writing.
1937
- 3. Use the Write tool to create the file (not Bash).
1938
- 4. Confirm with: "Saved to Notes: http://localhost:3141/#/note/<filename>"
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>"
1939
1974
 
1940
1975
  The notes directory may not exist yet — the app creates it automatically on first load.
1941
1976
 
@@ -2003,51 +2038,161 @@ app.post('/api/notes/setup-claude', (req, res) => {
2003
2038
  } catch (e) { res.status(500).json({ error: e.message }); }
2004
2039
  });
2005
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
+
2006
2122
  app.get('/api/notes', (req, res) => {
2007
2123
  ensureNotesDir();
2008
2124
  try {
2009
- const files = fs.readdirSync(NOTES_DIR).filter(f => f.endsWith('.md'));
2010
- 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));
2011
2126
  res.json(notes);
2012
2127
  } catch (e) { res.status(500).json({ error: e.message }); }
2013
2128
  });
2014
2129
 
2015
2130
  app.post('/api/notes', (req, res) => {
2016
2131
  ensureNotesDir();
2017
- const { title, content, session } = req.body;
2132
+ const { title, content, session, tags, folder } = req.body;
2018
2133
  if (!title) return res.status(400).json({ error: 'title required' });
2019
2134
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'note';
2020
2135
  const date = new Date().toISOString();
2021
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 });
2022
2141
  let filename = `${datePrefix}-${slug}.md`;
2023
- // avoid collisions
2024
2142
  let i = 1;
2025
- while (fs.existsSync(path.join(NOTES_DIR, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
2143
+ while (fs.existsSync(path.join(noteDir, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
2144
+ const notepath = safeFolder ? `${safeFolder}/${filename}` : filename;
2026
2145
  const sessionLine = session ? `\nsession: ${session}` : '';
2027
- const raw = `---\ntitle: ${title}\ndate: ${date}${sessionLine}\n---\n\n${content || ''}`;
2028
- fs.writeFileSync(path.join(NOTES_DIR, filename), raw, 'utf8');
2029
- res.json(parseNoteFile(filename));
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 }); }
2030
2171
  });
2031
2172
 
2032
- app.put('/api/notes/:filename', (req, res) => {
2033
- const filename = path.basename(req.params.filename);
2034
- if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
2035
- const filePath = path.join(NOTES_DIR, filename);
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);
2036
2177
  if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
2037
- const { title, content } = req.body;
2178
+ const { title, content, tags, pinned } = req.body;
2038
2179
  try {
2039
- const existing = parseNoteFile(filename);
2180
+ const existing = parseNoteFile(notepath);
2181
+ const resolvedTags = Array.isArray(tags) ? tags : existing.tags;
2182
+ const resolvedPinned = pinned !== undefined ? pinned : existing.pinned;
2040
2183
  const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
2041
- const raw = `---\ntitle: ${title || existing.title}\ndate: ${existing.date}${sessionLine}\n---\n\n${content ?? existing.content}`;
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}`;
2042
2187
  fs.writeFileSync(filePath, raw, 'utf8');
2043
- res.json(parseNoteFile(filename));
2188
+ res.json(parseNoteFile(notepath));
2044
2189
  } catch (e) { res.status(500).json({ error: e.message }); }
2045
2190
  });
2046
2191
 
2047
- app.delete('/api/notes/:filename', (req, res) => {
2048
- const filename = path.basename(req.params.filename);
2049
- if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
2050
- const filePath = path.join(NOTES_DIR, filename);
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);
2051
2196
  if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
2052
2197
  try {
2053
2198
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);