list-notes.mjs
226 lines 7.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * List notes with filters. Single backend for CLI and MCP. Phase 9.
3 * Extracted from CLI runListNotes for reuse.
4 */
5
6 import { listMarkdownFiles, readNote, normalizeSlug, normalizeTags, effectiveProjectSlug } from './vault.mjs';
7 import { isApprovalLogNote } from './approval-log.mjs';
8
9 /**
10 * @param {string} d - date string
11 * @returns {string} YYYY-MM-DD slice for range comparison
12 */
13 function dateSlice(d) {
14 if (d == null || typeof d !== 'string') return '';
15 return d.trim().slice(0, 10) || '';
16 }
17
18 /**
19 * Get notes with metadata for listing.
20 * @param {string} vaultPath
21 * @param {{ ignore?: string[] }} config
22 * @returns {{ path: string, frontmatter: object, body: string, project?: string, tags?: string[], date?: string, updated?: string, causal_chain_id?: string, entity?: string[], episode_id?: string }[]}
23 */
24 export function getNotesWithMeta(vaultPath, config = {}) {
25 const paths = listMarkdownFiles(vaultPath, { ignore: config.ignore });
26 const notes = [];
27 for (const p of paths) {
28 try {
29 notes.push(readNote(vaultPath, p));
30 } catch (_) {
31 // skip unreadable
32 }
33 }
34 return notes;
35 }
36
37 /**
38 * Apply list-notes structural filters (folder, project, tag, dates, chain, entity, episode,
39 * content_scope, network, wallet_address, payment_status).
40 * Mutates no inputs; returns a new array.
41 * @param {Array<{ path: string, frontmatter?: object, body?: string, project?: string, tags?: string[], date?: string, updated?: string, causal_chain_id?: string, entity?: string[], episode_id?: string, network?: string, wallet_address?: string, payment_status?: string }>} notes
42 * @param {{
43 * folder?: string,
44 * project?: string,
45 * tag?: string,
46 * since?: string,
47 * until?: string,
48 * chain?: string,
49 * entity?: string,
50 * episode?: string,
51 * content_scope?: 'all'|'notes'|'approval_logs',
52 * content_class?: string,
53 * network?: string,
54 * wallet_address?: string,
55 * payment_status?: string
56 * }} options
57 */
58 export function filterNotesByListOptions(notes, options = {}) {
59 let out = notes.slice();
60 if (options.folder) {
61 const prefix = options.folder.replace(/\\/g, '/').replace(/\/$/, '') + '/';
62 out = out.filter((n) => n.path === options.folder || n.path.startsWith(prefix));
63 }
64 if (options.project) {
65 const p = normalizeSlug(options.project);
66 out = out.filter((n) => effectiveProjectSlug(n.path, n.frontmatter) === p);
67 }
68 if (options.tag) {
69 const t = normalizeSlug(options.tag);
70 out = out.filter((n) => n.tags?.includes(t) || normalizeTags(n.frontmatter?.tags).includes(t));
71 }
72 if (options.since) {
73 const s = dateSlice(options.since);
74 if (s) out = out.filter((n) => dateSlice(n.date || n.updated) >= s);
75 }
76 if (options.until) {
77 const u = dateSlice(options.until);
78 if (u) out = out.filter((n) => dateSlice(n.date || n.updated) <= u);
79 }
80 if (options.chain) {
81 const c = normalizeSlug(options.chain);
82 out = out.filter((n) => n.causal_chain_id === c);
83 }
84 if (options.entity) {
85 const e = normalizeSlug(options.entity);
86 out = out.filter((n) => Array.isArray(n.entity) && n.entity.includes(e));
87 }
88 if (options.episode) {
89 const ep = normalizeSlug(options.episode);
90 out = out.filter((n) => n.episode_id === ep);
91 }
92 const cs = options.content_scope;
93 if (cs === 'notes') {
94 out = out.filter((n) => !isApprovalLogNote(n));
95 } else if (cs === 'approval_logs') {
96 out = out.filter((n) => isApprovalLogNote(n));
97 }
98 // Phase 12 — blockchain frontmatter filters
99 if (options.network) {
100 const net = String(options.network).trim().toLowerCase();
101 out = out.filter((n) => {
102 const nw = n.network ?? n.frontmatter?.network;
103 return nw != null && String(nw).trim().toLowerCase() === net;
104 });
105 }
106 if (options.wallet_address) {
107 const wa = String(options.wallet_address).trim().toLowerCase();
108 out = out.filter((n) => {
109 const addr = n.wallet_address ?? n.frontmatter?.wallet_address;
110 return addr != null && String(addr).trim().toLowerCase() === wa;
111 });
112 }
113 if (options.payment_status) {
114 const ps = String(options.payment_status).trim().toLowerCase();
115 out = out.filter((n) => {
116 const status = n.payment_status ?? n.frontmatter?.payment_status;
117 return status != null && String(status).trim().toLowerCase() === ps;
118 });
119 }
120 if (options.content_class) {
121 const cc = String(options.content_class).trim().toLowerCase();
122 out = out.filter((n) => {
123 const v = n.content_class ?? n.frontmatter?.content_class;
124 return v != null && String(v).trim().toLowerCase() === cc;
125 });
126 }
127 return out;
128 }
129
130 /**
131 * Run list-notes with filters. Returns SPEC §4.2 JSON shape.
132 * @param {{ vault_path: string, ignore?: string[] }} config
133 * @param {{
134 * folder?: string,
135 * project?: string,
136 * tag?: string,
137 * since?: string,
138 * until?: string,
139 * chain?: string,
140 * entity?: string,
141 * episode?: string,
142 * limit?: number,
143 * offset?: number,
144 * order?: 'date'|'date-asc'|string,
145 * fields?: 'path'|'path+metadata'|'full',
146 * countOnly?: boolean,
147 * content_scope?: 'all'|'notes'|'approval_logs',
148 * network?: string,
149 * wallet_address?: string,
150 * payment_status?: string
151 * }} options
152 * @returns {{ notes?: object[], total: number }}
153 */
154 export function runListNotes(config, options = {}) {
155 const limit = Math.max(0, options.limit ?? 20);
156 const offset = Math.max(0, options.offset ?? 0);
157 const order = options.order || 'date';
158 const fields = options.fields || 'path+metadata';
159 const countOnly = options.countOnly === true;
160
161 let notes = filterNotesByListOptions(getNotesWithMeta(config.vault_path, config), options);
162
163 if (order === 'date-asc') {
164 notes.sort((a, b) => (a.date || a.updated || '').localeCompare(b.date || b.updated || ''));
165 } else if (order === 'date') {
166 notes.sort((a, b) => (b.date || b.updated || '').localeCompare(a.date || a.updated || ''));
167 } else {
168 notes.sort((a, b) => a.path.localeCompare(b.path));
169 }
170
171 const total = notes.length;
172 const slice = notes.slice(offset, offset + limit);
173
174 if (countOnly) {
175 return { total };
176 }
177
178 const list = slice.map((n) => {
179 if (fields === 'path') return { path: n.path };
180 if (fields === 'full') return { path: n.path, frontmatter: n.frontmatter, body: n.body };
181 const fm = n.frontmatter || {};
182 return {
183 path: n.path,
184 title: fm.title ?? null,
185 project: n.project || null,
186 tags: n.tags || [],
187 date: n.date || null,
188 kind: fm.kind != null ? String(fm.kind) : null,
189 /** ISO timestamp for Hub UI calendar/list when `date` is unset (list response omits full frontmatter). */
190 knowtation_edited_at:
191 fm.knowtation_edited_at != null ? String(fm.knowtation_edited_at) : null,
192 };
193 });
194
195 return { notes: list, total };
196 }
197
198 /**
199 * Return facet values for filter dropdowns: projects, tags, folders, networks, wallets.
200 * @param {{ vault_path: string, ignore?: string[] }} config
201 * @returns {{ projects: string[], tags: string[], folders: string[], networks: string[], wallets: string[] }}
202 */
203 export function runFacets(config) {
204 const notes = getNotesWithMeta(config.vault_path, config);
205 const projects = new Set();
206 const tags = new Set();
207 const folders = new Set();
208 const networks = new Set();
209 const wallets = new Set();
210 for (const n of notes) {
211 if (n.project) projects.add(n.project);
212 for (const t of n.tags || []) if (t) tags.add(t);
213 const folder = n.path.includes('/') ? n.path.split('/').slice(0, -1).join('/') : '';
214 if (folder) folders.add(folder);
215 const fm = n.frontmatter || {};
216 if (fm.network != null && String(fm.network).trim()) networks.add(String(fm.network).trim());
217 if (fm.wallet_address != null && String(fm.wallet_address).trim()) wallets.add(String(fm.wallet_address).trim());
218 }
219 return {
220 projects: [...projects].sort(),
221 tags: [...tags].sort(),
222 folders: [...folders].sort(),
223 networks: [...networks].sort(),
224 wallets: [...wallets].sort(),
225 };
226 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago