@stravica-ai/digital-operator-pa 0.1.68 → 0.1.69
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.
|
@@ -199,6 +199,37 @@ Live reload was temporarily removed pending a workspace rebuild. Refresh the bro
|
|
|
199
199
|
- **Writes.** The PA writes the board only with `pa status set | patch | show` (validated, atomic temp-then-rename). Bare `pa status` is still the environment probe.
|
|
200
200
|
- **Behaviour.** Refreshes every 60 seconds and when the tab comes back into view, without reloading (scroll position and the "Done today" open state survive). The freshness pill turns amber at 45 minutes and red at 90 since `updatedAt`. A bad file (unparseable or off-schema) keeps the last good copy on screen behind a red banner. No board yet shows a plain "No status board yet today" card and keeps checking.
|
|
201
201
|
|
|
202
|
+
## Notes page (notes API)
|
|
203
|
+
|
|
204
|
+
`app/notes.html` is the core **Notes** page. It lists every note the PA and the operator keep, newest first, and opens each one in the viewer. The home page notes rail shows the first eight of the same list.
|
|
205
|
+
|
|
206
|
+
- **Roots.** `GET /__api/notes` walks these notes roots, in order:
|
|
207
|
+
1. `operator/notes`, the instance's canonical notes directory (the scaffold creates it). Always first.
|
|
208
|
+
2. `agents/<paName>/workspace/notes`, where the running PA writes its session notes beside its status board. `paName` resolves the same way the Status page does.
|
|
209
|
+
3. Each repo-relative path in the preferences key `notesRoots` (an array of strings in `operator/config/preferences.json` or the local overlay), in the order given. Example: `"notesRoots": ["agents/<other-agent>/workspace/notes", "shared/notes"]`.
|
|
210
|
+
|
|
211
|
+
With no `notesRoots` key the first two roots are walked; that is the default. Discovery covers the resolved PA only, not every `agents/*` folder, so a second agent's notes need a `notesRoots` entry. A configured root that is not a non-empty string, names the repo root itself or resolves outside the repo (by path or through a symlink) is refused: the server logs a warning and never walks it. A root named twice is walked once, and a note under a root nested inside an earlier root is listed once, under the earlier root.
|
|
212
|
+
- **What counts as a note.** In every root: files ending `.md` or `.html` (the viewer renders `.md` and serves `.html` as is), at any depth, except under a top-level `assets/` folder of that root. Nothing inside a file is read. The title is the filename without its extension and without one leading `discuss-`, `research-`, `draft-`, `spec-`, `pickup-` or `marketing-` prefix, hyphens as spaces, each word capitalised. A same-basename `.md` and `.html` pair in one folder lists as two notes; the page tags the `.html` one.
|
|
213
|
+
- **Response.**
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"notes": [
|
|
218
|
+
{ "name": "x.md", "title": "X", "path": "operator/notes/topic/x.md",
|
|
219
|
+
"root": "operator/notes", "folder": "topic", "modified": "2026-01-02T03:04:05.000Z" }
|
|
220
|
+
],
|
|
221
|
+
"notesDir": "present",
|
|
222
|
+
"notesDirPath": "operator/notes",
|
|
223
|
+
"roots": [
|
|
224
|
+
{ "path": "operator/notes", "state": "present", "count": 1 },
|
|
225
|
+
{ "path": "agents/<paName>/workspace/notes", "state": "missing", "count": 0 }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`notes` is one list, newest `modified` (file mtime) first across all roots, ties broken by `path` ascending. `path` is repo-relative, the value `viewer.html?file=` takes. `root` is the note's repo-relative root and `folder` is relative to that root (`''` at its top level). `notesDir` (`missing`, `empty` or `present`) and `notesDirPath` describe `operator/notes` alone. `roots` has one entry per root walked, with its own state and the count of notes listed under it. A failure is a 500 with `{ "error": "..." }`.
|
|
231
|
+
- **Page.** Each row shows its root under the title when more than one root is walked, and a chip per root (with its count) filters the list. Search matches the root, the folder, the title and, for fetched notes, the first lines of the content. With no notes at all, the empty state names every root walked and its state.
|
|
232
|
+
|
|
202
233
|
## Tests
|
|
203
234
|
|
|
204
235
|
```bash
|
|
@@ -688,7 +688,7 @@
|
|
|
688
688
|
var tagMatch = /^([a-z]+)-/i.exec(name);
|
|
689
689
|
var tag = tagMatch ? tagMatch[1] : 'note';
|
|
690
690
|
var age = fmtRelativeDate(n.modified);
|
|
691
|
-
var summary = name.replace(/\.md$/, '').replace(/^[a-z]+-/i, '').replace(/-/g, ' ');
|
|
691
|
+
var summary = name.replace(/\.(md|html)$/, '').replace(/^[a-z]+-/i, '').replace(/-/g, ' ');
|
|
692
692
|
return (
|
|
693
693
|
'<a class="card note-card" onclick="nav(\'/viewer.html?file=' + encodeURIComponent(n.path) + '\')">' +
|
|
694
694
|
'<div class="id">' + esc((age ? age + ' · ' : '') + tag) + '</div>' +
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
.note-snippet { font-size: 0.7rem; color: var(--text-dim); margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
|
|
38
38
|
.note-snippet mark { background: rgba(122,162,247,0.15); color: var(--accent); border-radius: 2px; }
|
|
39
39
|
.note-meta { color: var(--text-dim); font-size: 0.68rem; white-space: nowrap; margin-left: 0.75rem; flex-shrink: 0; }
|
|
40
|
+
.note-root { font-size: 0.68rem; color: var(--text-dim); margin-top: 0.1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
|
|
41
|
+
|
|
42
|
+
/* Root filter: one chip per notes root walked */
|
|
43
|
+
.root-filter { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.75rem; }
|
|
44
|
+
.root-filter[hidden] { display: none; }
|
|
45
|
+
.root-chip { font-family: inherit; font-size: 0.7rem; padding: 0.15rem 0.55rem; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-surface); color: var(--text-dim); cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
46
|
+
.root-chip:hover { border-color: var(--accent); }
|
|
47
|
+
.root-chip.active { border-color: var(--accent); color: var(--accent); }
|
|
40
48
|
.badge { display: inline-block; padding: 0.08rem 0.35rem; border-radius: 3px; font-size: 0.62rem; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
|
41
49
|
.b-discuss { background: rgba(122,162,247,0.15); color: var(--accent); }
|
|
42
50
|
.b-research { background: rgba(158,206,106,0.15); color: var(--green); }
|
|
@@ -59,6 +67,8 @@
|
|
|
59
67
|
<span class="search-count" id="search-count"></span>
|
|
60
68
|
</div>
|
|
61
69
|
|
|
70
|
+
<div class="root-filter" id="root-filter" hidden></div>
|
|
71
|
+
|
|
62
72
|
<div id="container"><div class="empty">Loading…</div></div>
|
|
63
73
|
|
|
64
74
|
<noscript><div style="padding:2rem;color:var(--text-dim);text-align:center;">Enable JavaScript to view notes.</div></noscript>
|
|
@@ -66,6 +76,9 @@
|
|
|
66
76
|
var allNotes = [];
|
|
67
77
|
var notesDirState = 'present';
|
|
68
78
|
var notesDirPath = 'operator/notes';
|
|
79
|
+
// Every notes root the server walked: [{ path, state, count }].
|
|
80
|
+
var notesRoots = [];
|
|
81
|
+
var rootFilter = '';
|
|
69
82
|
var noteContents = {};
|
|
70
83
|
var searchTimer = null;
|
|
71
84
|
|
|
@@ -73,6 +86,8 @@
|
|
|
73
86
|
var el = document.createElement('span'); el.textContent = s == null ? '' : String(s); return el.innerHTML;
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
|
90
|
+
|
|
76
91
|
function navTo(path) {
|
|
77
92
|
if (window.parent && window.parent.navigate) window.parent.navigate(path);
|
|
78
93
|
else window.location.href = path;
|
|
@@ -97,6 +112,35 @@
|
|
|
97
112
|
return '';
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
function rootStateText(r) {
|
|
116
|
+
if (r.state === 'missing') return r.path + ' (no directory)';
|
|
117
|
+
if (r.state === 'empty') return r.path + ' (empty)';
|
|
118
|
+
return r.path;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function joinOr(items) {
|
|
122
|
+
if (items.length <= 1) return items.join('');
|
|
123
|
+
return items.slice(0, -1).join(', ') + ' or ' + items[items.length - 1];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderRootFilter() {
|
|
127
|
+
var el = document.getElementById('root-filter');
|
|
128
|
+
if (notesRoots.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
|
|
129
|
+
var html = '<button type="button" class="root-chip' + (rootFilter ? '' : ' active') + '" data-root="">All roots (' + allNotes.length + ')</button>';
|
|
130
|
+
notesRoots.forEach(function(r) {
|
|
131
|
+
html += '<button type="button" class="root-chip' + (rootFilter === r.path ? ' active' : '') + '" data-root="' + escAttr(r.path) + '" title="' + escAttr(rootStateText(r)) + '">' + esc(r.path) + ' (' + (r.count || 0) + ')</button>';
|
|
132
|
+
});
|
|
133
|
+
el.innerHTML = html;
|
|
134
|
+
el.hidden = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
document.getElementById('root-filter').addEventListener('click', function(e) {
|
|
138
|
+
var chip = e.target.closest('.root-chip'); if (!chip) return;
|
|
139
|
+
rootFilter = chip.getAttribute('data-root') || '';
|
|
140
|
+
renderRootFilter();
|
|
141
|
+
render(document.getElementById('search').value.trim());
|
|
142
|
+
});
|
|
143
|
+
|
|
100
144
|
// Simple fuzzy match: all query words must appear in target (case-insensitive)
|
|
101
145
|
function fuzzyMatch(target, query) {
|
|
102
146
|
if (!query) return true;
|
|
@@ -133,12 +177,13 @@
|
|
|
133
177
|
var countEl = document.getElementById('search-count');
|
|
134
178
|
|
|
135
179
|
var filtered = allNotes.filter(function(n) {
|
|
180
|
+
if (rootFilter && n.root !== rootFilter) return false;
|
|
136
181
|
if (!query) return true;
|
|
137
182
|
var contentSnippet = (noteContents[n.path] || '').slice(0, 200);
|
|
138
|
-
return fuzzyMatch((n.folder || '') + ' ' + n.title + ' ' + contentSnippet, query);
|
|
183
|
+
return fuzzyMatch((n.root || '') + ' ' + (n.folder || '') + ' ' + n.title + ' ' + contentSnippet, query);
|
|
139
184
|
});
|
|
140
185
|
|
|
141
|
-
countEl.textContent = query ? filtered.length + ' / ' + allNotes.length : '';
|
|
186
|
+
countEl.textContent = (query || rootFilter) ? filtered.length + ' / ' + allNotes.length : '';
|
|
142
187
|
|
|
143
188
|
if (filtered.length === 0) {
|
|
144
189
|
// 0.1.54 (w-2026-09-21-dave-002, F7a/B3): distinguish missing vs
|
|
@@ -147,6 +192,12 @@
|
|
|
147
192
|
var emptyMsg;
|
|
148
193
|
if (query) {
|
|
149
194
|
emptyMsg = 'No notes match "' + esc(query) + '".';
|
|
195
|
+
} else if (rootFilter) {
|
|
196
|
+
emptyMsg = 'No notes in ' + esc(rootFilter) + '.';
|
|
197
|
+
} else if (notesRoots.length && notesRoots.every(function(r) { return r.state === 'missing'; })) {
|
|
198
|
+
emptyMsg = 'No notes directory at ' + esc(joinOr(notesRoots.map(function(r) { return r.path; }))) + '. Create one and add a .md or .html file to see it here.';
|
|
199
|
+
} else if (notesRoots.length) {
|
|
200
|
+
emptyMsg = 'No notes yet. Looked in ' + esc(notesRoots.map(rootStateText).join(', ')) + '.';
|
|
150
201
|
} else if (notesDirState === 'missing') {
|
|
151
202
|
emptyMsg = 'No notes directory at ' + esc(notesDirPath) + '. Create it and add a .md file to see it here.';
|
|
152
203
|
} else {
|
|
@@ -167,8 +218,10 @@
|
|
|
167
218
|
html += '<li class="note-item" onclick="openNote(' + idx + ')">';
|
|
168
219
|
html += '<div class="note-item-left"><div>';
|
|
169
220
|
html += noteBadge(note.name);
|
|
221
|
+
if (/\.html$/.test(note.name)) html += ' <span class="badge b-other">html</span> ';
|
|
170
222
|
if (note.folder) html += '<span class="note-folder">' + highlight(note.folder + '/', query) + '</span>';
|
|
171
223
|
html += '<span class="note-title">' + highlight(note.title, query) + '</span>';
|
|
224
|
+
if (note.root && notesRoots.length > 1) html += '<div class="note-root">' + highlight(note.root, query) + '</div>';
|
|
172
225
|
if (snippet) html += snippet;
|
|
173
226
|
html += '</div></div>';
|
|
174
227
|
html += '<span class="note-meta">' + relativeTime(note.modified) + '</span>';
|
|
@@ -202,6 +255,8 @@
|
|
|
202
255
|
allNotes = data.notes || [];
|
|
203
256
|
notesDirState = data.notesDir || 'present';
|
|
204
257
|
notesDirPath = data.notesDirPath || 'operator/notes';
|
|
258
|
+
notesRoots = Array.isArray(data.roots) ? data.roots : [];
|
|
259
|
+
renderRootFilter();
|
|
205
260
|
render('');
|
|
206
261
|
}).catch(function(err) {
|
|
207
262
|
document.getElementById('container').innerHTML = '<div class="empty">Failed to load: ' + esc(err.message) + '</div>';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, resolve, relative } from 'node:path';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
3
3
|
import { execSync, execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
|
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
* @param {URL} url
|
|
37
37
|
* @param {string} repoRoot -- absolute path to the project root
|
|
38
38
|
* @param {object} [deps] -- injectable bridges (tests only). Currently:
|
|
39
|
-
* deps.scheduler = { detect, list }
|
|
39
|
+
* deps.scheduler = { detect, list },
|
|
40
|
+
* deps.warn = (message) => void (notes-root
|
|
41
|
+
* refusals; default console.warn, once each).
|
|
40
42
|
* @returns {boolean}
|
|
41
43
|
*/
|
|
42
44
|
const SERVER_STARTED_AT = new Date().toISOString();
|
|
@@ -224,20 +226,30 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
|
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
// GET /__api/notes
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
229
|
+
// The notes index across every notes root (see resolveNotesRoots):
|
|
230
|
+
// {
|
|
231
|
+
// notes: [{ name, title, path, root, folder, modified }],
|
|
232
|
+
// notesDir: 'missing' | 'empty' | 'present',
|
|
233
|
+
// notesDirPath: 'operator/notes',
|
|
234
|
+
// roots: [{ path, state: 'missing' | 'empty' | 'present', count }],
|
|
235
|
+
// }
|
|
236
|
+
// `notes` is one list, newest `modified` first across all roots, ties
|
|
237
|
+
// broken by `path` ascending. `path` is repo-relative (what
|
|
238
|
+
// viewer.html?file= takes), `root` is the note's repo-relative root and
|
|
239
|
+
// `folder` is relative to that root ('' at its top level). `notesDir`
|
|
240
|
+
// and `notesDirPath` describe operator/notes alone, so a page that
|
|
241
|
+
// predates `roots` keeps its missing-versus-empty message. `roots`
|
|
242
|
+
// lists each root walked, in walk order, with the state and count of
|
|
243
|
+
// the notes listed under it. A throw is a 500 with `{ error }`.
|
|
234
244
|
if (method === 'GET' && pathname === '/__api/notes') {
|
|
235
245
|
try {
|
|
236
|
-
const walked = walkNotes(repoRoot);
|
|
246
|
+
const walked = walkNotes(repoRoot, { warn: deps.warn });
|
|
247
|
+
const canonical = walked.roots[0];
|
|
237
248
|
sendJson(res, 200, {
|
|
238
249
|
notes: walked.notes,
|
|
239
|
-
notesDir:
|
|
240
|
-
notesDirPath:
|
|
250
|
+
notesDir: canonical.state,
|
|
251
|
+
notesDirPath: canonical.path,
|
|
252
|
+
roots: walked.roots,
|
|
241
253
|
});
|
|
242
254
|
} catch (err) {
|
|
243
255
|
sendJson(res, 500, { error: err.message });
|
|
@@ -841,46 +853,152 @@ function walkWork(repoRoot) {
|
|
|
841
853
|
return items;
|
|
842
854
|
}
|
|
843
855
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
856
|
+
// Notes roots, in walk order:
|
|
857
|
+
// 1. operator/notes, the instance's canonical notes directory (the
|
|
858
|
+
// scaffold creates it), always first;
|
|
859
|
+
// 2. agents/<paName>/workspace/notes, where the running PA writes its
|
|
860
|
+
// session notes beside its status board (paName from
|
|
861
|
+
// resolveAgentName, the same resolution the status board uses);
|
|
862
|
+
// 3. each repo-relative path in preferences `notesRoots` (an array of
|
|
863
|
+
// strings), in the order given.
|
|
864
|
+
// Discovery covers the resolved PA only, not every agents/* folder: a
|
|
865
|
+
// second agent's notes, or any other notes folder, need a `notesRoots`
|
|
866
|
+
// entry. A configured root that is not a non-empty string, names the repo
|
|
867
|
+
// root itself, or resolves outside the repo (by path or through a
|
|
868
|
+
// symlink) is refused: logged and never walked. A root named twice is
|
|
869
|
+
// walked once.
|
|
870
|
+
const CANONICAL_NOTES_ROOT = 'operator/notes';
|
|
871
|
+
const NOTES_ROOTS_PREFERENCE = 'notesRoots';
|
|
872
|
+
// Every root follows the same rules: any depth, a top-level assets/
|
|
873
|
+
// skipped (images and attachments, never notes), .md and .html accepted
|
|
874
|
+
// (the viewer renders .md and serves .html as is). Nothing inside a file
|
|
875
|
+
// is read: the title comes from the filename.
|
|
876
|
+
const NOTE_FILE_RE = /\.(md|html)$/;
|
|
877
|
+
const NOTE_TITLE_PREFIXES = ['discuss-', 'research-', 'draft-', 'spec-', 'pickup-', 'marketing-'];
|
|
878
|
+
|
|
879
|
+
const warnedNotesRoots = new Set();
|
|
880
|
+
function warnNotesRootOnce(repoRoot, message) {
|
|
881
|
+
const key = repoRoot + '\0' + message;
|
|
882
|
+
if (warnedNotesRoots.has(key)) return;
|
|
883
|
+
warnedNotesRoots.add(key);
|
|
884
|
+
console.warn('[workspace] ' + message);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function isOutsideRel(rel) {
|
|
888
|
+
return rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function normaliseNotesRoot(repoRoot, raw) {
|
|
892
|
+
if (typeof raw !== 'string' || raw.trim() === '') return { error: 'not a non-empty string' };
|
|
893
|
+
const abs = resolve(repoRoot, raw.trim());
|
|
894
|
+
const rel = relative(repoRoot, abs);
|
|
895
|
+
if (rel === '') return { error: 'names the repo root itself' };
|
|
896
|
+
if (isOutsideRel(rel)) return { error: 'resolves outside the repo' };
|
|
897
|
+
if (existsSync(abs)) {
|
|
898
|
+
let realRel = null;
|
|
899
|
+
try { realRel = relative(realpathSync(repoRoot), realpathSync(abs)); } catch { realRel = null; }
|
|
900
|
+
if (realRel !== null && (realRel === '' || isOutsideRel(realRel))) {
|
|
901
|
+
return { error: 'resolves outside the repo through a symlink' };
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { path: rel.split(sep).join('/') };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function resolveNotesRoots(repoRoot, opts = {}) {
|
|
908
|
+
const warn = typeof opts.warn === 'function'
|
|
909
|
+
? opts.warn
|
|
910
|
+
: function (message) { warnNotesRootOnce(repoRoot, message); };
|
|
911
|
+
const roots = [CANONICAL_NOTES_ROOT];
|
|
912
|
+
const paName = resolveAgentName(repoRoot);
|
|
913
|
+
if (isValidAgentName(paName)) {
|
|
914
|
+
roots.push('agents/' + paName + '/workspace/notes');
|
|
915
|
+
} else {
|
|
916
|
+
warn('notes: paName ' + JSON.stringify(paName) + ' is not a valid agent folder name; its workspace notes are not walked');
|
|
917
|
+
}
|
|
918
|
+
let prefs = null;
|
|
919
|
+
try { prefs = readPreferencesSync(repoRoot); } catch { prefs = null; }
|
|
920
|
+
const configured = prefs ? prefs[NOTES_ROOTS_PREFERENCE] : undefined;
|
|
921
|
+
if (configured !== undefined && configured !== null) {
|
|
922
|
+
if (!Array.isArray(configured)) {
|
|
923
|
+
warn(NOTES_ROOTS_PREFERENCE + ': expected an array of repo-relative paths; ignored');
|
|
924
|
+
} else {
|
|
925
|
+
for (const raw of configured) {
|
|
926
|
+
const r = normaliseNotesRoot(repoRoot, raw);
|
|
927
|
+
if (r.error) {
|
|
928
|
+
warn(NOTES_ROOTS_PREFERENCE + ': refused ' + JSON.stringify(raw) + ' (' + r.error + '); not walked');
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
roots.push(r.path);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return roots.filter(function (r, i) { return roots.indexOf(r) === i; });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function noteTitle(name) {
|
|
939
|
+
let title = name.replace(NOTE_FILE_RE, '');
|
|
940
|
+
for (const prefix of NOTE_TITLE_PREFIXES) {
|
|
941
|
+
if (title.startsWith(prefix)) { title = title.slice(prefix.length); break; }
|
|
942
|
+
}
|
|
943
|
+
return title.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Note files under one root: [{ name, folder, relPath }], relPath and
|
|
947
|
+
// folder relative to the root ('' folder at its top level).
|
|
948
|
+
function listNoteFiles(absRoot) {
|
|
850
949
|
const files = [];
|
|
851
950
|
(function walk(rel) {
|
|
852
|
-
const entries = readdirSync(join(
|
|
951
|
+
const entries = readdirSync(rel ? join(absRoot, rel) : absRoot, { withFileTypes: true });
|
|
853
952
|
for (const e of entries) {
|
|
854
953
|
const relPath = rel ? rel + '/' + e.name : e.name;
|
|
855
954
|
if (e.isDirectory()) {
|
|
856
955
|
if (relPath !== 'assets') walk(relPath);
|
|
857
|
-
} else if (e.name
|
|
956
|
+
} else if (NOTE_FILE_RE.test(e.name)) {
|
|
858
957
|
files.push({ name: e.name, folder: rel, relPath: relPath });
|
|
859
958
|
}
|
|
860
959
|
}
|
|
861
960
|
})('');
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
961
|
+
return files;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function walkNotes(repoRoot, opts = {}) {
|
|
965
|
+
const notes = [];
|
|
966
|
+
const roots = [];
|
|
967
|
+
const listed = new Set();
|
|
968
|
+
for (const rootPath of resolveNotesRoots(repoRoot, opts)) {
|
|
969
|
+
const absRoot = join(repoRoot, ...rootPath.split('/'));
|
|
970
|
+
let isDir = false;
|
|
971
|
+
try { isDir = statSync(absRoot).isDirectory(); } catch { isDir = false; }
|
|
972
|
+
if (!isDir) {
|
|
973
|
+
roots.push({ path: rootPath, state: 'missing', count: 0 });
|
|
974
|
+
continue;
|
|
872
975
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
976
|
+
let count = 0;
|
|
977
|
+
for (const f of listNoteFiles(absRoot)) {
|
|
978
|
+
const path = rootPath + '/' + f.relPath;
|
|
979
|
+
// A root nested inside an earlier root: its notes stay with the
|
|
980
|
+
// earlier root, listed once.
|
|
981
|
+
if (listed.has(path)) continue;
|
|
982
|
+
let st;
|
|
983
|
+
try { st = statSync(join(absRoot, f.relPath)); } catch { continue; }
|
|
984
|
+
listed.add(path);
|
|
985
|
+
notes.push({
|
|
986
|
+
name: f.name,
|
|
987
|
+
title: noteTitle(f.name),
|
|
988
|
+
path: path,
|
|
989
|
+
root: rootPath,
|
|
990
|
+
folder: f.folder,
|
|
991
|
+
modified: st.mtime.toISOString(),
|
|
992
|
+
});
|
|
993
|
+
count++;
|
|
994
|
+
}
|
|
995
|
+
roots.push({ path: rootPath, state: count === 0 ? 'empty' : 'present', count: count });
|
|
881
996
|
}
|
|
882
|
-
notes.sort(function(a, b) {
|
|
883
|
-
|
|
997
|
+
notes.sort(function (a, b) {
|
|
998
|
+
if (a.modified !== b.modified) return a.modified < b.modified ? 1 : -1;
|
|
999
|
+
return a.path < b.path ? -1 : (a.path > b.path ? 1 : 0);
|
|
1000
|
+
});
|
|
1001
|
+
return { notes: notes, roots: roots };
|
|
884
1002
|
}
|
|
885
1003
|
|
|
886
1004
|
function walkDocuments(dir, repoRoot, result) {
|
package/package.json
CHANGED