claude-home 1.5.9 → 1.5.26
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/package.json +1 -1
- package/public/index.html +361 -12
- package/server.js +215 -0
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -1548,6 +1548,17 @@
|
|
|
1548
1548
|
Plans
|
|
1549
1549
|
<span class="nav-count" x-show="plans.length>0" x-text="plans.length"></span>
|
|
1550
1550
|
</div>
|
|
1551
|
+
<div class="nav-item" :class="{ active: view === 'notes' }" @click="view='notes';selectedSession=null;loadPersonalNotes()">
|
|
1552
|
+
<span class="nav-icon">
|
|
1553
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke="currentColor">
|
|
1554
|
+
<path d="M3 2h8l3 3v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/>
|
|
1555
|
+
<polyline points="11 2 11 5 14 5"/>
|
|
1556
|
+
<line x1="5" y1="7" x2="11" y2="7"/><line x1="5" y1="10" x2="9" y2="10"/>
|
|
1557
|
+
</svg>
|
|
1558
|
+
</span>
|
|
1559
|
+
Notes
|
|
1560
|
+
<span class="nav-count" x-show="personalNotes.length>0" x-text="personalNotes.length"></span>
|
|
1561
|
+
</div>
|
|
1551
1562
|
</div>
|
|
1552
1563
|
<div class="nav-divider"></div>
|
|
1553
1564
|
<div class="nav-section">
|
|
@@ -2252,25 +2263,32 @@
|
|
|
2252
2263
|
</div>
|
|
2253
2264
|
</div>
|
|
2254
2265
|
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0">
|
|
2255
|
-
<button class="btn btn-sm" :
|
|
2266
|
+
<button class="btn btn-sm" :style="cleanMode ? 'background:var(--blue);color:#fff' : 'background:var(--canvas-2);color:var(--ink)'" @click="cleanMode=!cleanMode" title="Toggle focus view">Focus</button>
|
|
2267
|
+
<div style="flex:1"></div>
|
|
2268
|
+
<span x-show="exportMsg" x-text="exportMsg" style="font-size:12px;color:var(--green)" x-transition></span>
|
|
2269
|
+
<span x-show="deletingSession" style="font-size:12px;color:var(--ink-3)">Deleting…</span>
|
|
2270
|
+
<button class="btn btn-primary btn-sm" id="resume-detail" :disabled="!sessionDetail?.resumable" :title="!sessionDetail?.resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click="sessionDetail?.resumable && resumeSession(selectedSession.sessionId, 'resume-detail', selectedSession.projectPath)">
|
|
2271
|
+
Resume →
|
|
2272
|
+
</button>
|
|
2273
|
+
<!-- ⋮ dropdown -->
|
|
2256
2274
|
<div style="position:relative">
|
|
2257
|
-
<button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink);display:flex;align-items:center
|
|
2258
|
-
|
|
2275
|
+
<button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink);border:1px solid var(--rule);padding:4px 10px;display:flex;align-items:center" @click="exportDropOpen=!exportDropOpen" @click.outside="exportDropOpen=false" title="More options">
|
|
2276
|
+
<svg width="3" height="15" viewBox="0 0 3 15" fill="currentColor"><circle cx="1.5" cy="1.5" r="1.5"/><circle cx="1.5" cy="7.5" r="1.5"/><circle cx="1.5" cy="13.5" r="1.5"/></svg>
|
|
2259
2277
|
</button>
|
|
2260
|
-
<div x-show="exportDropOpen" x-transition style="position:absolute;top:calc(100% + 4px);right:0;background:var(--
|
|
2278
|
+
<div x-show="exportDropOpen" x-transition style="position:absolute;top:calc(100% + 4px);right:0;background:var(--canvas,#fff);border:1px solid var(--rule);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:50;min-width:180px;overflow:hidden">
|
|
2261
2279
|
<button class="export-drop-item" @click="exportSession(true); exportDropOpen=false">Download .md</button>
|
|
2262
|
-
<button class="export-drop-item" @click="copySessionMd(); exportDropOpen=false">Copy
|
|
2280
|
+
<button class="export-drop-item" @click="copySessionMd(); exportDropOpen=false">Copy as Markdown</button>
|
|
2281
|
+
<button class="export-drop-item" @click="shareSession(); exportDropOpen=false" :disabled="!!shareMsg"><span x-text="shareMsg || 'Publish to Gist'"></span></button>
|
|
2282
|
+
<button class="export-drop-item" @click="navigator.clipboard.writeText(location.origin+location.pathname+'#/session/'+selectedSession.projectDir+'/'+selectedSession.sessionId);exportDropOpen=false">Copy local link</button>
|
|
2283
|
+
<button class="export-drop-item" @click="saveNoteFromSession();exportDropOpen=false">Save as note</button>
|
|
2284
|
+
<div style="height:1px;background:var(--rule);margin:4px 0"></div>
|
|
2285
|
+
<button class="export-drop-item" style="color:var(--red)" @click="deleteSession();exportDropOpen=false" x-show="!deletingSession">Delete session</button>
|
|
2263
2286
|
</div>
|
|
2264
2287
|
</div>
|
|
2265
|
-
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deleteSession()" x-show="!deletingSession">Delete</button>
|
|
2266
|
-
<span x-show="deletingSession" style="font-size:12px;color:var(--ink-3)">Deleting…</span>
|
|
2267
|
-
<button class="btn btn-primary btn-sm" id="resume-detail" :disabled="!sessionDetail?.resumable" :title="!sessionDetail?.resumable ? 'Session file deleted — cannot resume' : 'Copy command to resume this session'" @click="sessionDetail?.resumable && resumeSession(selectedSession.sessionId, 'resume-detail', selectedSession.projectPath)">
|
|
2268
|
-
Resume →
|
|
2269
|
-
</button>
|
|
2270
|
-
<span x-show="exportMsg" x-text="exportMsg" style="font-size:12px;color:var(--green)" x-transition></span>
|
|
2271
2288
|
</div>
|
|
2272
2289
|
</div>
|
|
2273
2290
|
|
|
2291
|
+
|
|
2274
2292
|
<div class="chat-messages">
|
|
2275
2293
|
<template x-if="loadingDetail">
|
|
2276
2294
|
<div class="loading"><div class="spinner"></div> Loading conversation…</div>
|
|
@@ -2528,8 +2546,10 @@
|
|
|
2528
2546
|
<template x-if="selectedPlan">
|
|
2529
2547
|
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
|
2530
2548
|
<span class="save-msg" x-show="planExportMsg" x-text="planExportMsg"></span>
|
|
2549
|
+
<span class="save-msg" x-show="planShareMsg" x-text="planShareMsg"></span>
|
|
2531
2550
|
<button class="btn btn-sm btn-outline" @click="copyPlanMd()">Copy MD</button>
|
|
2532
2551
|
<button class="btn btn-sm btn-outline" @click="downloadPlan()">Download</button>
|
|
2552
|
+
<button class="btn btn-sm" style="background:var(--canvas-2);color:var(--ink)" title="Publish to GitHub Gist and copy public URL" @click="sharePlan(selectedPlan.filename)" :disabled="!!planShareMsg">Share</button>
|
|
2533
2553
|
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePlan()">Delete</button>
|
|
2534
2554
|
</div>
|
|
2535
2555
|
</template>
|
|
@@ -2575,6 +2595,103 @@
|
|
|
2575
2595
|
</div>
|
|
2576
2596
|
</template>
|
|
2577
2597
|
|
|
2598
|
+
<!-- Notes view -->
|
|
2599
|
+
<template x-if="view === 'notes' && !selectedSession">
|
|
2600
|
+
<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
|
|
2601
|
+
<div class="topbar">
|
|
2602
|
+
<span class="topbar-title">Notes</span>
|
|
2603
|
+
<span style="font-size:11px;color:var(--ink-3);padding:2px 8px;background:var(--canvas-2);border:1px solid var(--rule);border-radius:4px" title="These are your personal notes — Claude doesn't read or use them as context. They're only for you.">Your notepad · not Claude's memory</span>
|
|
2604
|
+
<input class="search-input" type="text" placeholder="Search notes…" x-model="noteSearch" style="font-size:12px;width:200px;padding:4px 10px" />
|
|
2605
|
+
<span style="font-size:11px;color:var(--ink-3)" x-show="personalNotes.length>0" x-text="personalNotes.length + ' notes'"></span>
|
|
2606
|
+
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
|
2607
|
+
<template x-if="selectedNote && noteEditing">
|
|
2608
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
2609
|
+
<span class="save-msg" x-show="noteMsg==='Saved'" x-text="noteMsg"></span>
|
|
2610
|
+
<span class="err-msg" x-show="noteMsg && noteMsg!=='Saved'" x-text="noteMsg"></span>
|
|
2611
|
+
<button class="btn btn-sm btn-outline" @click="noteEditing=false">Cancel</button>
|
|
2612
|
+
<button class="btn btn-primary btn-sm" @click="savePersonalNote()" :disabled="noteSaving" x-text="noteSaving?'Saving…':'Save'"></button>
|
|
2613
|
+
</div>
|
|
2614
|
+
</template>
|
|
2615
|
+
<template x-if="selectedNote && !noteEditing">
|
|
2616
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
2617
|
+
<button class="btn btn-sm btn-outline" @click="noteEditing=true;noteTitleDraft=selectedNote.title;noteBodyDraft=selectedNote.content">Edit</button>
|
|
2618
|
+
<button class="btn btn-sm" style="background:var(--red-dim,#3a1a1a);color:var(--red);border:1px solid var(--red)" @click="deletePersonalNote()">Delete</button>
|
|
2619
|
+
</div>
|
|
2620
|
+
</template>
|
|
2621
|
+
<button class="btn btn-primary btn-sm" @click="noteCreating=true;noteNewTitle='';noteNewBody=''">+ New note</button>
|
|
2622
|
+
</div>
|
|
2623
|
+
</div>
|
|
2624
|
+
|
|
2625
|
+
<!-- New note form -->
|
|
2626
|
+
<template x-if="noteCreating">
|
|
2627
|
+
<div style="padding:16px 20px;border-bottom:1px solid var(--rule);background:var(--canvas-2)">
|
|
2628
|
+
<input class="settings-input" type="text" placeholder="Note title…" x-model="noteNewTitle" style="width:100%;margin-bottom:8px;font-size:13px" @keydown.enter="createPersonalNote()" />
|
|
2629
|
+
<textarea class="settings-input" placeholder="Content (optional)…" x-model="noteNewBody" rows="3" style="width:100%;resize:vertical;font-size:12px;font-family:inherit"></textarea>
|
|
2630
|
+
<div style="display:flex;gap:8px;margin-top:8px;justify-content:flex-end">
|
|
2631
|
+
<button class="btn btn-sm btn-outline" @click="noteCreating=false">Cancel</button>
|
|
2632
|
+
<button class="btn btn-primary btn-sm" @click="createPersonalNote()" :disabled="noteSaving||!noteNewTitle.trim()">Create</button>
|
|
2633
|
+
</div>
|
|
2634
|
+
</div>
|
|
2635
|
+
</template>
|
|
2636
|
+
|
|
2637
|
+
<div style="flex:1;overflow:hidden;">
|
|
2638
|
+
<template x-if="personalNotesLoading"><div class="loading"><div class="spinner"></div> Loading…</div></template>
|
|
2639
|
+
<template x-if="!personalNotesLoading && noteClaudeInstalled === false">
|
|
2640
|
+
<div style="margin:12px 20px;padding:10px 14px;background:var(--canvas-2);border:1px solid var(--rule);border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:12px">
|
|
2641
|
+
<div>
|
|
2642
|
+
<div style="font-size:12px;font-weight:600;margin-bottom:2px">Claude doesn't know about Notes yet</div>
|
|
2643
|
+
<div style="font-size:11px;color:var(--ink-3)">Add one instruction to your CLAUDE.md so Claude can save notes when you ask.</div>
|
|
2644
|
+
</div>
|
|
2645
|
+
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
2646
|
+
<span x-show="noteSetupMsg" x-text="noteSetupMsg" style="font-size:11px;color:var(--green)"></span>
|
|
2647
|
+
<button class="btn btn-primary btn-sm" @click="setupClaudeNotes()" :disabled="!!noteSetupMsg">Set up</button>
|
|
2648
|
+
</div>
|
|
2649
|
+
</div>
|
|
2650
|
+
</template>
|
|
2651
|
+
<template x-if="!personalNotesLoading && personalNotes.length === 0">
|
|
2652
|
+
<div class="empty"><div class="empty-mark">✎</div>No notes yet. Create one or ask Claude to save one for you.</div>
|
|
2653
|
+
</template>
|
|
2654
|
+
<template x-if="!personalNotesLoading && personalNotes.length > 0">
|
|
2655
|
+
<div class="memory-layout">
|
|
2656
|
+
<div class="memory-sidebar" :style="'width:'+sidebarW+'px'">
|
|
2657
|
+
<template x-for="n in personalNotes.filter(n => !noteSearch || n.title.toLowerCase().includes(noteSearch.toLowerCase()) || n.content.toLowerCase().includes(noteSearch.toLowerCase()))" :key="n.filename">
|
|
2658
|
+
<div class="plan-row" :class="{active: selectedNote?.filename === n.filename}" @click="selectedNote=n;noteEditing=false;window.location.hash='#/note/'+n.filename">
|
|
2659
|
+
<div class="plan-row-title" x-text="n.title"></div>
|
|
2660
|
+
<div class="plan-row-meta">
|
|
2661
|
+
<span x-text="formatDate(n.modified)"></span>
|
|
2662
|
+
<template x-if="n.session"><span style="color:var(--blue);font-size:10px">session</span></template>
|
|
2663
|
+
</div>
|
|
2664
|
+
</div>
|
|
2665
|
+
</template>
|
|
2666
|
+
</div>
|
|
2667
|
+
<div class="resize-handle" @mousedown.prevent="startResize($event)"></div>
|
|
2668
|
+
<div class="memory-detail">
|
|
2669
|
+
<template x-if="!selectedNote"><div class="memory-empty"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>Select a note</div></template>
|
|
2670
|
+
<template x-if="selectedNote && !noteEditing">
|
|
2671
|
+
<div>
|
|
2672
|
+
<div class="memory-detail-title" x-text="selectedNote.title"></div>
|
|
2673
|
+
<div class="memory-detail-meta">
|
|
2674
|
+
<span style="font-size:11px;color:var(--ink-3)" x-text="formatDate(selectedNote.modified)"></span>
|
|
2675
|
+
<template x-if="selectedNote.session">
|
|
2676
|
+
<span style="font-size:11px;color:var(--blue);cursor:pointer" @click="openSessionById(selectedNote.session)" x-text="'→ Session ' + selectedNote.session.slice(0,8) + '…'"></span>
|
|
2677
|
+
</template>
|
|
2678
|
+
</div>
|
|
2679
|
+
<div class="memory-content md-content" x-html="selectedNote.content ? renderMd(selectedNote.content) : '<span style=\'color:var(--ink-3)\'>Empty note. Click Edit to add content.</span>'"></div>
|
|
2680
|
+
</div>
|
|
2681
|
+
</template>
|
|
2682
|
+
<template x-if="selectedNote && noteEditing">
|
|
2683
|
+
<div style="display:flex;flex-direction:column;height:100%;gap:10px">
|
|
2684
|
+
<input class="settings-input" type="text" x-model="noteTitleDraft" style="font-size:14px;font-weight:600;width:100%" />
|
|
2685
|
+
<textarea class="settings-input" x-model="noteBodyDraft" style="flex:1;resize:none;font-family:monospace;font-size:12px;min-height:300px;width:100%"></textarea>
|
|
2686
|
+
</div>
|
|
2687
|
+
</template>
|
|
2688
|
+
</div>
|
|
2689
|
+
</div>
|
|
2690
|
+
</template>
|
|
2691
|
+
</div>
|
|
2692
|
+
</div>
|
|
2693
|
+
</template>
|
|
2694
|
+
|
|
2578
2695
|
<!-- Memory view -->
|
|
2579
2696
|
<template x-if="view === 'memory' && !selectedSession">
|
|
2580
2697
|
<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">
|
|
@@ -3380,6 +3497,58 @@
|
|
|
3380
3497
|
</div>
|
|
3381
3498
|
<template x-if="config.settings?.statusLine?.command"><div class="config-row"><div class="config-key">Status line</div><div class="config-val" x-text="config.settings.statusLine.command"></div></div></template>
|
|
3382
3499
|
</div>
|
|
3500
|
+
|
|
3501
|
+
<div class="config-section">
|
|
3502
|
+
<div class="config-section-title" style="display:flex;align-items:center;justify-content:space-between">
|
|
3503
|
+
Sharing
|
|
3504
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
3505
|
+
<span class="save-msg" x-show="githubTokenMsg==='Saved'" x-text="githubTokenMsg"></span>
|
|
3506
|
+
<span class="err-msg" x-show="githubTokenMsg && githubTokenMsg!=='Saved'" x-text="githubTokenMsg"></span>
|
|
3507
|
+
<button class="btn btn-primary btn-sm" @click="saveGithubToken()" :disabled="githubTokenSaving || !githubTokenDraft">
|
|
3508
|
+
<span x-text="githubTokenSaving?'Saving…':'Save'"></span>
|
|
3509
|
+
</button>
|
|
3510
|
+
</div>
|
|
3511
|
+
</div>
|
|
3512
|
+
<div class="config-row">
|
|
3513
|
+
<div class="config-key">GitHub token</div>
|
|
3514
|
+
<div class="config-val" style="flex:1">
|
|
3515
|
+
<input type="password" class="settings-input" x-model="githubTokenDraft" placeholder="ghp_…" style="width:100%;font-family:monospace" />
|
|
3516
|
+
<div style="font-size:11px;color:var(--ink-3);margin-top:4px">
|
|
3517
|
+
<template x-if="githubTokenSet">
|
|
3518
|
+
<span>Token saved (<span x-text="githubTokenMasked"></span>). Enter a new value to replace it.</span>
|
|
3519
|
+
</template>
|
|
3520
|
+
<template x-if="!githubTokenSet">
|
|
3521
|
+
<span>Required to publish sessions and plans as public GitHub Gists. Generate a classic token at github.com/settings/tokens with the <strong>gist</strong> scope checked.</span>
|
|
3522
|
+
</template>
|
|
3523
|
+
</div>
|
|
3524
|
+
</div>
|
|
3525
|
+
</div>
|
|
3526
|
+
</div>
|
|
3527
|
+
|
|
3528
|
+
<div class="config-section">
|
|
3529
|
+
<div class="config-section-title">Integrations</div>
|
|
3530
|
+
<div class="config-row">
|
|
3531
|
+
<div class="config-key">Notes</div>
|
|
3532
|
+
<div class="config-val" style="flex:1">
|
|
3533
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
3534
|
+
<template x-if="noteClaudeInstalled">
|
|
3535
|
+
<span style="font-size:12px;color:var(--green);display:flex;align-items:center;gap:4px">
|
|
3536
|
+
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="2"><polyline points="2 7 5 10 11 3"/></svg>
|
|
3537
|
+
Enabled
|
|
3538
|
+
</span>
|
|
3539
|
+
</template>
|
|
3540
|
+
<template x-if="!noteClaudeInstalled">
|
|
3541
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
3542
|
+
<span x-show="noteSetupMsg" x-text="noteSetupMsg" style="font-size:12px;color:var(--green)"></span>
|
|
3543
|
+
<button class="btn btn-primary btn-sm" @click="setupClaudeNotes()" :disabled="!!noteSetupMsg">Set up</button>
|
|
3544
|
+
</div>
|
|
3545
|
+
</template>
|
|
3546
|
+
</div>
|
|
3547
|
+
<div style="font-size:11px;color:var(--ink-3);margin-top:4px">Adds an instruction to your CLAUDE.md so Claude can save notes when you ask. Notes are your personal notepad — Claude won't read them as context.</div>
|
|
3548
|
+
</div>
|
|
3549
|
+
</div>
|
|
3550
|
+
</div>
|
|
3551
|
+
|
|
3383
3552
|
</div>
|
|
3384
3553
|
</div>
|
|
3385
3554
|
</template>
|
|
@@ -4165,8 +4334,24 @@
|
|
|
4165
4334
|
exportDropOpen: false,
|
|
4166
4335
|
exportMsg: '',
|
|
4167
4336
|
cleanMode: true,
|
|
4337
|
+
|
|
4168
4338
|
planExportMsg: '',
|
|
4169
4339
|
planExportOpen: false,
|
|
4340
|
+
planShareMsg: '',
|
|
4341
|
+
personalNotes: [],
|
|
4342
|
+
personalNotesLoading: false,
|
|
4343
|
+
selectedNote: null,
|
|
4344
|
+
noteEditing: false,
|
|
4345
|
+
noteTitleDraft: '',
|
|
4346
|
+
noteBodyDraft: '',
|
|
4347
|
+
noteMsg: '',
|
|
4348
|
+
noteSaving: false,
|
|
4349
|
+
noteSearch: '',
|
|
4350
|
+
noteCreating: false,
|
|
4351
|
+
noteNewTitle: '',
|
|
4352
|
+
noteNewBody: '',
|
|
4353
|
+
noteClaudeInstalled: null,
|
|
4354
|
+
noteSetupMsg: '',
|
|
4170
4355
|
sidebarW: parseInt(localStorage.getItem('cm:sidebarW') || '260'),
|
|
4171
4356
|
historyEntries: [],
|
|
4172
4357
|
historyLoading: false,
|
|
@@ -4210,6 +4395,13 @@
|
|
|
4210
4395
|
settingsDraft: { model: '', language: '', voiceEnabled: false, outputStyle: '', effortLevel: '', defaultMode: '' },
|
|
4211
4396
|
settingsSaving: false,
|
|
4212
4397
|
settingsMsg: '',
|
|
4398
|
+
githubTokenDraft: '',
|
|
4399
|
+
githubTokenSet: false,
|
|
4400
|
+
githubTokenMasked: '',
|
|
4401
|
+
githubTokenSaving: false,
|
|
4402
|
+
githubTokenMsg: '',
|
|
4403
|
+
shareMsg: '',
|
|
4404
|
+
shareUrl: '',
|
|
4213
4405
|
permsDraft: { allow: [], deny: [], ask: [] },
|
|
4214
4406
|
permsNewRule: { allow: '', deny: '', ask: '' },
|
|
4215
4407
|
ruleBuilder: { type: 'deny', tool: 'Bash', specifier: '', pathType: './' },
|
|
@@ -4325,16 +4517,36 @@
|
|
|
4325
4517
|
this.loadCosts();
|
|
4326
4518
|
this.loadStatus();
|
|
4327
4519
|
this.loadInsights();
|
|
4520
|
+
// Load counts for nav badges without blocking
|
|
4521
|
+
this.loadPlans();
|
|
4522
|
+
this.loadAgents();
|
|
4523
|
+
this.loadTools();
|
|
4524
|
+
this.loadMemory();
|
|
4525
|
+
this.loadPersonalNotes();
|
|
4526
|
+
// Parse URL hash for direct links
|
|
4527
|
+
const hash = window.location.hash;
|
|
4528
|
+
const mSession = hash.match(/^#\/session\/([^/]+)\/([^/]+)$/);
|
|
4529
|
+
const mNote = hash.match(/^#\/note\/(.+)$/);
|
|
4530
|
+
if (mSession) { this.view = 'sessions'; await this.openSessionById(mSession[2], mSession[1]); }
|
|
4531
|
+
if (mNote) {
|
|
4532
|
+
await this.loadPersonalNotes();
|
|
4533
|
+
const note = this.personalNotes.find(n => n.filename === mNote[1]);
|
|
4534
|
+
if (note) { this.view = 'notes'; this.selectedNote = note; }
|
|
4535
|
+
}
|
|
4536
|
+
// Clear hash when closing
|
|
4537
|
+
this.$watch('selectedSession', v => { if (!v) { if (window.location.hash.startsWith('#/session/')) window.location.hash = ''; } });
|
|
4538
|
+
this.$watch('selectedNote', v => { if (!v) { if (window.location.hash.startsWith('#/note/')) window.location.hash = ''; } });
|
|
4328
4539
|
},
|
|
4329
4540
|
|
|
4330
4541
|
initView(v) {
|
|
4331
4542
|
if (v === 'dashboard') { this.loadStats(); this.loadInsights(); }
|
|
4332
4543
|
if (v === 'projects') { this.loadProjects(); }
|
|
4333
4544
|
if (v === 'memory') { this.loadMemory(); }
|
|
4545
|
+
if (v === 'notes') { this.loadPersonalNotes(); }
|
|
4334
4546
|
if (v === 'plans') { this.loadPlans(); }
|
|
4335
4547
|
if (v === 'commands' || v === 'skills') { this.loadTools(); }
|
|
4336
4548
|
if (v === 'agents') { this.loadAgents(); }
|
|
4337
|
-
if (v === 'config' || v === 'instructions' || v === 'permissions' || v === 'hooks') { this.loadConfig(); }
|
|
4549
|
+
if (v === 'config' || v === 'instructions' || v === 'permissions' || v === 'hooks') { this.loadConfig(); this.checkNoteClaudeStatus(); }
|
|
4338
4550
|
},
|
|
4339
4551
|
|
|
4340
4552
|
async loadProjects() {
|
|
@@ -4425,6 +4637,8 @@
|
|
|
4425
4637
|
this.selectedSession = session;
|
|
4426
4638
|
this.loadingDetail = true;
|
|
4427
4639
|
this.sessionDetail = null;
|
|
4640
|
+
|
|
4641
|
+
window.location.hash = `#/session/${session.projectDir}/${session.sessionId}`;
|
|
4428
4642
|
const data = await fetch(`/api/sessions/${session.projectDir}/${session.sessionId}`).then(r => r.json());
|
|
4429
4643
|
this.sessionDetail = data;
|
|
4430
4644
|
this.loadingDetail = false;
|
|
@@ -4439,6 +4653,7 @@
|
|
|
4439
4653
|
await this.openSession(s);
|
|
4440
4654
|
},
|
|
4441
4655
|
|
|
4656
|
+
|
|
4442
4657
|
async resumeSession(sessionId, btnId, projectPath) {
|
|
4443
4658
|
const cmd = projectPath
|
|
4444
4659
|
? `cd "${projectPath}" && claude -r ${sessionId}`
|
|
@@ -5147,6 +5362,136 @@
|
|
|
5147
5362
|
} catch (e) { alert('Error: ' + e.message); }
|
|
5148
5363
|
},
|
|
5149
5364
|
|
|
5365
|
+
async saveGithubToken() {
|
|
5366
|
+
this.githubTokenSaving = true; this.githubTokenMsg = '';
|
|
5367
|
+
try {
|
|
5368
|
+
const r = await fetch('/api/app-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ githubToken: this.githubTokenDraft }) });
|
|
5369
|
+
if (!r.ok) throw new Error((await r.json()).error);
|
|
5370
|
+
this.githubTokenSet = !!this.githubTokenDraft;
|
|
5371
|
+
this.githubTokenMasked = this.githubTokenDraft ? this.githubTokenDraft.slice(0,4) + '…' + this.githubTokenDraft.slice(-4) : '';
|
|
5372
|
+
this.githubTokenDraft = '';
|
|
5373
|
+
this.githubTokenMsg = 'Saved';
|
|
5374
|
+
setTimeout(() => this.githubTokenMsg = '', 2000);
|
|
5375
|
+
} catch (e) { this.githubTokenMsg = 'Error: ' + e.message; }
|
|
5376
|
+
this.githubTokenSaving = false;
|
|
5377
|
+
},
|
|
5378
|
+
|
|
5379
|
+
async shareSession() {
|
|
5380
|
+
this.shareMsg = 'Sharing…'; this.shareUrl = '';
|
|
5381
|
+
try {
|
|
5382
|
+
const r = await fetch(`/api/share/session/${this.selectedSession.projectDir}/${this.selectedSession.sessionId}`, { method: 'POST' });
|
|
5383
|
+
const data = await r.json();
|
|
5384
|
+
if (!r.ok) throw new Error(data.error);
|
|
5385
|
+
this.shareUrl = data.url;
|
|
5386
|
+
await navigator.clipboard.writeText(data.url);
|
|
5387
|
+
this.shareMsg = 'Link copied!';
|
|
5388
|
+
} catch (e) { this.shareMsg = e.message; }
|
|
5389
|
+
setTimeout(() => { this.shareMsg = ''; this.shareUrl = ''; }, 5000);
|
|
5390
|
+
},
|
|
5391
|
+
|
|
5392
|
+
async sharePlan(filename) {
|
|
5393
|
+
this.planShareMsg = 'Sharing…';
|
|
5394
|
+
try {
|
|
5395
|
+
const r = await fetch(`/api/share/plan/${filename}`, { method: 'POST' });
|
|
5396
|
+
const data = await r.json();
|
|
5397
|
+
if (!r.ok) throw new Error(data.error);
|
|
5398
|
+
await navigator.clipboard.writeText(data.url);
|
|
5399
|
+
this.planShareMsg = 'Link copied!';
|
|
5400
|
+
} catch (e) { this.planShareMsg = e.message; }
|
|
5401
|
+
setTimeout(() => this.planShareMsg = '', 5000);
|
|
5402
|
+
},
|
|
5403
|
+
|
|
5404
|
+
async loadPersonalNotes() {
|
|
5405
|
+
if (this.personalNotesLoading) return;
|
|
5406
|
+
this.personalNotesLoading = true;
|
|
5407
|
+
const [notes, status] = await Promise.all([
|
|
5408
|
+
fetch('/api/notes').then(r => r.json()).catch(() => []),
|
|
5409
|
+
fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({})),
|
|
5410
|
+
]);
|
|
5411
|
+
this.personalNotes = notes;
|
|
5412
|
+
this.noteClaudeInstalled = status.installed ?? null;
|
|
5413
|
+
this.personalNotesLoading = false;
|
|
5414
|
+
},
|
|
5415
|
+
|
|
5416
|
+
async checkNoteClaudeStatus() {
|
|
5417
|
+
const s = await fetch('/api/notes/claude-md-status').then(r => r.json()).catch(() => ({}));
|
|
5418
|
+
this.noteClaudeInstalled = s.installed ?? null;
|
|
5419
|
+
},
|
|
5420
|
+
|
|
5421
|
+
async setupClaudeNotes() {
|
|
5422
|
+
this.noteSetupMsg = 'Installing…';
|
|
5423
|
+
try {
|
|
5424
|
+
const r = await fetch('/api/notes/setup-claude', { method: 'POST' });
|
|
5425
|
+
const d = await r.json();
|
|
5426
|
+
this.noteClaudeInstalled = true;
|
|
5427
|
+
this.noteSetupMsg = d.alreadyInstalled ? 'Already installed' : 'Installed!';
|
|
5428
|
+
} catch (e) { this.noteSetupMsg = 'Error: ' + e.message; }
|
|
5429
|
+
setTimeout(() => this.noteSetupMsg = '', 3000);
|
|
5430
|
+
},
|
|
5431
|
+
|
|
5432
|
+
async createPersonalNote() {
|
|
5433
|
+
if (!this.noteNewTitle.trim()) return;
|
|
5434
|
+
this.noteSaving = true;
|
|
5435
|
+
try {
|
|
5436
|
+
const note = await fetch('/api/notes', {
|
|
5437
|
+
method: 'POST',
|
|
5438
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5439
|
+
body: JSON.stringify({ title: this.noteNewTitle, content: this.noteNewBody }),
|
|
5440
|
+
}).then(r => r.json());
|
|
5441
|
+
this.personalNotes.unshift(note);
|
|
5442
|
+
this.selectedNote = note;
|
|
5443
|
+
this.noteCreating = false;
|
|
5444
|
+
this.noteNewTitle = '';
|
|
5445
|
+
this.noteNewBody = '';
|
|
5446
|
+
this.noteEditing = false;
|
|
5447
|
+
} catch (e) { this.noteMsg = 'Error: ' + e.message; }
|
|
5448
|
+
this.noteSaving = false;
|
|
5449
|
+
},
|
|
5450
|
+
|
|
5451
|
+
async savePersonalNote() {
|
|
5452
|
+
if (!this.selectedNote) return;
|
|
5453
|
+
this.noteSaving = true; this.noteMsg = '';
|
|
5454
|
+
try {
|
|
5455
|
+
const updated = await fetch(`/api/notes/${this.selectedNote.filename}`, {
|
|
5456
|
+
method: 'PUT',
|
|
5457
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5458
|
+
body: JSON.stringify({ title: this.noteTitleDraft, content: this.noteBodyDraft }),
|
|
5459
|
+
}).then(r => r.json());
|
|
5460
|
+
const idx = this.personalNotes.findIndex(n => n.filename === this.selectedNote.filename);
|
|
5461
|
+
if (idx !== -1) this.personalNotes[idx] = updated;
|
|
5462
|
+
this.selectedNote = updated;
|
|
5463
|
+
this.noteEditing = false;
|
|
5464
|
+
this.noteMsg = 'Saved';
|
|
5465
|
+
setTimeout(() => this.noteMsg = '', 2000);
|
|
5466
|
+
} catch (e) { this.noteMsg = 'Error: ' + e.message; }
|
|
5467
|
+
this.noteSaving = false;
|
|
5468
|
+
},
|
|
5469
|
+
|
|
5470
|
+
async deletePersonalNote() {
|
|
5471
|
+
if (!this.selectedNote || !confirm(`Delete "${this.selectedNote.title}"?`)) return;
|
|
5472
|
+
await fetch(`/api/notes/${this.selectedNote.filename}`, { method: 'DELETE' });
|
|
5473
|
+
this.personalNotes = this.personalNotes.filter(n => n.filename !== this.selectedNote.filename);
|
|
5474
|
+
this.selectedNote = null;
|
|
5475
|
+
this.noteEditing = false;
|
|
5476
|
+
},
|
|
5477
|
+
|
|
5478
|
+
async saveNoteFromSession() {
|
|
5479
|
+
const title = this.selectedSession?.firstPrompt?.slice(0, 60) || 'Session note';
|
|
5480
|
+
const sessionId = this.selectedSession?.sessionId || '';
|
|
5481
|
+
const note = await fetch('/api/notes', {
|
|
5482
|
+
method: 'POST',
|
|
5483
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5484
|
+
body: JSON.stringify({ title, content: '', session: sessionId }),
|
|
5485
|
+
}).then(r => r.json());
|
|
5486
|
+
this.personalNotes.unshift(note);
|
|
5487
|
+
this.selectedNote = note;
|
|
5488
|
+
this.noteEditing = true;
|
|
5489
|
+
this.noteTitleDraft = note.title;
|
|
5490
|
+
this.noteBodyDraft = note.content;
|
|
5491
|
+
this.view = 'notes';
|
|
5492
|
+
this.selectedSession = null;
|
|
5493
|
+
},
|
|
5494
|
+
|
|
5150
5495
|
async saveSettings() {
|
|
5151
5496
|
this.settingsSaving = true; this.settingsMsg = '';
|
|
5152
5497
|
try {
|
|
@@ -5576,6 +5921,10 @@
|
|
|
5576
5921
|
const proj = this.projects?.find(p => p.dirName === this.configProject);
|
|
5577
5922
|
const pp = proj?.projectPath ? '?projectPath=' + encodeURIComponent(proj.projectPath) : '';
|
|
5578
5923
|
this.config = await fetch('/api/config' + pp).then(r => r.json());
|
|
5924
|
+
const as = await fetch('/api/app-settings').then(r => r.json()).catch(() => ({}));
|
|
5925
|
+
this.githubTokenSet = as.githubTokenSet || false;
|
|
5926
|
+
this.githubTokenMasked = as.githubTokenMasked || '';
|
|
5927
|
+
this.githubTokenDraft = '';
|
|
5579
5928
|
this.settingsDraft = {
|
|
5580
5929
|
model: this.config.settings?.model || '',
|
|
5581
5930
|
language: this.config.settings?.language || '',
|
package/server.js
CHANGED
|
@@ -1767,6 +1767,221 @@ app.get('/api/sessions/:project/:sessionId/export', async (req, res) => {
|
|
|
1767
1767
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1768
1768
|
});
|
|
1769
1769
|
|
|
1770
|
+
// ─── App settings (claude-home specific, not Claude's settings.json) ─────────
|
|
1771
|
+
const APP_SETTINGS_FILE = path.join(DATA_DIR, 'app-settings.json');
|
|
1772
|
+
|
|
1773
|
+
function readAppSettings() {
|
|
1774
|
+
try { return JSON.parse(fs.readFileSync(APP_SETTINGS_FILE, 'utf8')); } catch { return {}; }
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
app.get('/api/app-settings', (req, res) => {
|
|
1778
|
+
const s = readAppSettings();
|
|
1779
|
+
// Mask token for display
|
|
1780
|
+
const masked = s.githubToken ? s.githubToken.slice(0, 4) + '…' + s.githubToken.slice(-4) : '';
|
|
1781
|
+
res.json({ githubTokenSet: !!s.githubToken, githubTokenMasked: masked });
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
app.put('/api/app-settings', (req, res) => {
|
|
1785
|
+
try {
|
|
1786
|
+
const current = readAppSettings();
|
|
1787
|
+
const { githubToken } = req.body;
|
|
1788
|
+
if (githubToken !== undefined) current.githubToken = githubToken;
|
|
1789
|
+
ensureDataDir();
|
|
1790
|
+
fs.writeFileSync(APP_SETTINGS_FILE, JSON.stringify(current, null, 2), 'utf8');
|
|
1791
|
+
res.json({ ok: true });
|
|
1792
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
// ─── Gist sharing ─────────────────────────────────────────────────────────────
|
|
1796
|
+
function postGist(token, description, filename, content) {
|
|
1797
|
+
return new Promise((resolve, reject) => {
|
|
1798
|
+
const body = JSON.stringify({ description, public: true, files: { [filename]: { content } } });
|
|
1799
|
+
const opts = {
|
|
1800
|
+
hostname: 'api.github.com',
|
|
1801
|
+
path: '/gists',
|
|
1802
|
+
method: 'POST',
|
|
1803
|
+
headers: {
|
|
1804
|
+
'Authorization': `Bearer ${token}`,
|
|
1805
|
+
'Content-Type': 'application/json',
|
|
1806
|
+
'User-Agent': 'claude-home',
|
|
1807
|
+
'Accept': 'application/vnd.github+json',
|
|
1808
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1809
|
+
},
|
|
1810
|
+
};
|
|
1811
|
+
const req = https.request(opts, res => {
|
|
1812
|
+
let data = '';
|
|
1813
|
+
res.on('data', c => data += c);
|
|
1814
|
+
res.on('end', () => {
|
|
1815
|
+
try {
|
|
1816
|
+
const json = JSON.parse(data);
|
|
1817
|
+
if (json.html_url) resolve(json.html_url);
|
|
1818
|
+
else {
|
|
1819
|
+
const msg = json.message || 'GitHub API error';
|
|
1820
|
+
const hint = (msg === 'Not Found' || msg === 'Bad credentials')
|
|
1821
|
+
? `${msg} — check your token has the "gist" scope`
|
|
1822
|
+
: msg;
|
|
1823
|
+
reject(new Error(hint));
|
|
1824
|
+
}
|
|
1825
|
+
} catch { reject(new Error('Invalid response from GitHub')); }
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
req.on('error', reject);
|
|
1829
|
+
req.write(body);
|
|
1830
|
+
req.end();
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
app.post('/api/share/session/:project/:sessionId', async (req, res) => {
|
|
1835
|
+
const { githubToken } = readAppSettings();
|
|
1836
|
+
if (!githubToken) return res.status(400).json({ error: 'GitHub token not configured. Add it in Settings → Sharing.' });
|
|
1837
|
+
const { project, sessionId } = req.params;
|
|
1838
|
+
const filePath = path.join(PROJECTS_DIR, project, `${sessionId}.jsonl`);
|
|
1839
|
+
if (!filePath.startsWith(PROJECTS_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
1840
|
+
try {
|
|
1841
|
+
const messages = await parseJsonl(filePath);
|
|
1842
|
+
const lines = [`# Session ${sessionId}`, `**Project:** ${project}`, ''];
|
|
1843
|
+
for (const m of messages) {
|
|
1844
|
+
if (m.type === 'user') {
|
|
1845
|
+
const text = typeof m.message?.content === 'string' ? m.message.content
|
|
1846
|
+
: (m.message?.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
1847
|
+
if (text.trim()) lines.push(`## Human\n\n${text.trim()}`, '');
|
|
1848
|
+
} else if (m.type === 'assistant') {
|
|
1849
|
+
const text = typeof m.message?.content === 'string' ? m.message.content
|
|
1850
|
+
: (m.message?.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
1851
|
+
if (text.trim()) lines.push(`## Claude\n\n${text.trim()}`, '');
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
const url = await postGist(githubToken, `Claude session — ${project}`, `${sessionId}.md`, lines.join('\n'));
|
|
1855
|
+
res.json({ url });
|
|
1856
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
app.post('/api/share/plan/:filename', async (req, res) => {
|
|
1860
|
+
const { githubToken } = readAppSettings();
|
|
1861
|
+
if (!githubToken) return res.status(400).json({ error: 'GitHub token not configured. Add it in Settings → Sharing.' });
|
|
1862
|
+
const filename = path.basename(req.params.filename);
|
|
1863
|
+
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
1864
|
+
const filePath = path.join(os.homedir(), '.claude', 'plans', filename);
|
|
1865
|
+
try {
|
|
1866
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1867
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
1868
|
+
const description = titleMatch ? titleMatch[1].trim() : filename;
|
|
1869
|
+
const url = await postGist(githubToken, `Claude plan — ${description}`, filename, content);
|
|
1870
|
+
res.json({ url });
|
|
1871
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ─── Personal Notes ───────────────────────────────────────────────────────────
|
|
1875
|
+
const NOTES_DIR = path.join(DATA_DIR, 'notes');
|
|
1876
|
+
function ensureNotesDir() { if (!fs.existsSync(NOTES_DIR)) fs.mkdirSync(NOTES_DIR, { recursive: true }); }
|
|
1877
|
+
|
|
1878
|
+
function parseNoteFile(filename) {
|
|
1879
|
+
const filePath = path.join(NOTES_DIR, filename);
|
|
1880
|
+
const stat = fs.statSync(filePath);
|
|
1881
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
1882
|
+
const { meta, content: body } = parseFrontmatter(raw);
|
|
1883
|
+
return {
|
|
1884
|
+
filename,
|
|
1885
|
+
title: meta.title || filename.replace('.md', ''),
|
|
1886
|
+
date: meta.date || stat.mtimeMs,
|
|
1887
|
+
session: meta.session || '',
|
|
1888
|
+
content: body.trim(),
|
|
1889
|
+
modified: new Date(stat.mtimeMs).toISOString(),
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const NOTES_CLAUDE_MD_SNIPPET = `
|
|
1894
|
+
## Personal Notes
|
|
1895
|
+
|
|
1896
|
+
When the user asks you to "save a note", "add to notes", "guarda esto como nota", or similar:
|
|
1897
|
+
1. Create a markdown file in \`~/.claude/claude-home/notes/\` with this exact format:
|
|
1898
|
+
- Filename: \`YYYY-MM-DD-short-slug.md\` (e.g. \`2026-03-31-bug-fix-auth.md\`)
|
|
1899
|
+
- Content:
|
|
1900
|
+
\`\`\`
|
|
1901
|
+
---
|
|
1902
|
+
title: <descriptive title>
|
|
1903
|
+
date: <current ISO date>
|
|
1904
|
+
session: <current session ID if known>
|
|
1905
|
+
---
|
|
1906
|
+
|
|
1907
|
+
<the content the user wants to save>
|
|
1908
|
+
\`\`\`
|
|
1909
|
+
2. Use the Bash tool to write the file.
|
|
1910
|
+
3. Confirm with: "Saved to Notes: http://localhost:3141/#/note/<filename>"
|
|
1911
|
+
|
|
1912
|
+
The notes directory may not exist yet — create it if needed with \`mkdir -p\`.
|
|
1913
|
+
`;
|
|
1914
|
+
|
|
1915
|
+
app.get('/api/notes/claude-md-status', (req, res) => {
|
|
1916
|
+
const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
1917
|
+
try {
|
|
1918
|
+
const content = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, 'utf8') : '';
|
|
1919
|
+
res.json({ installed: content.includes('Personal Notes') });
|
|
1920
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
app.post('/api/notes/setup-claude', (req, res) => {
|
|
1924
|
+
const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
1925
|
+
try {
|
|
1926
|
+
const current = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, 'utf8') : '';
|
|
1927
|
+
if (current.includes('Personal Notes')) return res.json({ ok: true, alreadyInstalled: true });
|
|
1928
|
+
fs.writeFileSync(claudeMdPath, current + '\n' + NOTES_CLAUDE_MD_SNIPPET, 'utf8');
|
|
1929
|
+
res.json({ ok: true });
|
|
1930
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
app.get('/api/notes', (req, res) => {
|
|
1934
|
+
ensureNotesDir();
|
|
1935
|
+
try {
|
|
1936
|
+
const files = fs.readdirSync(NOTES_DIR).filter(f => f.endsWith('.md'));
|
|
1937
|
+
const notes = files.map(f => parseNoteFile(f)).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
1938
|
+
res.json(notes);
|
|
1939
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
app.post('/api/notes', (req, res) => {
|
|
1943
|
+
ensureNotesDir();
|
|
1944
|
+
const { title, content, session } = req.body;
|
|
1945
|
+
if (!title) return res.status(400).json({ error: 'title required' });
|
|
1946
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'note';
|
|
1947
|
+
const date = new Date().toISOString();
|
|
1948
|
+
const datePrefix = date.slice(0, 10);
|
|
1949
|
+
let filename = `${datePrefix}-${slug}.md`;
|
|
1950
|
+
// avoid collisions
|
|
1951
|
+
let i = 1;
|
|
1952
|
+
while (fs.existsSync(path.join(NOTES_DIR, filename))) { filename = `${datePrefix}-${slug}-${i++}.md`; }
|
|
1953
|
+
const sessionLine = session ? `\nsession: ${session}` : '';
|
|
1954
|
+
const raw = `---\ntitle: ${title}\ndate: ${date}${sessionLine}\n---\n\n${content || ''}`;
|
|
1955
|
+
fs.writeFileSync(path.join(NOTES_DIR, filename), raw, 'utf8');
|
|
1956
|
+
res.json(parseNoteFile(filename));
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
app.put('/api/notes/:filename', (req, res) => {
|
|
1960
|
+
const filename = path.basename(req.params.filename);
|
|
1961
|
+
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
1962
|
+
const filePath = path.join(NOTES_DIR, filename);
|
|
1963
|
+
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
1964
|
+
const { title, content } = req.body;
|
|
1965
|
+
try {
|
|
1966
|
+
const existing = parseNoteFile(filename);
|
|
1967
|
+
const sessionLine = existing.session ? `\nsession: ${existing.session}` : '';
|
|
1968
|
+
const raw = `---\ntitle: ${title || existing.title}\ndate: ${existing.date}${sessionLine}\n---\n\n${content ?? existing.content}`;
|
|
1969
|
+
fs.writeFileSync(filePath, raw, 'utf8');
|
|
1970
|
+
res.json(parseNoteFile(filename));
|
|
1971
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
app.delete('/api/notes/:filename', (req, res) => {
|
|
1975
|
+
const filename = path.basename(req.params.filename);
|
|
1976
|
+
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'invalid filename' });
|
|
1977
|
+
const filePath = path.join(NOTES_DIR, filename);
|
|
1978
|
+
if (!filePath.startsWith(NOTES_DIR)) return res.status(400).json({ error: 'invalid path' });
|
|
1979
|
+
try {
|
|
1980
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
1981
|
+
res.json({ ok: true });
|
|
1982
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1770
1985
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
1771
1986
|
|
|
1772
1987
|
function startServer(port) {
|