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 +10 -0
- package/package.json +1 -1
- package/public/index.html +432 -23
- package/server.js +171 -26
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ A daily task list that lives alongside your Claude work. Add tasks, provide cont
|
|
|
22
22
|
|
|
23
23
|
Claude can add tasks directly from any session — just say "add this to today" or "remind me tomorrow to…"
|
|
24
24
|
|
|
25
|
+
Tasks created from notes show a **↗ Note title** chip that navigates directly to the source note.
|
|
26
|
+
|
|
25
27
|
### Notes
|
|
26
28
|
Your personal notepad — separate from Claude's memory. Notes are for you, not for Claude's context. Capture decisions, TILs, bug solutions, runbooks, snippets, or anything worth keeping.
|
|
27
29
|
|
|
@@ -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
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
<
|
|
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
|
-
<
|
|
2829
|
-
|
|
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-
|
|
2832
|
-
<div class="
|
|
2833
|
-
<
|
|
2990
|
+
<template x-if="noteFolderPath !== ''">
|
|
2991
|
+
<div class="note-folder-back" @click="noteFolderPath='';selectedNote=null">
|
|
2992
|
+
<span style="color:var(--blue)">← All notes</span>
|
|
2993
|
+
<span style="color:var(--ink-3)"> / </span>
|
|
2994
|
+
<span style="font-weight:500" x-text="noteFolderPath"></span>
|
|
2995
|
+
</div>
|
|
2996
|
+
</template>
|
|
2997
|
+
<!-- Tag filter (scoped to current folder) -->
|
|
2998
|
+
<template x-if="noteAllTags().length > 0">
|
|
2999
|
+
<div class="note-tag-filter" @click.window="noteTagMenu=null;noteTagRenaming=false">
|
|
3000
|
+
<template x-for="t in noteAllTags()" :key="t.tag">
|
|
3001
|
+
<span class="note-tag-chip-wrap" style="position:relative;display:inline-flex;align-items:center">
|
|
3002
|
+
<span class="note-tag-chip" :class="{active: noteTagFilter.includes(t.tag)}"
|
|
3003
|
+
@click="noteTagFilter.includes(t.tag) ? noteTagFilter.splice(noteTagFilter.indexOf(t.tag),1) : noteTagFilter.push(t.tag)"
|
|
3004
|
+
x-text="t.tag + ' (' + t.count + ')'"></span>
|
|
3005
|
+
<span class="note-tag-mgmt-btn" @click.stop="noteTagMenu===t.tag ? noteTagMenu=null : (noteTagMenu=t.tag, noteTagRenameDraft=t.tag)" title="Manage tag">⋮</span>
|
|
3006
|
+
<template x-if="noteTagMenu === t.tag">
|
|
3007
|
+
<div class="note-tag-menu" @click.stop>
|
|
3008
|
+
<template x-if="!noteTagRenaming">
|
|
3009
|
+
<div>
|
|
3010
|
+
<div class="note-tag-menu-item" @click="noteTagRenaming=true;$nextTick(()=>$refs['tagRenameInput_'+t.tag]?.focus())">Rename…</div>
|
|
3011
|
+
<div class="note-tag-menu-item note-tag-menu-danger" @click="deleteTag(t.tag)">Delete from all notes</div>
|
|
3012
|
+
</div>
|
|
3013
|
+
</template>
|
|
3014
|
+
<template x-if="noteTagRenaming">
|
|
3015
|
+
<div style="display:flex;gap:4px;padding:2px">
|
|
3016
|
+
<input class="settings-input" style="font-size:11px;padding:2px 5px;width:100px"
|
|
3017
|
+
x-model="noteTagRenameDraft"
|
|
3018
|
+
:ref="'tagRenameInput_'+t.tag"
|
|
3019
|
+
x-init="$nextTick(()=>$el.focus())"
|
|
3020
|
+
@keydown.enter="renameTag(t.tag, noteTagRenameDraft)"
|
|
3021
|
+
@keydown.escape="noteTagRenaming=false" />
|
|
3022
|
+
<button class="btn btn-sm btn-primary" style="padding:1px 6px;font-size:11px" @click="renameTag(t.tag, noteTagRenameDraft)">OK</button>
|
|
3023
|
+
</div>
|
|
3024
|
+
</template>
|
|
3025
|
+
</div>
|
|
3026
|
+
</template>
|
|
3027
|
+
</span>
|
|
3028
|
+
</template>
|
|
3029
|
+
</div>
|
|
3030
|
+
</template>
|
|
3031
|
+
<!-- Note list (scoped to current folder) -->
|
|
3032
|
+
<template x-for="n in noteFolderNotes()" :key="n.path">
|
|
3033
|
+
<div class="plan-row" :class="{active: selectedNote?.path === n.path}" @click="selectedNote=n;scratchActive=false;noteEditing=false;window.location.hash='#/note/'+n.path"
|
|
3034
|
+
<div class="plan-row-title" style="display:flex;align-items:center;gap:4px">
|
|
3035
|
+
<template x-if="n.pinned"><span style="font-size:10px;line-height:1" title="Pinned">📌</span></template>
|
|
3036
|
+
<span x-text="n.title"></span>
|
|
3037
|
+
</div>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
5184
|
-
const note = this.personalNotes.find(n => n.
|
|
5185
|
-
if (note) { this.selectedNote = note; this.noteEditing = false; window.location.hash = '#/note/' +
|
|
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.
|
|
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.
|
|
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
|
-
|
|
402
|
+
const val = kv[2].trim();
|
|
403
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
404
|
+
meta[currentKey] = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
405
|
+
} else {
|
|
406
|
+
meta[currentKey] = val;
|
|
407
|
+
}
|
|
403
408
|
} else if (currentKey && line.match(/^\s+-\s+(.+)$/)) {
|
|
404
409
|
const val = line.match(/^\s+-\s+(.+)$/)[1].trim();
|
|
405
410
|
if (!Array.isArray(meta[currentKey])) meta[currentKey] = meta[currentKey] ? [meta[currentKey]] : [];
|
|
@@ -1902,21 +1907,47 @@ app.post('/api/share/plan/:filename', async (req, res) => {
|
|
|
1902
1907
|
const NOTES_DIR = path.join(DATA_DIR, 'notes');
|
|
1903
1908
|
function ensureNotesDir() { if (!fs.existsSync(NOTES_DIR)) fs.mkdirSync(NOTES_DIR, { recursive: true }); }
|
|
1904
1909
|
|
|
1905
|
-
function parseNoteFile(
|
|
1906
|
-
const filePath = path.join(NOTES_DIR,
|
|
1910
|
+
function parseNoteFile(notepath) {
|
|
1911
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
1907
1912
|
const stat = fs.statSync(filePath);
|
|
1908
1913
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1909
1914
|
const { meta, content: body } = parseFrontmatter(raw);
|
|
1915
|
+
const tags = Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []);
|
|
1916
|
+
const parts = notepath.replace(/\\/g, '/').split('/');
|
|
1917
|
+
const filename = parts[parts.length - 1];
|
|
1918
|
+
const folder = parts.slice(0, -1).join('/');
|
|
1910
1919
|
return {
|
|
1911
1920
|
filename,
|
|
1921
|
+
path: notepath,
|
|
1922
|
+
folder,
|
|
1912
1923
|
title: meta.title || filename.replace('.md', ''),
|
|
1913
1924
|
date: meta.date || stat.mtimeMs,
|
|
1914
1925
|
session: meta.session || '',
|
|
1926
|
+
tags,
|
|
1927
|
+
pinned: meta.pinned === true || meta.pinned === 'true',
|
|
1915
1928
|
content: body.trim(),
|
|
1916
1929
|
modified: new Date(stat.mtimeMs).toISOString(),
|
|
1917
1930
|
};
|
|
1918
1931
|
}
|
|
1919
1932
|
|
|
1933
|
+
function scanNotes(dir, base) {
|
|
1934
|
+
if (dir === undefined) dir = NOTES_DIR;
|
|
1935
|
+
if (base === undefined) base = '';
|
|
1936
|
+
const results = [];
|
|
1937
|
+
try {
|
|
1938
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1939
|
+
const fullPath = path.join(dir, entry);
|
|
1940
|
+
const relPath = base ? `${base}/${entry}` : entry;
|
|
1941
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
1942
|
+
results.push(...scanNotes(fullPath, relPath));
|
|
1943
|
+
} else if (entry.endsWith('.md')) {
|
|
1944
|
+
results.push(parseNoteFile(relPath));
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
} catch (e) { /* ignore unreadable dirs */ }
|
|
1948
|
+
return results;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1920
1951
|
const NOTES_CLAUDE_MD_SNIPPET = `
|
|
1921
1952
|
## Personal Notes
|
|
1922
1953
|
|
|
@@ -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. **
|
|
1937
|
-
|
|
1938
|
-
|
|
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
|
|
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(
|
|
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
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
|
2033
|
-
const
|
|
2034
|
-
if (!
|
|
2035
|
-
const filePath = path.join(NOTES_DIR,
|
|
2173
|
+
app.put('/api/notes/*', (req, res) => {
|
|
2174
|
+
const notepath = req.params[0];
|
|
2175
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2176
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
2048
|
-
const
|
|
2049
|
-
if (!
|
|
2050
|
-
const filePath = path.join(NOTES_DIR,
|
|
2192
|
+
app.delete('/api/notes/*', (req, res) => {
|
|
2193
|
+
const notepath = req.params[0];
|
|
2194
|
+
if (!notepath || !notepath.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
2195
|
+
const filePath = path.join(NOTES_DIR, notepath);
|
|
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);
|