hub.js javascript
10,140 lines 411.0 KB
Raw
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor ⚠ breaking 15 days ago
1 /**
2 * Knowtation Hub UI — list, calendar, overview, quick add, presets. Phase 11C.
3 */
4
5 (function () {
6 const params = new URLSearchParams(location.search);
7 // Build-time or deployment config: set window.HUB_API_BASE_URL (e.g. from config.js). Empty string = same origin (when static host proxies /api to the gateway).
8 const apiBase = (function resolveApiBase() {
9 if (typeof window === 'undefined') return 'http://localhost:3333';
10 const paramApi = params.get('api');
11 if (paramApi != null && String(paramApi).trim()) {
12 return String(paramApi).trim().replace(/\/$/, '');
13 }
14 const hostname = location.hostname || '';
15 const isLocalDev =
16 hostname === 'localhost' ||
17 hostname === '127.0.0.1' ||
18 hostname === '[::1]' ||
19 hostname === '::1';
20 // Self-hosted dev: always call the same origin as the page (npm run hub). Stale localStorage
21 // hub_api_url often points at a hosted gateway and causes HTML 404 for Node-only routes.
22 if (isLocalDev) {
23 return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
24 }
25 if (Object.prototype.hasOwnProperty.call(window, 'HUB_API_BASE_URL')) {
26 const v = window.HUB_API_BASE_URL;
27 if (v == null) {
28 return (
29 localStorage.getItem('hub_api_url') ||
30 location.origin ||
31 'http://localhost:3333'
32 ).replace(/\/$/, '');
33 }
34 const s = String(v).trim();
35 if (s === '') return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
36 return s.replace(/\/$/, '');
37 }
38 return (localStorage.getItem('hub_api_url') || location.origin || 'http://localhost:3333').replace(/\/$/, '');
39 })();
40 /** Public MCP endpoint (https://…/mcp) when operator sets window.HUB_MCP_PUBLIC_URL in web/hub/config.js; else ''. */
41 const mcpPublicUrl = (function resolveMcpPublicUrl() {
42 if (typeof window === 'undefined') return '';
43 if (!Object.prototype.hasOwnProperty.call(window, 'HUB_MCP_PUBLIC_URL')) return '';
44 const v = window.HUB_MCP_PUBLIC_URL;
45 if (v == null) return '';
46 const s = String(v).trim();
47 if (s === '') return '';
48 return s.replace(/\/$/, '');
49 })();
50 /** Canonical doc: where Hub token, REST, remote MCP, and local CLI differ (copy blocks point here). */
51 const INTEGRATION_DOC_URL = 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md';
52 const hashParams = new URLSearchParams(location.hash.replace(/^#/, ''));
53 /** Used to defer onboarding until invite consume has run (see scheduleMaybeShowOnboardingWizard). */
54 const pageLoadHadInviteQuery = Boolean(params.get('invite'));
55 let token = hashParams.get('token') || params.get('token') || localStorage.getItem('hub_token');
56 if (token) {
57 localStorage.setItem('hub_token', token);
58 if (hashParams.has('token')) {
59 history.replaceState({}, '', location.pathname + location.search);
60 } else if (params.has('token')) {
61 const u = new URL(location.href);
62 u.searchParams.delete('token');
63 history.replaceState({}, '', u.toString());
64 }
65 }
66
67 /** Latest GET /api/v1/settings used for Backup tab (hosted repo field + sync body). */
68 let lastBackupSettingsPayload = null;
69
70 const PRESETS_KEY = 'hub_view_presets';
71 const el = (id) => document.getElementById(id);
72 const app = el('app');
73 const main = el('main');
74 const loginRequired = el('login-required');
75 const btnLoginGoogle = el('btn-login-google');
76 const btnLoginGithub = el('btn-login-github');
77 const btnLogout = el('btn-logout');
78 const btnNewNote = el('btn-new-note');
79 const btnImport = el('btn-import');
80 const btnHeaderSuggested = el('btn-header-suggested');
81 const btnHowToUse = el('btn-how-to-use');
82 const btnSettings = el('btn-settings');
83 const browseToolbar = el('browse-toolbar');
84 const userName = el('user-name');
85 const oauthNotConfigured = el('oauth-not-configured');
86 const loginIntro = el('login-intro');
87 const searchQuery = el('search-query');
88 const filterProject = el('filter-project');
89 const filterTag = el('filter-tag');
90 const filterFolder = el('filter-folder');
91 const filterSince = el('filter-since');
92 const filterUntil = el('filter-until');
93 const filterContentScope = el('filter-content-scope');
94 const filterNetwork = el('filter-network');
95 const filterWallet = el('filter-wallet');
96 const searchMode = el('search-mode');
97 const btnSearch = el('btn-search');
98 const btnClearSearch = el('btn-clear-search');
99 const btnApplyFilters = el('btn-apply-filters');
100 const btnReindex = el('btn-reindex');
101 const notesList = el('notes-list');
102 const notesTotal = el('notes-total');
103 /** True when the last unfiltered browse list (loadNotes, no list filters) returned zero notes. */
104 let hubBrowseListEmptyUnfiltered = false;
105 /** Last facets from {@link fetchFacetsResolved} (Hub create panel project pickers + similarity guard). */
106 let lastHubFacets = null;
107 /** Latest `/api/v1/vault/folders` list for subfolder derivation under `projects/<slug>/`. */
108 let lastVaultFoldersForCreate = [];
109 /** After “Keep my path” on similar-project modal, allow one create without re-prompting. */
110 let fullCreateSimilarOverrideOnce = false;
111 let fullCreateSimilarModalSuggestedSlug = '';
112 let fullCreateSimilarModalPendingPath = '';
113 let fullPathSimilarDebounceTimer = 0;
114 const filterChipsEl = el('filter-chips');
115 const presetsListEl = el('presets-list');
116 const presetNameInput = el('preset-name');
117 const hubBetaNote = el('hub-beta-note');
118 if (hubBetaNote && window.location.hostname !== 'knowtation.store' && window.location.hostname !== 'www.knowtation.store') hubBetaNote.classList.add('hidden');
119
120 let providers = null;
121 let calendarMonth = new Date();
122 let currentNotePathForCopy = '';
123 /** @type {{ path: string, body: string, frontmatter: Record<string, string> } | null} */
124 let currentOpenNote = null;
125 /** Increments when the SectionSource panel is reset so stale body-free reads do not render. */
126 let hubSectionSourceSeq = 0;
127 /** When set, full-create save may delete this path after posting the duplicate (optional checkbox). */
128 /** @type {{ path: string } | null} */
129 let pendingDuplicateDeleteSource = null;
130 /** AbortController for window resize while note edit body layout is active. */
131 let detailEditBodyLayoutAbort = null;
132
133 /** Hide the detail drawer (does not clear currentOpenNote). */
134 function hideDetailPanelChrome() {
135 const dp = el('detail-panel');
136 if (dp) {
137 dp.classList.add('hidden');
138 dp.classList.remove('detail-panel-proposal-wide');
139 }
140 }
141
142 /** User dismisses the drawer (Escape, Close): clear open-note state. */
143 function closeDetailPanel() {
144 currentOpenNote = null;
145 currentNotePathForCopy = '';
146 resetDetailSectionSourceState();
147 teardownDetailEditBodyLayout();
148 hideDetailPanelChrome();
149 const bcbClose = el('btn-detail-copy-body');
150 if (bcbClose) bcbClose.classList.add('hidden');
151 const bcp = el('btn-copy-path');
152 if (bcp) bcp.classList.add('hidden');
153 }
154
155 let listSelectedIndex = 0;
156 /** Increments on each `openNote` call so stale fetch completions do not append duplicate actions or overwrite UI. */
157 let hubOpenNoteSeq = 0;
158 /** @type {import('chart.js').Chart[]} */
159 let chartInstances = [];
160
161 const FILTER_CHIPS_EXPANDED_KEY = 'hub_filter_chips_expanded';
162 let filterChipsExpanded = false;
163 try {
164 filterChipsExpanded = localStorage.getItem(FILTER_CHIPS_EXPANDED_KEY) === '1';
165 } catch (_) {
166 filterChipsExpanded = false;
167 }
168
169 const ACCENT_STORAGE_KEY = 'hub_accent_color';
170 const THEME_STORAGE_KEY = 'hub_theme';
171 const COLOR_PALETTE_STORAGE_KEY = 'hub_color_palette';
172 const DEFAULT_ACCENT = '#89cff0';
173 const DEFAULT_THEME = 'dark';
174 const DEFAULT_COLOR_PALETTE = 'default';
175 const VALID_COLOR_PALETTES = new Set([
176 'default',
177 'ocean',
178 'forest',
179 'sunset',
180 'lavender',
181 'ember',
182 'arctic',
183 'slate',
184 'midnight',
185 'sakura',
186 'sand',
187 'mint',
188 ]);
189 const loadingHtml = '<div class="loading-state" aria-live="polite">Loading…</div>';
190 function applyAccent(hex) {
191 if (hex) {
192 document.documentElement.style.setProperty('--accent', hex);
193 try {
194 localStorage.setItem(ACCENT_STORAGE_KEY, hex);
195 } catch (_) {}
196 }
197 }
198 function applyTheme(theme) {
199 const value = theme === 'light' ? 'light' : 'dark';
200 document.documentElement.setAttribute('data-theme', value === 'dark' ? '' : value);
201 try {
202 localStorage.setItem(THEME_STORAGE_KEY, value);
203 } catch (_) {}
204 }
205 function applyColorPalette(id) {
206 const p =
207 id && VALID_COLOR_PALETTES.has(String(id)) ? String(id) : DEFAULT_COLOR_PALETTE;
208 if (p === DEFAULT_COLOR_PALETTE) {
209 document.documentElement.removeAttribute('data-palette');
210 } else {
211 document.documentElement.setAttribute('data-palette', p);
212 }
213 try {
214 localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, p);
215 } catch (_) {}
216 }
217 function currentColorPalette() {
218 const a = document.documentElement.getAttribute('data-palette');
219 if (a && VALID_COLOR_PALETTES.has(a) && a !== DEFAULT_COLOR_PALETTE) return a;
220 return DEFAULT_COLOR_PALETTE;
221 }
222 (function initThemeAndAccent() {
223 try {
224 const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
225 if (savedTheme === 'light') applyTheme('light');
226 const savedAccent = localStorage.getItem(ACCENT_STORAGE_KEY);
227 if (savedAccent) applyAccent(savedAccent);
228 const savedPalette = localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
229 if (savedPalette) applyColorPalette(savedPalette);
230 } catch (_) {}
231 })();
232
233 function headers() {
234 const h = { 'Content-Type': 'application/json' };
235 if (token) h['Authorization'] = 'Bearer ' + token;
236 const vid = getCurrentVaultId();
237 if (vid) h['X-Vault-Id'] = vid;
238 return h;
239 }
240
241 // Persistent sessions: when the short-lived access token expires, silently exchange the
242 // HttpOnly refresh cookie for a new one instead of dropping the user to the login screen.
243 // Single-flight so a burst of 401s triggers exactly one refresh.
244 let refreshInFlight = null;
245 async function refreshAccessToken() {
246 if (refreshInFlight) return refreshInFlight;
247 refreshInFlight = (async () => {
248 try {
249 const res = await fetch(apiBase + '/api/v1/auth/refresh', {
250 method: 'POST',
251 credentials: 'include', // send the HttpOnly refresh cookie
252 cache: 'no-store',
253 headers: { 'Content-Type': 'application/json' },
254 });
255 if (!res.ok) return false;
256 const data = await res.json().catch(() => null);
257 if (data && typeof data.access_token === 'string' && data.access_token) {
258 token = data.access_token;
259 try { localStorage.setItem('hub_token', token); } catch (_) {}
260 return true;
261 }
262 return false;
263 } catch (_) {
264 return false;
265 }
266 })();
267 try {
268 return await refreshInFlight;
269 } finally {
270 refreshInFlight = null;
271 }
272 }
273
274 async function api(path, opts = {}) {
275 const method = (opts.method || 'GET').toUpperCase();
276 // GET/HEAD: retry up to 2×. POST/PATCH/DELETE: retry once only on pure network failures
277 // (before any HTTP response), which means the server never received the request so retrying
278 // is safe. Never retry on HTTP error responses (4xx/5xx) — those were received and processed.
279 //
280 // `opts.noRetry: true` opts out of retries entirely. Used by `POST /api/v1/index`: a 30s
281 // gateway timeout (Netlify Function cap) drops the client connection, which the browser
282 // surfaces as `Failed to fetch`. With retry on, the bridge then receives a SECOND index
283 // request while the first is still running, double-billing DeepInfra and worsening contention.
284 const maxNetworkRetries = opts.noRetry === true
285 ? 0
286 : (method === 'GET' || method === 'HEAD') ? 2 : 1;
287 // Strip non-fetch keys before forwarding to fetch() so they don't pollute the request init.
288 const { noRetry: _noRetry, ...fetchOpts } = opts;
289 // Internal one-shot control flag for the 401 silent-refresh retry; never forward to fetch().
290 delete fetchOpts._retriedAfterRefresh;
291 let res;
292 let networkRetries = maxNetworkRetries;
293 for (;;) {
294 try {
295 res = await fetch(apiBase + path, {
296 ...fetchOpts,
297 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
298 headers: { ...headers(), ...fetchOpts.headers },
299 });
300 break;
301 } catch (e) {
302 const m = e && e.message ? String(e.message) : String(e);
303 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
304 networkRetries--;
305 await new Promise(resolve => setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000));
306 continue;
307 }
308 if (m === 'Failed to fetch' || m.includes('NetworkError')) {
309 throw new Error(
310 'Could not reach the API (' +
311 apiBase +
312 '). Check gateway status, CORS (HUB_CORS_ORIGIN), ad blockers, and Netlify limits.',
313 );
314 }
315 throw e instanceof Error ? e : new Error(m);
316 }
317 }
318 if (res.status === 401) {
319 // Try a one-time silent refresh before forcing re-login. Never recurse on the auth
320 // endpoints themselves, and only retry once per original request.
321 if (
322 path !== '/api/v1/auth/refresh' &&
323 path !== '/api/v1/auth/logout' &&
324 !opts._retriedAfterRefresh
325 ) {
326 const refreshed = await refreshAccessToken();
327 if (refreshed) {
328 return api(path, { ...opts, _retriedAfterRefresh: true });
329 }
330 }
331 token = null;
332 localStorage.removeItem('hub_token');
333 if (app) app.classList.add('login-screen');
334 main.classList.add('hidden');
335 loginRequired.classList.remove('hidden');
336 browseToolbar.classList.add('hidden');
337 btnNewNote.classList.add('hidden');
338 if (btnImport) btnImport.classList.add('hidden');
339 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
340 if (btnHowToUse) btnHowToUse.classList.add('hidden');
341 if (btnSettings) btnSettings.classList.add('hidden');
342 showLoginChrome();
343 throw new Error('Unauthorized');
344 }
345 let text = await res.text();
346 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
347 let data;
348 try {
349 data = text ? JSON.parse(text) : null;
350 } catch (_) {
351 const t = text.trim();
352 if (/^<!DOCTYPE/i.test(t) || /<html/i.test(t)) {
353 throw new Error(
354 `Server returned a web page (${res.status}) instead of API JSON. Restart the Hub (\`npm run hub\`) after pulling. On localhost, the UI must use the same origin as Node Hub (not a hosted gateway); use \`?api=\` only if you intentionally point at another API base.`,
355 );
356 }
357 throw new Error(
358 'Response was not valid JSON (' +
359 res.status +
360 '). Start of body: ' +
361 t.slice(0, 120) +
362 (t.length > 120 ? '...' : ''),
363 );
364 }
365 if (!res.ok) {
366 const label = data?.error || res.statusText;
367 const detail = data?.message != null && String(data.message).trim() ? String(data.message).trim() : '';
368 const combined = detail ? `${label}: ${detail}` : label;
369 const err = new Error(combined);
370 if (data && data.code) err.code = data.code;
371 throw err;
372 }
373 return data;
374 }
375
376 /** Busy state for buttons during slow API calls (clear feedback on hosted). */
377 function setButtonBusy(btn, busy, labelWhenBusy) {
378 if (!btn || btn.nodeType !== 1) return;
379 const busyText = labelWhenBusy || 'Working…';
380 if (busy) {
381 if (btn.dataset.knowtationBtnRestLabel == null) {
382 btn.dataset.knowtationBtnRestLabel = btn.textContent;
383 }
384 btn.textContent = busyText;
385 btn.disabled = true;
386 btn.classList.add('btn-busy');
387 btn.setAttribute('aria-busy', 'true');
388 } else {
389 if (btn.dataset.knowtationBtnRestLabel != null) {
390 btn.textContent = btn.dataset.knowtationBtnRestLabel;
391 delete btn.dataset.knowtationBtnRestLabel;
392 }
393 btn.classList.remove('btn-busy');
394 btn.removeAttribute('aria-busy');
395 btn.disabled = false;
396 }
397 }
398
399 async function withButtonBusy(btn, labelWhenBusy, fn) {
400 if (!btn) return fn();
401 setButtonBusy(btn, true, labelWhenBusy);
402 try {
403 return await fn();
404 } finally {
405 setButtonBusy(btn, false);
406 }
407 }
408
409 const HOSTED_BACKUP_REPO_LS = 'knowtation_hosted_backup_repo';
410 /** If set, `resolveApiBase` uses this instead of `location.origin` — can point local Hub UI at Netlify by mistake. */
411 const HUB_API_URL_LS = 'hub_api_url';
412
413 const VAULT_ID_LS = 'hub_vault_id';
414 /** @see `web/hub/hub-client-import-zip.mjs` — 4B sequential import cap. */
415 const HUB_IMPORT_MAX_SEQUENTIAL = 200;
416 const importFileEl = el('import-file');
417 const importFileFolderEl = el('import-file-folder');
418 const importFolderHintEl = el('import-folder-hint');
419 const importBatchCancelBtn = el('import-batch-cancel');
420 const importBatchAriaEl = el('import-batch-aria');
421 /** Dropped files/folder (4C) — when set, submit uses this instead of the file inputs. */
422 /** @type {File[] | null} */
423 let importPendingDropFiles = null;
424 const importDropZoneEl = el('import-drop-zone');
425 const importDropStatusEl = el('import-drop-status');
426 /** @type {AbortController | null} */
427 let importBatchAbort = null;
428 const btnImportChooseFolder = el('btn-import-choose-folder');
429
430 function wrapFileWithWebkitRel(file, relPath) {
431 const w = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
432 const rel = String(relPath || file.name).replace(/^\//, '');
433 try {
434 Object.defineProperty(w, 'webkitRelativePath', { value: rel, enumerable: true, configurable: true });
435 } catch (_) {}
436 return w;
437 }
438
439 /**
440 * @param {FileSystemFileEntry} fe
441 * @param {string} pathPrefix
442 * @returns {Promise<File>}
443 */
444 function fileEntryToFileWithPath(fe, pathPrefix) {
445 return new Promise((resolve, reject) => {
446 fe.file(
447 (file) => {
448 const rel = (String(pathPrefix || '') + file.name).replace(/^\//, '');
449 resolve(wrapFileWithWebkitRel(file, rel));
450 },
451 reject,
452 );
453 });
454 }
455
456 /**
457 * @param {FileSystemDirectoryEntry} dirEntry
458 * @param {string} pathPrefix
459 * @returns {Promise<File[]>}
460 */
461 async function readAllFilesInDirectoryEntry(dirEntry, pathPrefix) {
462 const all = [];
463 const reader = dirEntry.createReader();
464 let batch;
465 do {
466 /** @type {FileSystemEntry[]} */
467 batch = await new Promise((res, rej) => reader.readEntries(res, rej));
468 for (const e of batch) {
469 if (e.isFile) {
470 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(e), pathPrefix));
471 } else if (e.isDirectory) {
472 all.push(
473 ...(await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(e), pathPrefix + e.name + '/')),
474 );
475 }
476 }
477 } while (batch.length > 0);
478 return all;
479 }
480
481 /**
482 * @param {DataTransfer} dataTransfer
483 * @returns {Promise<File[]>}
484 */
485 async function collectFilesFromDataTransfer(dataTransfer) {
486 if (!dataTransfer) return [];
487 const canEntry =
488 dataTransfer.items &&
489 dataTransfer.items.length > 0 &&
490 Array.from(dataTransfer.items).some((it) => it.kind === 'file' && 'webkitGetAsEntry' in it);
491 if (canEntry) {
492 const all = [];
493 for (const item of Array.from(dataTransfer.items)) {
494 if (item.kind !== 'file') continue;
495 if (item.webkitGetAsEntry) {
496 const entry = item.webkitGetAsEntry();
497 if (entry) {
498 if (entry.isFile) {
499 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(entry), ''));
500 } else if (entry.isDirectory) {
501 all.push(
502 ...(
503 await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(entry), entry.name + '/')
504 ),
505 );
506 }
507 } else {
508 const f = item.getAsFile();
509 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
510 }
511 } else {
512 const f = item.getAsFile();
513 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
514 }
515 }
516 return all;
517 }
518 if (dataTransfer.files && dataTransfer.files.length) {
519 return Array.from(dataTransfer.files).map((f) => wrapFileWithWebkitRel(f, f.name));
520 }
521 return [];
522 }
523
524 function updateImportDropStatusUi() {
525 if (!importDropStatusEl) return;
526 if (importPendingDropFiles && importPendingDropFiles.length > 0) {
527 importDropStatusEl.hidden = false;
528 importDropStatusEl.textContent =
529 importPendingDropFiles.length +
530 ' file(s) from drop. Click Import, or use the file picker above to replace.';
531 } else {
532 importDropStatusEl.hidden = true;
533 importDropStatusEl.textContent = '';
534 }
535 }
536
537 function clearImportDropPending() {
538 importPendingDropFiles = null;
539 if (importDropZoneEl) importDropZoneEl.classList.remove('import-drop-zone--over');
540 updateImportDropStatusUi();
541 }
542
543 function setImportBatchAria(s) {
544 if (importBatchAriaEl) importBatchAriaEl.textContent = s || '';
545 }
546
547 function normalizeUrlOrigin(base) {
548 try {
549 const s = String(base || '').trim().replace(/\/$/, '');
550 if (!s) return '';
551 const u = new URL(s.startsWith('http') ? s : 'https://' + s);
552 return u.origin;
553 } catch (_) {
554 return '';
555 }
556 }
557
558 function isLocalHubHostname() {
559 const h = location.hostname;
560 return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
561 }
562
563 /** Local Hub tab but `apiBase` targets another origin (e.g. Netlify) — causes “Could not reach the API … knowtation-gateway…”. */
564 function localApiBaseFootgunActive() {
565 if (!isLocalHubHostname()) return false;
566 const pageO = normalizeUrlOrigin(location.origin);
567 const apiO = normalizeUrlOrigin(apiBase);
568 if (!pageO || !apiO) return false;
569 return pageO !== apiO;
570 }
571
572 function refreshApiBaseFootgunBanner() {
573 const b = el('hub-api-base-footgun-banner');
574 if (!b) return;
575 if (!localApiBaseFootgunActive()) {
576 b.classList.add('hidden');
577 b.innerHTML = '';
578 return;
579 }
580 let lsHint = false;
581 try {
582 lsHint = Boolean(localStorage.getItem(HUB_API_URL_LS));
583 } catch (_) {}
584 const qsHint = Boolean(params.get('api'));
585 b.classList.remove('hidden');
586 const hint =
587 (lsHint ? ' <code>localStorage.' + HUB_API_URL_LS + '</code> is set.' : '') +
588 (qsHint ? ' This URL has an <code>?api=</code> override.' : '');
589 b.innerHTML =
590 '<p><strong>Wrong API for this tab.</strong> This page is on <code>' +
591 escapeHtml(location.origin) +
592 '</code> but the Hub calls <code>' +
593 escapeHtml(apiBase) +
594 '</code> for requests (settings, backup, notes).' +
595 hint +
596 ' For self-hosted <code>npm run hub</code>, clear the override so the API matches this origin, then reload.</p>' +
597 '<p><button type="button" class="btn-secondary" id="hub-api-footgun-clear">Clear API override &amp; reload</button></p>';
598 const clearBtn = el('hub-api-footgun-clear');
599 if (clearBtn) {
600 clearBtn.onclick = () => {
601 try {
602 localStorage.removeItem(HUB_API_URL_LS);
603 } catch (_) {}
604 const u = new URL(location.href);
605 u.searchParams.delete('api');
606 window.location.href = u.toString();
607 };
608 }
609 }
610
611 function getCurrentVaultId() {
612 try {
613 return localStorage.getItem(VAULT_ID_LS) || 'default';
614 } catch (_) {
615 return 'default';
616 }
617 }
618
619 function setCurrentVaultId(id) {
620 try {
621 localStorage.setItem(VAULT_ID_LS, id);
622 } catch (_) {}
623 }
624
625 /** Per-vault hint: Meaning (semantic) search may lag vault edits until Re-index runs successfully. */
626 const HUB_SEMANTIC_INDEX_STALE_PREFIX = 'hub_semantic_index_stale_v1:';
627
628 function hubSemanticIndexStaleLsKey(vaultId) {
629 const v = vaultId != null && String(vaultId).trim() !== '' ? String(vaultId).trim() : 'default';
630 return HUB_SEMANTIC_INDEX_STALE_PREFIX + v;
631 }
632
633 function hubRefreshIndexStaleBanner() {
634 const banner = el('hub-index-stale-banner');
635 if (!banner) return;
636 let flagged = false;
637 try {
638 flagged = Boolean(localStorage.getItem(hubSemanticIndexStaleLsKey(getCurrentVaultId())));
639 } catch (_) {
640 flagged = false;
641 }
642 if (!flagged) {
643 banner.classList.add('hidden');
644 return;
645 }
646 banner.classList.remove('hidden');
647 }
648
649 function hubMarkSemanticIndexStaleForVault(vaultId) {
650 try {
651 localStorage.setItem(hubSemanticIndexStaleLsKey(vaultId), String(Date.now()));
652 } catch (_) {}
653 hubRefreshIndexStaleBanner();
654 }
655
656 function hubMarkSemanticIndexStale() {
657 hubMarkSemanticIndexStaleForVault(getCurrentVaultId());
658 }
659
660 function hubClearSemanticIndexStaleForVault(vaultId) {
661 try {
662 localStorage.removeItem(hubSemanticIndexStaleLsKey(vaultId));
663 } catch (_) {}
664 hubRefreshIndexStaleBanner();
665 }
666
667 function hubClearSemanticIndexStale() {
668 hubClearSemanticIndexStaleForVault(getCurrentVaultId());
669 }
670
671 function updateVaultSwitcher(vaultList, allowedVaultIds) {
672 const wrap = el('vault-switcher-wrap');
673 const select = el('vault-switcher');
674 if (!wrap || !select) return;
675 const rows = Array.isArray(vaultList) ? vaultList : [];
676 const byId = new Map(rows.map((v) => [String(v.id), v]));
677 let allowed =
678 Array.isArray(allowedVaultIds) && allowedVaultIds.length
679 ? allowedVaultIds.map(String)
680 : rows.length
681 ? rows.map((v) => String(v.id))
682 : ['default'];
683 allowed = [...new Set(allowed)];
684 const options = allowed.map((id) => {
685 const v = byId.get(id);
686 return { id, label: v && (v.label || v.id) ? String(v.label || v.id) : id };
687 });
688 select.innerHTML = options
689 .map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label) + '</option>')
690 .join('');
691 select.value = getCurrentVaultId();
692 if (!allowed.includes(select.value)) select.value = allowed[0] || 'default';
693 setCurrentVaultId(select.value);
694 wrap.classList.toggle('hidden', options.length <= 1);
695 if (allowed.length >= 2 && options.length === 1) {
696 select.title =
697 'This Hub has more vaults. To use them, copy your User ID from Settings → Backup into Vault access on Settings → Vaults, then save and refresh.';
698 } else {
699 select.title = '';
700 }
701 select.onchange = () => {
702 setCurrentVaultId(select.value);
703 loadFacets();
704 loadNotes();
705 loadProposals();
706 hubRefreshIndexStaleBanner();
707 };
708 }
709
710 function applyHostedUiFromSettings(s) {
711 if (!s || typeof s !== 'object') return;
712 const hosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
713 window.__hubIsHosted = hosted;
714 const btn = el('btn-projects-help');
715 if (btn) btn.classList.toggle('hidden', !hosted);
716 }
717
718 function normalizeGithubRepoSlug(raw) {
719 let t = (raw || '').trim();
720 if (!t) return '';
721 t = t.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
722 const parts = t.split('/').filter(Boolean);
723 if (parts.length >= 2) return parts[0] + '/' + parts[1];
724 return t;
725 }
726
727 /** Hosted (canister): any logged-in user may sync to their own GitHub; self-hosted still requires admin. */
728 function settingsSyncDisabled(s, vg, isHosted) {
729 const isAdmin = s.role === 'admin';
730 const hostedGitBackup = isHosted && s.github_connect_available;
731 if (hostedGitBackup) {
732 const inputEl = el('settings-hosted-repo');
733 const inputRepo = normalizeGithubRepoSlug(inputEl && inputEl.value);
734 const slug = inputRepo || normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) || normalizeGithubRepoSlug(s.repo);
735 return !s.github_connected || !slug;
736 }
737 return !vg.enabled || !vg.has_remote || !isAdmin;
738 }
739
740 /** After Connect GitHub, blob read-after-write can lag; retry settings until github_connected or timeout. */
741 async function fetchSettingsForBackupModal() {
742 const pendingRaw = sessionStorage.getItem('knowtation_github_connect_pending');
743 const pendingTs = pendingRaw ? parseInt(pendingRaw, 10) : NaN;
744 const pendingFresh = Number.isFinite(pendingTs) && Date.now() - pendingTs < 120000;
745 if (!pendingFresh) {
746 if (pendingRaw) sessionStorage.removeItem('knowtation_github_connect_pending');
747 return api('/api/v1/settings');
748 }
749 let s;
750 for (let attempt = 0; attempt < 8; attempt++) {
751 s = await api('/api/v1/settings');
752 if (s.github_connected || !s.github_connect_available) break;
753 if (attempt < 7) await new Promise((r) => setTimeout(r, 600));
754 }
755 sessionStorage.removeItem('knowtation_github_connect_pending');
756 return s;
757 }
758
759 /** Align with hub/server effectiveRole: viewer read-only; member maps to editor for writes. */
760 function hubUserCanWriteNotes() {
761 const r = window.__hubUserRole;
762 return r === 'editor' || r === 'admin' || r === 'member';
763 }
764
765 /** Same roles as POST /api/v1/proposals on Hub (evaluators propose; viewers do not). */
766 function hubUserMayProposeFromNote() {
767 const r = window.__hubUserRole;
768 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
769 }
770
771 /** Download current note (POST /api/v1/export); allowed for any vault reader including viewer. */
772 function hubUserCanExportNote() {
773 const r = window.__hubUserRole || 'member';
774 return (
775 r === 'editor' || r === 'admin' || r === 'member' || r === 'viewer' || r === 'evaluator'
776 );
777 }
778
779 /** Proposal Enrich (AI): evaluators may run it without note-write roles; editors/admins/members still qualify. */
780 function hubUserMayEnrichProposal() {
781 const r = window.__hubUserRole;
782 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
783 }
784
785 /** Multi-vault copy/move in note detail (Settings must list ≥2 allowed vaults). */
786 function hubHasMultipleVaultsForCopy() {
787 const s = lastBackupSettingsPayload;
788 if (!s || !Array.isArray(s.allowed_vault_ids)) return false;
789 return s.allowed_vault_ids.filter(Boolean).length >= 2;
790 }
791
792 function hubUserIsAdmin() {
793 return window.__hubUserRole === 'admin';
794 }
795
796 /** Delete vault: self-hosted admins only; hosted matches “create vault” (writer + workspace owner when set). */
797 function hubUserMayDeleteVault() {
798 if (!hubUserCanWriteNotes()) return false;
799 if (isHostedHubFromSettings()) {
800 const ws = lastBackupSettingsPayload;
801 const ownerId =
802 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
803 ? String(ws.workspace_owner_id).trim()
804 : '';
805 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
806 if (ownerId && me && me !== ownerId) return false;
807 return true;
808 }
809 return hubUserIsAdmin();
810 }
811
812 function populateSettingsDeleteVaultSelect(s) {
813 const sel = el('settings-delete-vault-select');
814 if (!sel) return;
815 const vaultList = (s && Array.isArray(s.vault_list) && s.vault_list) || [];
816 const allowedRaw = s && Array.isArray(s.allowed_vault_ids) ? s.allowed_vault_ids : null;
817 const allowedSet = allowedRaw && allowedRaw.length > 0 ? new Set(allowedRaw.map(String)) : null;
818 const opts = vaultList.filter((v) => {
819 if (!v || v.id == null) return false;
820 const id = String(v.id).trim();
821 if (!id || id === 'default') return false;
822 if (allowedSet && !allowedSet.has(id)) return false;
823 return true;
824 });
825 sel.innerHTML =
826 opts.length === 0
827 ? '<option value="">(no extra vaults)</option>'
828 : '<option value="">— Choose vault —</option>' +
829 opts
830 .map(
831 (v) =>
832 '<option value="' +
833 escapeHtml(String(v.id)) +
834 '">' +
835 escapeHtml(String(v.label != null && v.label !== '' ? v.label : v.id)) +
836 '</option>',
837 )
838 .join('');
839 }
840
841 function refreshVaultDeleteSubsection() {
842 const wrap = el('settings-danger-zone-vault');
843 if (!wrap) return;
844 const s = lastBackupSettingsPayload;
845 if (!s || !hubUserMayDeleteVault()) {
846 wrap.classList.add('hidden');
847 return;
848 }
849 populateSettingsDeleteVaultSelect(s);
850 const vaultList = (s.vault_list) || [];
851 const extra = vaultList.filter((v) => v && String(v.id).trim() && String(v.id).trim() !== 'default');
852 if (extra.length === 0) {
853 wrap.classList.add('hidden');
854 return;
855 }
856 wrap.classList.remove('hidden');
857 }
858
859 function refreshDeleteProjectPanelVisibility() {
860 const panel = el('settings-danger-zone-panel');
861 if (panel) panel.classList.toggle('hidden', !hubUserCanWriteNotes());
862 refreshVaultDeleteSubsection();
863 }
864
865 /** Apply GET /api/v1/settings payload to header vault switcher, hosted flag, and cached backup modal state. */
866 function applySettingsPayloadToHubChrome(s) {
867 if (!s || typeof s !== 'object') return;
868 lastBackupSettingsPayload = s;
869 if (s.role) window.__hubUserRole = String(s.role);
870 refreshDeleteProjectPanelVisibility();
871 refreshNewProposalTabVisibility();
872 const allowed = (s.allowed_vault_ids || []).map(String);
873 const current = String(getCurrentVaultId());
874 if (allowed.length && !allowed.includes(current)) {
875 setCurrentVaultId(allowed[0] || 'default');
876 }
877 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
878 applyHostedUiFromSettings(s);
879 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
880 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
881 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
882 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
883 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
884 const metaSelf = el('settings-bulk-metadata-self-only');
885 if (metaSelf) metaSelf.classList.remove('hidden');
886 applyMuseBridgePanel(s);
887 }
888
889 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
890 function applyMuseBridgePanel(s) {
891 if (!s || typeof s !== 'object') return;
892 const mb = s.muse_bridge;
893 const statusEl = el('settings-muse-status');
894 const envHint = el('settings-muse-env-hint');
895 const input = el('settings-muse-url');
896 const saveBtn = el('btn-settings-muse-save');
897 const msg = el('settings-muse-msg');
898 if (msg) {
899 msg.textContent = '';
900 msg.className = 'settings-msg';
901 }
902 if (!mb) {
903 if (statusEl) statusEl.textContent = '—';
904 if (input) {
905 input.value = '';
906 input.disabled = true;
907 }
908 if (saveBtn) saveBtn.classList.add('hidden');
909 return;
910 }
911 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
912 const isAdmin = s.role === 'admin';
913 if (statusEl) {
914 statusEl.textContent =
915 mb.enabled && mb.origin
916 ? 'Server status: linked — ' + mb.origin
917 : 'Server status: Muse link not configured for this Hub.';
918 }
919 if (envHint) {
920 envHint.classList.toggle('hidden', !mb.env_override_active);
921 envHint.textContent = mb.env_override_active
922 ? 'This Hub process has MUSE_URL set in its environment; that value overrides config/local.yaml. Change or unset it on the server to edit the field below.'
923 : '';
924 }
925 if (input) {
926 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
927 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
928 input.disabled = !canEdit;
929 input.title = canEdit
930 ? ''
931 : isHosted
932 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
933 : !isAdmin
934 ? 'Only admins can save the Muse URL.'
935 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
936 }
937 if (saveBtn) {
938 const show = !isHosted && isAdmin && mb.url_editable === true;
939 saveBtn.classList.toggle('hidden', !show);
940 }
941 }
942
943 function showLoginChrome() {
944 btnLogout.classList.add('hidden');
945 userName.textContent = '';
946 if (!providers) return;
947 if (providers.google) btnLoginGoogle.classList.remove('hidden');
948 if (providers.github) btnLoginGithub.classList.remove('hidden');
949 if (!providers.google && !providers.github) {
950 oauthNotConfigured.classList.remove('hidden');
951 if (loginIntro) loginIntro.classList.add('hidden');
952 }
953 }
954
955 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
956 let onboardingModulePromise = null;
957 function loadOnboardingModule() {
958 if (!onboardingModulePromise) {
959 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
960 }
961 return onboardingModulePromise;
962 }
963
964 function getOnboardingUserKey() {
965 if (!token) return '';
966 try {
967 const payload = JSON.parse(atob(token.split('.')[1]));
968 return String(payload.sub || payload.email || 'unknown');
969 } catch (_) {
970 return 'unknown';
971 }
972 }
973
974 /**
975 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
976 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
977 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
978 */
979 function wizardHostedFromContext(settingsPayload) {
980 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
981 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
982 if (vd === 'canister') return true;
983 try {
984 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
985 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
986 } catch (_) {}
987 return false;
988 }
989
990 function persistOnboardingProgress(mod, partial) {
991 const userKey = getOnboardingUserKey();
992 const isHosted = wizardHostedFromContext();
993 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
994 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
995 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
996 st = mod.createFreshState(userKey, hostingPath);
997 }
998 Object.assign(st, partial);
999 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1000 }
1001
1002 let onboardingWizardBindingsDone = false;
1003 let onboardingRenderStep = function () {};
1004
1005 function closeOnboardingWizardResume() {
1006 const modal = el('modal-onboarding');
1007 if (!modal || modal.classList.contains('hidden')) return;
1008 modal.classList.add('hidden');
1009 }
1010
1011 function closeOnboardingWizardDismiss() {
1012 loadOnboardingModule()
1013 .then((mod) => {
1014 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1015 updateEmptyVaultStripVisibility();
1016 })
1017 .catch(function () {});
1018 const modal = el('modal-onboarding');
1019 if (modal) modal.classList.add('hidden');
1020 }
1021
1022 function bindOnboardingWizardOnce(mod) {
1023 if (onboardingWizardBindingsDone) return;
1024 onboardingWizardBindingsDone = true;
1025 const modal = el('modal-onboarding');
1026 const closeBtn = el('modal-onboarding-close');
1027 const backdrop = el('modal-onboarding-backdrop');
1028 const btnSkip = el('btn-onboarding-skip');
1029 const btnBack = el('btn-onboarding-back');
1030 const btnNext = el('btn-onboarding-next');
1031 const body = el('onboarding-step-body');
1032 const progress = el('onboarding-progress');
1033 const live = el('onboarding-live');
1034 const secondary = el('onboarding-secondary-actions');
1035
1036 function handleSecondaryAction(id) {
1037 /* Keep onboarding open underneath: Settings / How to use / Projects stack on top (DOM order + z-index). Close the top modal to return to the guide. */
1038 if (id === 'projectsHelp') {
1039 openProjectsHelpModal();
1040 return;
1041 }
1042 if (id === 'howToKnowledge') {
1043 openHowToUse('knowledge-agents');
1044 return;
1045 }
1046 if (id === 'openSettingsBackup') {
1047 openSettings();
1048 return;
1049 }
1050 if (id === 'openSettingsIntegrations') {
1051 openSettingsIntegrationsTab();
1052 return;
1053 }
1054 if (id === 'howToSetup4') {
1055 openHowToUse('setup', 'how-to-step-selfhosted-index');
1056 return;
1057 }
1058 if (id === 'howToSetup3') {
1059 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1060 return;
1061 }
1062 if (id === 'openWhyTokenDoc') {
1063 window.open(
1064 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1065 '_blank',
1066 'noopener,noreferrer',
1067 );
1068 return;
1069 }
1070 if (id === 'openImportModal') {
1071 closeOnboardingWizardResume();
1072 openImportModal();
1073 return;
1074 }
1075 if (id === 'openImportSourcesDoc') {
1076 window.open(
1077 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1078 '_blank',
1079 'noopener,noreferrer',
1080 );
1081 return;
1082 }
1083 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1084 window.open(
1085 id === 'openAgentDocProposals'
1086 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1087 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1088 '_blank',
1089 'noopener,noreferrer',
1090 );
1091 return;
1092 }
1093 if (id === 'focusSuggestedTab') {
1094 closeOnboardingWizardResume();
1095 switchHubMainTab('suggested');
1096 return;
1097 }
1098 }
1099
1100 onboardingRenderStep = function renderOnboardingStep() {
1101 const userKey = getOnboardingUserKey();
1102 const isHosted = wizardHostedFromContext();
1103 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1104 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1105 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1106 st = mod.createFreshState(userKey, hostingPath);
1107 }
1108 const total = mod.getStepCount(isHosted);
1109 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1110 const content = mod.getStepContent(isHosted, idx);
1111 if (body) body.innerHTML = content ? content.bodyHtml : '';
1112 if (content && content.id === 'h-imports' && body) {
1113 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1114 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1115 }
1116
1117 if (progress) {
1118 progress.innerHTML = '';
1119 for (let i = 0; i < total; i++) {
1120 const d = document.createElement('span');
1121 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1122 d.title = 'Step ' + (i + 1) + ' of ' + total;
1123 progress.appendChild(d);
1124 }
1125 }
1126 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1127
1128 if (btnBack) btnBack.disabled = idx <= 0;
1129 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1130
1131 if (secondary) {
1132 secondary.innerHTML = '';
1133 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1134 const b = document.createElement('button');
1135 b.type = 'button';
1136 b.className = 'btn-link btn-link-small';
1137 b.textContent = a.label;
1138 b.addEventListener('click', () => handleSecondaryAction(a.id));
1139 secondary.appendChild(b);
1140 });
1141 }
1142 };
1143
1144 if (btnBack) {
1145 btnBack.addEventListener('click', () => {
1146 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1147 if (!st || st.status !== 'in_progress') return;
1148 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1149 onboardingRenderStep();
1150 });
1151 }
1152 if (btnNext) {
1153 btnNext.addEventListener('click', () => {
1154 const userKey = getOnboardingUserKey();
1155 const isHosted = wizardHostedFromContext();
1156 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1157 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1158 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1159 const total = mod.getStepCount(isHosted);
1160 if (st.stepIndex >= total - 1) {
1161 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1162 if (modal) modal.classList.add('hidden');
1163 return;
1164 }
1165 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1166 onboardingRenderStep();
1167 });
1168 }
1169 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1170 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1171 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1172
1173 modal.addEventListener('click', (ev) => {
1174 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1175 if (!copyBtn || !body) return;
1176 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1177 const txt = ta && ta.value ? String(ta.value) : '';
1178 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1179 ev.preventDefault();
1180 void navigator.clipboard.writeText(txt).then(() => {
1181 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1182 });
1183 });
1184 }
1185
1186 async function openOnboardingWizard(opts) {
1187 const restart = opts && opts.restart;
1188 const mod = await loadOnboardingModule();
1189 bindOnboardingWizardOnce(mod);
1190 const userKey = getOnboardingUserKey();
1191 const isHosted = wizardHostedFromContext();
1192 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1193 if (restart) {
1194 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1195 } else {
1196 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1197 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1198 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1199 }
1200 }
1201 const modal = el('modal-onboarding');
1202 if (modal) modal.classList.remove('hidden');
1203 onboardingRenderStep();
1204 const btnNext = el('btn-onboarding-next');
1205 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1206 }
1207
1208 async function scheduleMaybeShowOnboardingWizard(s) {
1209 if (!token) return;
1210 if (params.get('open') === 'billing') return;
1211 try {
1212 const mod = await loadOnboardingModule();
1213 const userKey = getOnboardingUserKey();
1214 const isHosted = wizardHostedFromContext(s);
1215 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1216 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1217 if (st && st.userKey !== userKey) st = null;
1218 if (st && st.hostingPath !== hostingPath) st = null;
1219 if (!mod.shouldAutoOpenWizard(st, userKey, hostingPath)) return;
1220 const delayMs = pageLoadHadInviteQuery ? 2200 : 0;
1221 setTimeout(() => {
1222 void openOnboardingWizard({ restart: false });
1223 }, delayMs);
1224 } catch (_) {}
1225 }
1226
1227 function showMain() {
1228 if (app) app.classList.remove('login-screen');
1229 loginRequired.classList.add('hidden');
1230 main.classList.remove('hidden');
1231 btnHowToUse.classList.remove('hidden');
1232 if (btnSettings) btnSettings.classList.remove('hidden');
1233 browseToolbar.classList.remove('hidden');
1234 if (token) {
1235 btnLoginGoogle.classList.add('hidden');
1236 btnLoginGithub.classList.add('hidden');
1237 oauthNotConfigured.classList.add('hidden');
1238 btnLogout.classList.remove('hidden');
1239 try {
1240 const payload = JSON.parse(atob(token.split('.')[1]));
1241 userName.textContent = payload.name || payload.sub || 'Logged in';
1242 window.__hubUserRole = payload.role || 'member';
1243 const isViewer = window.__hubUserRole === 'viewer';
1244 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1245 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1246 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1247 refreshDeleteProjectPanelVisibility();
1248 } catch (_) {
1249 userName.textContent = 'Logged in';
1250 window.__hubUserRole = 'member';
1251 if (btnNewNote) btnNewNote.classList.remove('hidden');
1252 if (btnImport) btnImport.classList.remove('hidden');
1253 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1254 refreshDeleteProjectPanelVisibility();
1255 }
1256 } else {
1257 if (btnNewNote) btnNewNote.classList.add('hidden');
1258 if (btnImport) btnImport.classList.add('hidden');
1259 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1260 }
1261 hubRefreshIndexStaleBanner();
1262 }
1263
1264 function loginUrl(provider) {
1265 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1266 const invite = params.get('invite');
1267 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1268 }
1269 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1270 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1271 // because a direct window.location.href navigation has no retry mechanism.
1272 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1273 // guaranteed warm, and the OAuth redirect hits a hot instance.
1274 async function oauthNavigate(provider, btn) {
1275 const original = btn.textContent;
1276 btn.disabled = true;
1277 btn.textContent = 'Connecting…';
1278 try {
1279 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1280 // during this time so the user knows something is happening.
1281 await fetch(apiBase + '/api/v1/auth/providers', {
1282 cache: 'no-store',
1283 signal: AbortSignal.timeout(22000),
1284 });
1285 } catch (_) {
1286 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1287 // OAuth handler itself has the full 26 s budget once TCP is established.
1288 }
1289 window.location.href = loginUrl(provider);
1290 // Navigation is underway; restore button state in case the browser returns here.
1291 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1292 }
1293 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1294 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1295
1296 btnLogout.onclick = () => {
1297 // Revoke the refresh token server-side (real logout), then clear local state regardless
1298 // of whether the network call succeeds.
1299 try {
1300 fetch(apiBase + '/api/v1/auth/logout', {
1301 method: 'POST',
1302 credentials: 'include',
1303 cache: 'no-store',
1304 headers: { 'Content-Type': 'application/json' },
1305 }).catch(() => {});
1306 } catch (_) { /* best effort */ }
1307 token = null;
1308 localStorage.removeItem('hub_token');
1309 if (app) app.classList.add('login-screen');
1310 main.classList.add('hidden');
1311 browseToolbar.classList.add('hidden');
1312 btnNewNote.classList.add('hidden');
1313 if (btnImport) btnImport.classList.add('hidden');
1314 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1315 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1316 if (btnSettings) btnSettings.classList.add('hidden');
1317 closeOnboardingWizardResume();
1318 loginRequired.classList.remove('hidden');
1319 if (loginIntro) loginIntro.classList.remove('hidden');
1320 showLoginChrome();
1321 };
1322
1323 async function initProviders() {
1324 for (let attempt = 0; attempt < 3; attempt++) {
1325 try {
1326 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1327 if (!r.ok) throw new Error('providers');
1328 providers = await r.json();
1329 break;
1330 } catch (_) {
1331 if (attempt < 2) {
1332 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1333 continue;
1334 }
1335 providers = { google: false, github: false };
1336 oauthNotConfigured.classList.remove('hidden');
1337 if (loginIntro) loginIntro.classList.add('hidden');
1338 const first = oauthNotConfigured.querySelector('p');
1339 if (first) {
1340 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1341 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1342 if (isHosted && sameOrigin) {
1343 first.innerHTML =
1344 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1345 '</code> is calling itself for the API, but the API runs on the <strong>gateway</strong>. Set <code>window.HUB_API_BASE_URL</code> in <code>web/hub/config.js</code> to your gateway URL (e.g. <code>https://knowtation-gateway.netlify.app</code>), then commit and redeploy so 4Everland serves the updated config.';
1346 } else if (isHosted && !sameOrigin) {
1347 first.innerHTML =
1348 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1349 '</code> is deployed and allows this site (check <strong>HUB_CORS_ORIGIN</strong> includes <code>' + escapeHtml(location.origin) + '</code>). If the gateway is still deploying on Netlify, wait a few minutes and refresh.';
1350 } else {
1351 first.innerHTML =
1352 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1353 escapeHtml(apiBase) +
1354 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1355 }
1356 }
1357 return;
1358 }
1359 }
1360
1361 if (!providers.google && !providers.github) {
1362 oauthNotConfigured.classList.remove('hidden');
1363 if (loginIntro) loginIntro.classList.add('hidden');
1364 } else {
1365 oauthNotConfigured.classList.add('hidden');
1366 if (loginIntro) loginIntro.classList.remove('hidden');
1367 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1368 const loggedIn =
1369 Boolean(token) ||
1370 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1371 if (!loggedIn) {
1372 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1373 if (providers.github) btnLoginGithub.classList.remove('hidden');
1374 }
1375 }
1376 }
1377
1378 if (token) {
1379 if (params.get('invite')) {
1380 (async () => {
1381 const inviteToken = params.get('invite');
1382 let lastErr;
1383 for (let attempt = 0; attempt < 3; attempt++) {
1384 try {
1385 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1386 const u = new URL(location.href);
1387 u.searchParams.delete('invite');
1388 u.searchParams.set('invite_accepted', '1');
1389 history.replaceState({}, '', u.toString());
1390 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1391 return;
1392 } catch (e) {
1393 lastErr = e;
1394 const code = e && e.code;
1395 const msg = String(e && e.message ? e.message : e || '');
1396 const staleInvite =
1397 code === 'NOT_FOUND' ||
1398 code === 'EXPIRED' ||
1399 /not found|already used|expired/i.test(msg);
1400 if (staleInvite) {
1401 const u = new URL(location.href);
1402 u.searchParams.delete('invite');
1403 history.replaceState({}, '', u.toString());
1404 if (code === 'EXPIRED' && typeof showToast === 'function') {
1405 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
1406 }
1407 return;
1408 }
1409 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
1410 }
1411 }
1412 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
1413 })();
1414 }
1415 showMain();
1416 getImageProxyToken().catch(function () {});
1417 (async function ensureVaultAndSwitcherThenLoad() {
1418 let settingsPayload = null;
1419 try {
1420 settingsPayload = await api('/api/v1/settings');
1421 applySettingsPayloadToHubChrome(settingsPayload);
1422 } catch (_) {}
1423 syncHubListSortUI('notes');
1424 setProposalFiltersBarVisible(false);
1425 refreshNewProposalTabVisibility();
1426 loadFacets();
1427 loadNotes();
1428 loadProposals();
1429 loadActivity();
1430 renderPresets();
1431 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
1432 })();
1433 initProviders();
1434 if (params.get('open') === 'billing') {
1435 const checkoutSuccess = params.get('checkout') === 'success';
1436 // Clean up params before opening so back-button doesn't re-trigger.
1437 const u = new URL(location.href);
1438 u.searchParams.delete('open');
1439 u.searchParams.delete('checkout');
1440 history.replaceState({}, '', u.toString());
1441 // Small delay so the main Hub has rendered before the modal opens.
1442 setTimeout(() => {
1443 openSettingsBillingTab();
1444 if (checkoutSuccess && typeof showToast === 'function') {
1445 showToast('Subscription activated — welcome to your new plan!');
1446 }
1447 }, 400);
1448 }
1449 if (params.get('github_connected') === '1') {
1450 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
1451 setTimeout(() => {
1452 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
1453 const u = new URL(location.href);
1454 u.searchParams.delete('github_connected');
1455 history.replaceState({}, '', u.toString());
1456 }, 500);
1457 } else if (params.get('github_connect_error')) {
1458 setTimeout(() => {
1459 const code = params.get('github_connect_error');
1460 const msg =
1461 code === 'blob_storage'
1462 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
1463 : 'GitHub connect: ' + code;
1464 if (typeof showToast === 'function') showToast(msg, true);
1465 const u = new URL(location.href);
1466 u.searchParams.delete('github_connect_error');
1467 history.replaceState({}, '', u.toString());
1468 }, 500);
1469 }
1470 } else {
1471 if (app) app.classList.add('login-screen');
1472 main.classList.add('hidden');
1473 loginRequired.classList.remove('hidden');
1474 btnNewNote.classList.add('hidden');
1475 if (btnImport) btnImport.classList.add('hidden');
1476 const inviteBanner = el('login-invite-banner');
1477 if (inviteBanner && params.get('invite')) {
1478 inviteBanner.textContent = "You've been invited. Sign in to join.";
1479 inviteBanner.classList.remove('hidden');
1480 }
1481 initProviders();
1482 }
1483 refreshApiBaseFootgunBanner();
1484 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
1485 setTimeout(() => {
1486 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1487 const u = new URL(location.href);
1488 u.searchParams.delete('invite_accepted');
1489 history.replaceState({}, '', u.pathname + u.search);
1490 }, 500);
1491 }
1492
1493 function dateSlice(d) {
1494 if (!d || typeof d !== 'string') return '';
1495 return d.trim().slice(0, 10);
1496 }
1497
1498 /** Hosted canister returns frontmatter as a JSON string; self-hosted often uses an object. List metadata (date, title, …) is flattened on self-hosted list responses — mirror that here. Keep in sync with lib/parse-frontmatter-json.mjs. */
1499 function materializeFrontmatter(fm) {
1500 if (fm == null) return {};
1501 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
1502 if (typeof fm === 'string') {
1503 let cur = fm.replace(/^\uFEFF/, '').trim();
1504 if (!cur) return {};
1505 for (let i = 0; i < 8; i++) {
1506 try {
1507 const o = JSON.parse(cur);
1508 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
1509 if (typeof o === 'string') {
1510 const next = o.trim();
1511 if (next === cur) return {};
1512 cur = next;
1513 continue;
1514 }
1515 return {};
1516 } catch {
1517 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
1518 try {
1519 const inner = JSON.parse(cur);
1520 if (typeof inner === 'string') {
1521 cur = inner.trim();
1522 continue;
1523 }
1524 } catch {
1525 /* fall through */
1526 }
1527 }
1528 return {};
1529 }
1530 }
1531 return {};
1532 }
1533 return {};
1534 }
1535
1536 function tagsFromFrontmatter(fm) {
1537 const raw = fm && fm.tags;
1538 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1539 if (typeof raw === 'string' && raw.trim()) {
1540 return raw
1541 .split(/[,\n]/)
1542 .map((s) => s.trim())
1543 .filter(Boolean);
1544 }
1545 return [];
1546 }
1547
1548 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
1549 function isoDateLocalFromMs(ms) {
1550 const d = new Date(ms);
1551 if (Number.isNaN(d.getTime())) return null;
1552 const y = d.getFullYear();
1553 const mo = String(d.getMonth() + 1).padStart(2, '0');
1554 const day = String(d.getDate()).padStart(2, '0');
1555 return y + '-' + mo + '-' + day;
1556 }
1557
1558 /**
1559 * Calendar bucket for Hub list/calendar/overview.
1560 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
1561 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
1562 */
1563 function calendarDisplayDayKey(raw) {
1564 if (raw == null) return null;
1565 const s = String(raw).trim();
1566 if (!s) return null;
1567 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
1568 const ms = Date.parse(s);
1569 if (Number.isNaN(ms)) return s.slice(0, 10);
1570 return isoDateLocalFromMs(ms);
1571 }
1572
1573 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
1574 function inferredDisplayDateFromNotePath(notePath) {
1575 if (!notePath || typeof notePath !== 'string') return null;
1576 const base = notePath.split('/').pop() || '';
1577 const m = /^note-(\d{10,})\.md$/i.exec(base);
1578 if (!m) return null;
1579 const ms = Number(m[1]);
1580 if (!Number.isFinite(ms)) return null;
1581 return isoDateLocalFromMs(ms);
1582 }
1583
1584 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
1585 function listItemDisplayDate(n, fm) {
1586 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
1587 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
1588 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
1589 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
1590 const inferred = inferredDisplayDateFromNotePath(n.path);
1591 return inferred || null;
1592 }
1593
1594 function noteSortOrCalendarDay(n) {
1595 const raw = n.date || n.updated || '';
1596 return calendarDisplayDayKey(raw) || dateSlice(raw);
1597 }
1598
1599 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
1600 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
1601 const HUB_SORT_NOTES_OPTS = [
1602 { v: 'date_desc', l: 'Newest first' },
1603 { v: 'date_asc', l: 'Oldest first' },
1604 { v: 'year_desc', l: 'Year (newest first)' },
1605 { v: 'year_asc', l: 'Year (oldest first)' },
1606 { v: 'path_asc', l: 'Path A–Z' },
1607 { v: 'title_asc', l: 'Title A–Z' },
1608 ];
1609 const HUB_SORT_PROP_OPTS = [
1610 { v: 'updated_desc', l: 'Newest first' },
1611 { v: 'updated_asc', l: 'Oldest first' },
1612 { v: 'path_asc', l: 'Path A–Z' },
1613 { v: 'status_asc', l: 'Status A–Z' },
1614 ];
1615
1616 function hubListSortGetSelect() {
1617 return el('hub-list-sort');
1618 }
1619
1620 function syncHubListSortUI(activeTab) {
1621 const sel = hubListSortGetSelect();
1622 if (!sel) return;
1623 const isNotes = activeTab === 'notes';
1624 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
1625 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
1626 let saved = '';
1627 try {
1628 saved = localStorage.getItem(key) || '';
1629 } catch (_) {}
1630 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
1631 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
1632 sel.value = saved;
1633 }
1634
1635 function setProposalFiltersBarVisible(show) {
1636 const bar = el('proposal-filters-bar');
1637 if (bar) bar.classList.toggle('hidden', !show);
1638 }
1639
1640 function refreshNewProposalTabVisibility() {
1641 const btn = el('btn-new-proposal');
1642 if (!btn) return;
1643 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1644 const show = tab === 'suggested' && hubUserCanWriteNotes();
1645 btn.classList.toggle('hidden', !show);
1646 }
1647
1648 function applySortedNotesClient(notes) {
1649 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1650 if (tab !== 'notes') return notes;
1651 const S = globalThis.HubListSort;
1652 const sel = hubListSortGetSelect();
1653 const mode = sel && sel.value ? sel.value : 'date_desc';
1654 if (!S || typeof S.sortNotesList !== 'function') return notes;
1655 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
1656 }
1657
1658 function applySortedProposalsClient(list) {
1659 const S = globalThis.HubListSort;
1660 const sel = hubListSortGetSelect();
1661 const mode = sel && sel.value ? sel.value : 'updated_desc';
1662 if (!S || typeof S.sortProposalsList !== 'function') return list;
1663 return S.sortProposalsList(list, mode);
1664 }
1665
1666 function normalizeHubListItem(n) {
1667 if (!n || typeof n !== 'object') return n;
1668 const fm = materializeFrontmatter(n.frontmatter);
1669 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
1670 const displayDate = listItemDisplayDate(n, fm);
1671 const updated =
1672 n.updated != null
1673 ? String(n.updated)
1674 : fm.knowtation_edited_at != null
1675 ? String(fm.knowtation_edited_at)
1676 : null;
1677 return {
1678 ...n,
1679 frontmatter: fm,
1680 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
1681 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
1682 tags,
1683 date: displayDate,
1684 updated,
1685 };
1686 }
1687
1688 function facetsAreEmpty(f) {
1689 if (!f || typeof f !== 'object') return true;
1690 const pl = f.projects && f.projects.length;
1691 const tl = f.tags && f.tags.length;
1692 const fl = f.folders && f.folders.length;
1693 return !pl && !tl && !fl;
1694 }
1695
1696 async function deriveFacetsFromNotes() {
1697 const out = await api('/api/v1/notes?limit=500&offset=0');
1698 const projects = new Set();
1699 const tags = new Set();
1700 const folders = new Set();
1701 for (const raw of out.notes || []) {
1702 const n = normalizeHubListItem(raw);
1703 if (n.path) {
1704 const seg = String(n.path).split('/')[0];
1705 if (seg) folders.add(seg);
1706 }
1707 if (n.project) projects.add(String(n.project));
1708 (n.tags || []).forEach((t) => tags.add(String(t)));
1709 }
1710 return {
1711 projects: [...projects].sort((a, b) => a.localeCompare(b)),
1712 tags: [...tags].sort((a, b) => a.localeCompare(b)),
1713 folders: [...folders].sort((a, b) => a.localeCompare(b)),
1714 };
1715 }
1716
1717 async function fetchFacetsResolved() {
1718 let facets = await api('/api/v1/notes/facets');
1719 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
1720 return facets;
1721 }
1722
1723 function hubRowIsApprovalLog(n) {
1724 if (!n || !n.path) return false;
1725 const path = String(n.path).replace(/\\/g, '/');
1726 if (path === 'approvals' || path.startsWith('approvals/')) return true;
1727 const k =
1728 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
1729 return String(k) === 'approval_log';
1730 }
1731
1732 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
1733 function applyVaultListFilters(notes, opts) {
1734 let out = notes.slice();
1735 if (opts.folder) {
1736 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
1737 const prefix = f + '/';
1738 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
1739 }
1740 if (opts.project) {
1741 const p = normSlug(opts.project);
1742 out = out.filter(
1743 (n) =>
1744 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
1745 );
1746 }
1747 if (opts.tag) {
1748 const t = normSlug(opts.tag);
1749 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
1750 }
1751 if (opts.since) {
1752 const s = dateSlice(opts.since);
1753 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
1754 }
1755 if (opts.until) {
1756 const u = dateSlice(opts.until);
1757 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
1758 }
1759 const cs = opts.content_scope;
1760 if (cs === 'notes') {
1761 out = out.filter((n) => !hubRowIsApprovalLog(n));
1762 } else if (cs === 'approval_logs') {
1763 out = out.filter((n) => hubRowIsApprovalLog(n));
1764 }
1765 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
1766 if (opts.network) {
1767 const net = String(opts.network).trim().toLowerCase();
1768 out = out.filter((n) => {
1769 const v = n.frontmatter?.network ?? n.network;
1770 return v != null && String(v).trim().toLowerCase() === net;
1771 });
1772 }
1773 if (opts.wallet_address) {
1774 const wa = String(opts.wallet_address).trim().toLowerCase();
1775 out = out.filter((n) => {
1776 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
1777 return v != null && String(v).trim().toLowerCase() === wa;
1778 });
1779 }
1780 if (opts.payment_status) {
1781 const ps = String(opts.payment_status).trim().toLowerCase();
1782 out = out.filter((n) => {
1783 const v = n.frontmatter?.payment_status ?? n.payment_status;
1784 return v != null && String(v).trim().toLowerCase() === ps;
1785 });
1786 }
1787 return out;
1788 }
1789
1790 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
1791 const HUB_RESERVED_FM_KEYS = new Set([
1792 'knowtation_editor',
1793 'knowtation_edited_at',
1794 'author_kind',
1795 'knowtation_proposed_by',
1796 'knowtation_approved_by',
1797 ]);
1798
1799 function stripReservedHubFm(fm) {
1800 const out = {};
1801 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
1802 for (const [k, v] of Object.entries(fm)) {
1803 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
1804 out[k] = v;
1805 }
1806 return out;
1807 }
1808
1809 /**
1810 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
1811 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
1812 */
1813 function stringifyNotePostPayload(path, body, frontmatter) {
1814 const fmStr =
1815 typeof frontmatter === 'string'
1816 ? frontmatter
1817 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
1818 return JSON.stringify({ path, body, frontmatter: fmStr });
1819 }
1820
1821 const DETAIL_EDIT_FM_KEYS = [
1822 'title',
1823 'date',
1824 'project',
1825 'tags',
1826 'causal_chain_id',
1827 'entity',
1828 'episode_id',
1829 'follows',
1830 ];
1831
1832 function mergedFrontmatterForDetailSave() {
1833 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
1834 const preserved = {};
1835 for (const [k, v] of Object.entries(base)) {
1836 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
1837 }
1838 const dateVal =
1839 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
1840 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
1841 const tTitle = title.trim();
1842 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1843 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
1844 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
1845 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
1846 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
1847 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
1848 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
1849 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
1850 const follows = followsRaw
1851 ? followsRaw.includes(',')
1852 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
1853 : followsRaw
1854 : undefined;
1855 const out = { ...preserved, date: dateVal };
1856 if (tTitle) out.title = tTitle;
1857 else delete out.title;
1858 if (project) out.project = project;
1859 else delete out.project;
1860 if (tags) out.tags = tags;
1861 else delete out.tags;
1862 if (causalChain) out.causal_chain_id = causalChain;
1863 else delete out.causal_chain_id;
1864 if (entity.length) out.entity = entity;
1865 else delete out.entity;
1866 if (episode) out.episode_id = episode;
1867 else delete out.episode_id;
1868 if (follows) out.follows = follows;
1869 else delete out.follows;
1870 return out;
1871 }
1872
1873 function fillDetailEditFieldsFromFrontmatter(fm) {
1874 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
1875 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1876 const savedProj = f.project != null ? String(f.project).trim() : '';
1877 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
1878 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
1879 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
1880 if (el('detail-edit-project')) {
1881 const inp = el('detail-edit-project');
1882 if (pathProj) {
1883 inp.value = pathProj;
1884 inp.readOnly = true;
1885 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
1886 } else {
1887 inp.readOnly = false;
1888 inp.title = '';
1889 inp.value = savedProj;
1890 }
1891 }
1892 const hint = el('detail-edit-project-hint');
1893 if (hint) {
1894 if (pathProj) {
1895 hint.classList.remove('hidden');
1896 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
1897 hint.textContent = mismatch
1898 ? 'Path implies project «' +
1899 pathProj +
1900 '»; saved frontmatter had «' +
1901 savedProj +
1902 '». Saving will store «' +
1903 pathProj +
1904 '» to match the path.'
1905 : 'Project slug matches vault path projects/' + pathProj + '/.';
1906 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
1907 } else {
1908 hint.classList.remove('hidden');
1909 hint.className = 'muted small detail-project-hint';
1910 hint.textContent =
1911 'Optional frontmatter label for filters and charts. It does not have to match the file path. If you use a path like projects/your-slug/…, the Hub keeps this field aligned with that folder name.';
1912 }
1913 }
1914 const pathTypoEl = el('detail-edit-path-typo-hint');
1915 if (pathTypoEl && currentOpenNote) {
1916 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
1917 if (sug) {
1918 pathTypoEl.textContent =
1919 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
1920 sug +
1921 '. Rename or move the file in your vault (path cannot be edited here).';
1922 pathTypoEl.className = 'muted small detail-project-hint warn';
1923 pathTypoEl.classList.remove('hidden');
1924 } else {
1925 pathTypoEl.textContent = '';
1926 pathTypoEl.className = 'muted small detail-project-hint hidden';
1927 pathTypoEl.classList.add('hidden');
1928 }
1929 }
1930 const tags = f.tags;
1931 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
1932 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
1933 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
1934 const ent = f.entity;
1935 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
1936 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
1937 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
1938 const fol = f.follows;
1939 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
1940 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
1941 }
1942
1943 async function loadFacets() {
1944 try {
1945 const savedProject = filterProject.value;
1946 const savedTag = filterTag.value;
1947 const savedFolder = filterFolder.value;
1948 const savedNetwork = filterNetwork ? filterNetwork.value : '';
1949 const savedWallet = filterWallet ? filterWallet.value : '';
1950 const facets = await fetchFacetsResolved();
1951 lastHubFacets = facets;
1952 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
1953 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
1954 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
1955 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
1956 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
1957 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
1958 // Phase 12 — blockchain filter dropdowns (hidden when no data)
1959 if (filterNetwork) {
1960 const nets = facets.networks || [];
1961 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
1962 filterNetwork.classList.toggle('hidden', nets.length === 0);
1963 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
1964 }
1965 if (filterWallet) {
1966 const wallets = facets.wallets || [];
1967 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
1968 filterWallet.classList.toggle('hidden', wallets.length === 0);
1969 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
1970 }
1971 renderFilterChips(facets);
1972 hydrateFullCreateProjectSlugSelect(facets);
1973 hydrateImportCreateProjectSlugSelect(facets);
1974 } catch (_) {
1975 renderFilterChips(null);
1976 lastHubFacets = null;
1977 hydrateFullCreateProjectSlugSelect(null);
1978 hydrateImportCreateProjectSlugSelect(null);
1979 }
1980 }
1981
1982 function normSlug(s) {
1983 return String(s || '')
1984 .toLowerCase()
1985 .replace(/[^a-z0-9-]/g, '-')
1986 .replace(/-+/g, '-')
1987 .replace(/^-|-$/g, '');
1988 }
1989
1990 /**
1991 * First path segment after `projects/` (vault-relative). Used so project frontmatter
1992 * stays aligned with on-disk layout (projects/<slug>/…).
1993 */
1994 function projectSlugFromProjectsPath(path) {
1995 if (!path || typeof path !== 'string') return null;
1996 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
1997 return m ? m[1] : null;
1998 }
1999
2000 /**
2001 * Common typo: vault path starts with `project/` instead of `projects/`.
2002 * Returns the same path with the corrected prefix, or null if no typo.
2003 */
2004 function projectsPathTypoSuggestion(path) {
2005 const p = String(path || '').trim();
2006 if (!p) return null;
2007 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2008 return null;
2009 }
2010
2011 function normalizeProjectKeyForSimilarity(s) {
2012 return String(s || '')
2013 .toLowerCase()
2014 .trim()
2015 .replace(/[\s_]+/g, '-')
2016 .replace(/-+/g, '-')
2017 .replace(/^-|-$/g, '');
2018 }
2019
2020 function levenshteinHub(a, b) {
2021 const m = a.length;
2022 const n = b.length;
2023 if (!m) return n;
2024 if (!n) return m;
2025 const row = new Array(n + 1);
2026 for (let j = 0; j <= n; j++) row[j] = j;
2027 for (let i = 1; i <= m; i++) {
2028 let prev = row[0];
2029 row[0] = i;
2030 for (let j = 1; j <= n; j++) {
2031 const cur = row[j];
2032 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2033 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2034 prev = cur;
2035 }
2036 }
2037 return row[n];
2038 }
2039
2040 /**
2041 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2042 * Exact normSlug match returns null (no warning).
2043 */
2044 function findSimilarFacetProject(userSlug, projectsArr) {
2045 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2046 const uNorm = normSlug(String(userSlug));
2047 if (!uNorm) return null;
2048 for (const p of projectsArr) {
2049 if (normSlug(String(p)) === uNorm) return null;
2050 }
2051 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2052 let best = null;
2053 let bestScore = Infinity;
2054 for (const p of projectsArr) {
2055 const pv = String(p).trim();
2056 if (!pv) continue;
2057 const pNorm = normSlug(pv);
2058 if (!pNorm) continue;
2059 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2060 let score = Infinity;
2061 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2062 if (score > 0) {
2063 const a = normalizeProjectKeyForSimilarity(userSlug);
2064 const b = normalizeProjectKeyForSimilarity(pv);
2065 const d = levenshteinHub(a, b);
2066 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2067 }
2068 if (score > 0) {
2069 const a = normalizeProjectKeyForSimilarity(userSlug);
2070 const b = normalizeProjectKeyForSimilarity(pv);
2071 const shorter = a.length <= b.length ? a : b;
2072 const longer = a.length <= b.length ? b : a;
2073 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2074 score = Math.min(score, longer.length - shorter.length + 0.5);
2075 }
2076 }
2077 if (score < bestScore) {
2078 bestScore = score;
2079 best = pv;
2080 }
2081 }
2082 return bestScore < 10 ? best : null;
2083 }
2084
2085 function collectProjectSubroots(slug, folderStrings) {
2086 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2087 const subs = new Set();
2088 for (const f of folderStrings || []) {
2089 if (!f || typeof f !== 'string') continue;
2090 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2091 if (!n.startsWith(prefix)) continue;
2092 const rest = n.slice(prefix.length);
2093 if (!rest) continue;
2094 const first = rest.split('/')[0];
2095 if (first) subs.add(first);
2096 }
2097 return [...subs].sort((a, b) => a.localeCompare(b));
2098 }
2099
2100 function fullCreatePathFilename(pathVal) {
2101 const t = String(pathVal || '').trim();
2102 const parts = t.split('/').filter(Boolean);
2103 const last = parts[parts.length - 1];
2104 if (last && /\.md$/i.test(last)) return last;
2105 return 'note-' + Date.now() + '.md';
2106 }
2107
2108 function mergeFolderStringsForSubroots() {
2109 const out = new Set();
2110 for (const f of lastVaultFoldersForCreate || []) {
2111 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2112 }
2113 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2114 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2115 }
2116 return [...out];
2117 }
2118
2119 function updateFullCreatePathLayoutVisibility() {
2120 const slugSel = el('full-create-project-slug');
2121 const subWrap = el('full-create-project-subroot-wrap');
2122 const nonProj = el('full-create-nonproject-folder-wrap');
2123 const subSel = el('full-create-project-subroot');
2124 if (!slugSel) return;
2125 const v = slugSel.value;
2126 const useProject = v && v !== '__custom__';
2127 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2128 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2129 if (subSel) subSel.disabled = !useProject;
2130 }
2131
2132 function refreshFullCreateSubrootSelect() {
2133 const slugSel = el('full-create-project-slug');
2134 const subSel = el('full-create-project-subroot');
2135 if (!slugSel || !subSel) return;
2136 const slug = slugSel.value;
2137 const preserve = subSel.value;
2138 if (!slug || slug === '__custom__') {
2139 subSel.innerHTML = '';
2140 subSel.disabled = true;
2141 return;
2142 }
2143 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2144 const head = document.createElement('option');
2145 head.value = '';
2146 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2147 subSel.innerHTML = '';
2148 subSel.appendChild(head);
2149 for (const s of subs) {
2150 const o = document.createElement('option');
2151 o.value = s;
2152 o.textContent = s;
2153 subSel.appendChild(o);
2154 }
2155 const custom = document.createElement('option');
2156 custom.value = '__custom_sub__';
2157 custom.textContent = 'Custom (edit path)';
2158 subSel.appendChild(custom);
2159 subSel.disabled = false;
2160 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2161 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2162 else if (subs.includes('inbox')) subSel.value = 'inbox';
2163 else if (subs.length === 1) subSel.value = subs[0];
2164 else subSel.value = '';
2165 }
2166
2167 function composeFullPathFromCreatePickers() {
2168 const slugSel = el('full-create-project-slug');
2169 const subSel = el('full-create-project-subroot');
2170 const pathInp = el('full-path');
2171 if (!slugSel || !pathInp) return;
2172 const slugVal = slugSel.value;
2173 if (!slugVal || slugVal === '__custom__') return;
2174 if (subSel && subSel.value === '__custom_sub__') return;
2175 const sub =
2176 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2177 const fname = fullCreatePathFilename(pathInp.value);
2178 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2179 pathInp.value = base;
2180 }
2181
2182 function syncFullCreatePickersFromPath() {
2183 const slugSel = el('full-create-project-slug');
2184 const subSel = el('full-create-project-subroot');
2185 const pathInp = el('full-path');
2186 if (!slugSel || !pathInp) return;
2187 const raw = pathInp.value.trim();
2188 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2189 if (!m) {
2190 slugSel.value = raw ? '__custom__' : '';
2191 refreshFullCreateSubrootSelect();
2192 updateFullCreatePathLayoutVisibility();
2193 return;
2194 }
2195 const diskSlug = m[1];
2196 const rest = m[2];
2197 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2198 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2199 if (match) slugSel.value = match;
2200 else slugSel.value = '__custom__';
2201 refreshFullCreateSubrootSelect();
2202 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2203 const segments = rest.split('/').filter(Boolean);
2204 const lastSeg = segments[segments.length - 1];
2205 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2206 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2207 const firstDir = dirParts[0] || '';
2208 const allowed = new Set(
2209 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2210 );
2211 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2212 else if (firstDir) subSel.value = '__custom_sub__';
2213 else subSel.value = '';
2214 }
2215 updateFullCreatePathLayoutVisibility();
2216 }
2217
2218 function hydrateFullCreateProjectSlugSelect(facets) {
2219 const sel = el('full-create-project-slug');
2220 if (!sel) return;
2221 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2222 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2223 const preserve = sel.value;
2224 sel.innerHTML =
2225 '<option value="">— Not under projects/ —</option>' +
2226 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2227 '<option value="__custom__">Custom (type full path)</option>';
2228 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2229 refreshFullCreateSubrootSelect();
2230 updateFullCreatePathLayoutVisibility();
2231 }
2232
2233 function updateImportPathLayoutVisibility() {
2234 const slugSel = el('import-create-project-slug');
2235 const subWrap = el('import-create-project-subroot-wrap');
2236 const nonProj = el('import-nonproject-folder-wrap');
2237 const subSel = el('import-create-project-subroot');
2238 if (!slugSel) return;
2239 const v = slugSel.value;
2240 const useProject = v && v !== '__custom__';
2241 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2242 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2243 if (subSel) subSel.disabled = !useProject;
2244 }
2245
2246 function refreshImportCreateSubrootSelect() {
2247 const slugSel = el('import-create-project-slug');
2248 const subSel = el('import-create-project-subroot');
2249 if (!slugSel || !subSel) return;
2250 const slug = slugSel.value;
2251 const preserve = subSel.value;
2252 if (!slug || slug === '__custom__') {
2253 subSel.innerHTML = '';
2254 subSel.disabled = true;
2255 return;
2256 }
2257 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2258 const head = document.createElement('option');
2259 head.value = '';
2260 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2261 subSel.innerHTML = '';
2262 subSel.appendChild(head);
2263 for (const s of subs) {
2264 const o = document.createElement('option');
2265 o.value = s;
2266 o.textContent = s;
2267 subSel.appendChild(o);
2268 }
2269 const custom = document.createElement('option');
2270 custom.value = '__custom_sub__';
2271 custom.textContent = 'Custom (edit path)';
2272 subSel.appendChild(custom);
2273 subSel.disabled = false;
2274 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2275 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2276 else if (subs.includes('inbox')) subSel.value = 'inbox';
2277 else if (subs.length === 1) subSel.value = subs[0];
2278 else subSel.value = '';
2279 }
2280
2281 function composeImportOutputDirFromPickers() {
2282 const slugSel = el('import-create-project-slug');
2283 const subSel = el('import-create-project-subroot');
2284 const outInp = el('import-output-dir');
2285 if (!slugSel || !outInp) return;
2286 const slugVal = slugSel.value;
2287 if (!slugVal || slugVal === '__custom__') return;
2288 if (subSel && subSel.value === '__custom_sub__') return;
2289 const sub =
2290 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2291 const subUse = sub || 'inbox';
2292 outInp.value = 'projects/' + slugVal + '/' + subUse;
2293 }
2294
2295 function syncImportPickersFromOutputDir() {
2296 const slugSel = el('import-create-project-slug');
2297 const subSel = el('import-create-project-subroot');
2298 const outInp = el('import-output-dir');
2299 if (!slugSel || !outInp) return;
2300 const raw = outInp.value.trim().replace(/\/+$/, '');
2301 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2302 if (!m) {
2303 slugSel.value = raw ? '__custom__' : '';
2304 refreshImportCreateSubrootSelect();
2305 updateImportPathLayoutVisibility();
2306 return;
2307 }
2308 const diskSlug = m[1];
2309 const rest = m[2] || '';
2310 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2311 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2312 if (match) slugSel.value = match;
2313 else slugSel.value = '__custom__';
2314 refreshImportCreateSubrootSelect();
2315 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2316 const segments = rest.split('/').filter(Boolean);
2317 const firstDir = segments[0] || '';
2318 const allowed = new Set(
2319 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2320 );
2321 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2322 else if (firstDir) subSel.value = '__custom_sub__';
2323 else subSel.value = '';
2324 }
2325 updateImportPathLayoutVisibility();
2326 }
2327
2328 function hydrateImportCreateProjectSlugSelect(facets) {
2329 const sel = el('import-create-project-slug');
2330 if (!sel) return;
2331 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2332 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2333 const preserve = sel.value;
2334 sel.innerHTML =
2335 '<option value="">— Not under projects/ —</option>' +
2336 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2337 '<option value="__custom__">Custom (type full path)</option>';
2338 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2339 refreshImportCreateSubrootSelect();
2340 updateImportPathLayoutVisibility();
2341 }
2342
2343 function syncImportFolderSelectToOutputDir() {
2344 const outInp = el('import-output-dir');
2345 const sel = el('import-vault-folder');
2346 if (!outInp || !sel) return;
2347 const p = outInp.value.trim().replace(/\/+$/, '');
2348 if (!p) return;
2349 let best = '__custom__';
2350 let bestLen = -1;
2351 for (const opt of sel.options) {
2352 const v = opt.value;
2353 if (v === '__custom__') continue;
2354 if (p === v || p.startsWith(v + '/')) {
2355 if (v.length > bestLen) {
2356 best = v;
2357 bestLen = v.length;
2358 }
2359 }
2360 }
2361 sel.value = bestLen >= 0 ? best : '__custom__';
2362 }
2363
2364 function defaultImportOutputDir() {
2365 const slugSel = el('import-create-project-slug');
2366 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2367 const subSel = el('import-create-project-subroot');
2368 const sub =
2369 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2370 const subUse = sub || 'inbox';
2371 return 'projects/' + slugSel.value + '/' + subUse;
2372 }
2373 const sel = el('import-vault-folder');
2374 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2375 return folder;
2376 }
2377
2378 function getImportProjectAndOutputDir() {
2379 const outInp = el('import-output-dir');
2380 const slugSel = el('import-create-project-slug');
2381 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2382 if (raw) {
2383 const sug = projectsPathTypoSuggestion(raw);
2384 if (sug) {
2385 return {
2386 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
2387 project: '',
2388 outputDir: undefined,
2389 };
2390 }
2391 }
2392 const outputDir = raw || undefined;
2393 let project = '';
2394 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2395 project = normSlug(slugSel.value);
2396 }
2397 if (!project && outputDir) {
2398 const m = outputDir.match(/^projects\/([^/]+)/);
2399 if (m) project = normSlug(m[1]);
2400 }
2401 return { err: null, project: project || '', outputDir: outputDir || undefined };
2402 }
2403
2404 function updateFullCreateSimilarInlineHint() {
2405 const hint = el('full-path-similar-hint');
2406 const btn = el('btn-full-path-use-similar-project');
2407 const pathInp = el('full-path');
2408 if (!hint || !pathInp) return;
2409 const notePath = pathInp.value.trim();
2410 const slug = projectSlugFromProjectsPath(notePath);
2411 const similar =
2412 slug && (lastHubFacets && lastHubFacets.projects)
2413 ? findSimilarFacetProject(slug, lastHubFacets.projects)
2414 : null;
2415 if (similar && notePath.startsWith('projects/')) {
2416 hint.textContent =
2417 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
2418 hint.className = 'muted small detail-project-hint warn';
2419 hint.classList.remove('hidden');
2420 if (btn) {
2421 btn.classList.remove('hidden');
2422 btn.onclick = () => {
2423 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
2424 pathInp.value = fixed;
2425 syncFolderSelectToPathInput();
2426 syncFullCreatePickersFromPath();
2427 syncFullProjectFromPath();
2428 updateFullPathProjectTypoHint();
2429 updateFullCreateSimilarInlineHint();
2430 };
2431 }
2432 } else {
2433 hint.textContent = '';
2434 hint.className = 'muted small detail-project-hint hidden';
2435 hint.classList.add('hidden');
2436 if (btn) {
2437 btn.classList.add('hidden');
2438 btn.onclick = null;
2439 }
2440 }
2441 }
2442
2443 function scheduleFullCreateSimilarHint() {
2444 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
2445 fullPathSimilarDebounceTimer = window.setTimeout(() => {
2446 fullPathSimilarDebounceTimer = 0;
2447 updateFullCreateSimilarInlineHint();
2448 }, 220);
2449 }
2450
2451 function openFullCreateSimilarModal(notePath, suggestedSlug) {
2452 const modal = el('modal-create-similar-project');
2453 const body = el('modal-create-similar-project-body');
2454 if (!modal || !body) return;
2455 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
2456 fullCreateSimilarModalPendingPath = notePath;
2457 const bad = projectSlugFromProjectsPath(notePath) || '…';
2458 body.textContent =
2459 'Your path starts with projects/' +
2460 bad +
2461 '/ but an existing project slug is «' +
2462 suggestedSlug +
2463 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
2464 modal.classList.remove('hidden');
2465 const focusBtn = el('btn-modal-create-similar-use-existing');
2466 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
2467 }
2468
2469 function closeFullCreateSimilarModal() {
2470 const modal = el('modal-create-similar-project');
2471 if (modal) modal.classList.add('hidden');
2472 fullCreateSimilarModalSuggestedSlug = '';
2473 fullCreateSimilarModalPendingPath = '';
2474 }
2475
2476 /** True when any list filter used by loadNotes / Quick chips is set. */
2477 function listFacetFiltersActive() {
2478 if (filterProject.value) return true;
2479 if (filterTag.value) return true;
2480 if (filterFolder.value) return true;
2481 if (filterNetwork && filterNetwork.value) return true;
2482 if (filterWallet && filterWallet.value) return true;
2483 const fps = el('filter-payment-status');
2484 if (fps && fps.value) return true;
2485 if (filterSince && filterSince.value) return true;
2486 if (filterUntil && filterUntil.value) return true;
2487 if (filterContentScope && filterContentScope.value) return true;
2488 return false;
2489 }
2490
2491 function clearListFacetFilters() {
2492 filterProject.value = '';
2493 filterTag.value = '';
2494 filterFolder.value = '';
2495 if (filterNetwork) filterNetwork.value = '';
2496 if (filterWallet) filterWallet.value = '';
2497 const fps = el('filter-payment-status');
2498 if (fps) fps.value = '';
2499 if (filterSince) filterSince.value = '';
2500 if (filterUntil) filterUntil.value = '';
2501 if (filterContentScope) filterContentScope.value = '';
2502 }
2503
2504 function renderFilterChips(facets) {
2505 filterChipsEl.innerHTML = '';
2506 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2507
2508 const header = document.createElement('div');
2509 header.className = 'filter-chips-header';
2510
2511 const label = document.createElement('span');
2512 label.className = 'toolbar-label';
2513 label.textContent = 'Quick';
2514
2515 const toggle = document.createElement('button');
2516 toggle.type = 'button';
2517 toggle.className = 'filter-chips-toggle';
2518 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2519 toggle.setAttribute('aria-controls', 'filter-chips-panel');
2520 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2521 toggle.setAttribute(
2522 'aria-label',
2523 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2524 );
2525 toggle.innerHTML =
2526 '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
2527 toggle.onclick = () => {
2528 filterChipsExpanded = !filterChipsExpanded;
2529 try {
2530 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
2531 } catch (_) {}
2532 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2533 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2534 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2535 toggle.setAttribute(
2536 'aria-label',
2537 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2538 );
2539 };
2540
2541 header.appendChild(label);
2542 header.appendChild(toggle);
2543 filterChipsEl.appendChild(header);
2544
2545 const panel = document.createElement('div');
2546 panel.id = 'filter-chips-panel';
2547 panel.className = 'filter-chips-panel';
2548 panel.setAttribute('role', 'region');
2549 panel.setAttribute('aria-label', 'Quick filter chips');
2550 filterChipsEl.appendChild(panel);
2551
2552 const allBtn = document.createElement('button');
2553 allBtn.type = 'button';
2554 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
2555 allBtn.textContent = 'All';
2556 allBtn.title =
2557 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
2558 allBtn.onclick = () => {
2559 searchQuery.value = '';
2560 clearListFacetFilters();
2561 switchNotesView('list');
2562 loadNotes();
2563 renderFilterChips(null);
2564 };
2565 panel.appendChild(allBtn);
2566
2567 const apply = (f) => {
2568 if (!f) return;
2569 (f.projects || []).slice(0, 12).forEach((p) => {
2570 const b = document.createElement('button');
2571 b.type = 'button';
2572 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
2573 b.textContent = 'project:' + p;
2574 b.onclick = () => {
2575 searchQuery.value = '';
2576 filterProject.value = p;
2577 filterTag.value = '';
2578 filterFolder.value = '';
2579 switchNotesView('list');
2580 loadNotes();
2581 renderFilterChips(null);
2582 };
2583 panel.appendChild(b);
2584 });
2585 (f.tags || []).slice(0, 10).forEach((t) => {
2586 const b = document.createElement('button');
2587 b.type = 'button';
2588 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
2589 b.textContent = 'tag:' + t;
2590 b.onclick = () => {
2591 searchQuery.value = '';
2592 filterTag.value = t;
2593 filterProject.value = '';
2594 filterFolder.value = '';
2595 switchNotesView('list');
2596 loadNotes();
2597 renderFilterChips(null);
2598 };
2599 panel.appendChild(b);
2600 });
2601 (f.folders || []).slice(0, 12).forEach((folder) => {
2602 const b = document.createElement('button');
2603 b.type = 'button';
2604 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
2605 b.textContent = 'folder:' + folder;
2606 b.onclick = () => {
2607 searchQuery.value = '';
2608 filterFolder.value = folder;
2609 filterProject.value = '';
2610 filterTag.value = '';
2611 switchNotesView('list');
2612 loadNotes();
2613 renderFilterChips(null);
2614 };
2615 panel.appendChild(b);
2616 });
2617 // Phase 12 — network chips
2618 (f.networks || []).slice(0, 8).forEach((net) => {
2619 const b = document.createElement('button');
2620 b.type = 'button';
2621 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
2622 b.textContent = 'net:' + net;
2623 b.onclick = () => {
2624 searchQuery.value = '';
2625 if (filterNetwork) filterNetwork.value = net;
2626 switchNotesView('list');
2627 loadNotes();
2628 renderFilterChips(null);
2629 };
2630 panel.appendChild(b);
2631 });
2632 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
2633 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
2634 const payStatuses = ['pending', 'settled', 'failed'];
2635 payStatuses.forEach((ps) => {
2636 const b = document.createElement('button');
2637 b.type = 'button';
2638 b.className = 'chip-btn chip-blockchain';
2639 b.textContent = 'status:' + ps;
2640 b.onclick = () => {
2641 searchQuery.value = '';
2642 const fpsEl = el('filter-payment-status');
2643 if (fpsEl) fpsEl.value = ps;
2644 switchNotesView('list');
2645 loadNotes();
2646 renderFilterChips(null);
2647 };
2648 panel.appendChild(b);
2649 });
2650 }
2651 };
2652 if (facets) apply(facets);
2653 else fetchFacetsResolved().then(apply).catch(() => {});
2654 }
2655
2656 function getPresets() {
2657 try {
2658 const raw = localStorage.getItem(PRESETS_KEY);
2659 return raw ? JSON.parse(raw) : [];
2660 } catch (_) {
2661 return [];
2662 }
2663 }
2664
2665 function savePreset() {
2666 const name = (presetNameInput.value || '').trim();
2667 if (!name) return;
2668 const presets = getPresets().filter((p) => p.name !== name);
2669 presets.push({
2670 name,
2671 project: filterProject.value,
2672 tag: filterTag.value,
2673 folder: filterFolder.value,
2674 since: filterSince?.value || '',
2675 until: filterUntil?.value || '',
2676 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2677 });
2678 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
2679 presetNameInput.value = '';
2680 renderPresets();
2681 }
2682
2683 function renderPresets() {
2684 presetsListEl.innerHTML = '';
2685 getPresets().forEach((p) => {
2686 const b = document.createElement('button');
2687 b.type = 'button';
2688 b.className = 'preset-pill';
2689 b.textContent = p.name;
2690 b.title = [p.folder && 'folder:' + p.folder, p.project && 'project:' + p.project, p.tag && 'tag:' + p.tag, p.since && 'since:' + p.since, p.until && 'until:' + p.until, p.content_scope && 'content:' + p.content_scope].filter(Boolean).join(' ');
2691 b.onclick = () => {
2692 filterProject.value = p.project || '';
2693 filterTag.value = p.tag || '';
2694 filterFolder.value = p.folder || '';
2695 if (filterSince) filterSince.value = p.since || '';
2696 if (filterUntil) filterUntil.value = p.until || '';
2697 if (filterContentScope) filterContentScope.value = p.content_scope || '';
2698 switchNotesView('list');
2699 loadNotes();
2700 renderFilterChips(null);
2701 };
2702 presetsListEl.appendChild(b);
2703 });
2704 }
2705
2706 el('btn-save-preset').onclick = savePreset;
2707
2708 function renderNoteRow(n) {
2709 const title = n.title || n.path;
2710 const isLog = hubRowIsApprovalLog(n);
2711 const chips = [];
2712 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
2713 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
2714 const meta = [n.date].filter(Boolean).join(' · ');
2715 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
2716 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
2717 return (
2718 '<div class="' +
2719 rowClass +
2720 '" data-path="' +
2721 escapeHtml(n.path) +
2722 '"><span class="row-title">' +
2723 escapeHtml(title) +
2724 badge +
2725 '</span><div class="row-chips">' +
2726 chips.join('') +
2727 '</div>' +
2728 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
2729 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
2730 '</div>'
2731 );
2732 }
2733
2734 function bindNoteClicks(container) {
2735 container.querySelectorAll('.list-item').forEach((item) => {
2736 item.onclick = () => openNote(item.dataset.path);
2737 const delBtn = item.querySelector('.list-item-delete');
2738 if (delBtn) {
2739 delBtn.onclick = async (e) => {
2740 e.stopPropagation();
2741 const path = item.dataset.path;
2742 if (!path) return;
2743 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
2744 try {
2745 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
2746 if (typeof showToast === 'function') showToast('Deleted: ' + path);
2747 hubMarkSemanticIndexStale();
2748 if (currentOpenNote && currentOpenNote.path === path) {
2749 currentOpenNote = null;
2750 resetDetailSectionSourceState();
2751 hideDetailPanelChrome();
2752 }
2753 loadNotes();
2754 loadFacets();
2755 } catch (err) {
2756 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
2757 }
2758 };
2759 }
2760 });
2761 }
2762
2763 function hasActiveNoteListFilters() {
2764 if (filterProject && filterProject.value) return true;
2765 if (filterTag && filterTag.value) return true;
2766 if (filterFolder && filterFolder.value) return true;
2767 if (filterSince && filterSince.value) return true;
2768 if (filterUntil && filterUntil.value) return true;
2769 if (filterContentScope && filterContentScope.value) return true;
2770 if (filterNetwork && filterNetwork.value) return true;
2771 if (filterWallet && filterWallet.value) return true;
2772 const paymentStatusEl = el('filter-payment-status');
2773 if (paymentStatusEl && paymentStatusEl.value) return true;
2774 return false;
2775 }
2776
2777 function readOnboardingDismissedSync() {
2778 try {
2779 const raw = localStorage.getItem('knowtation_onboarding_v1');
2780 if (!raw) return false;
2781 const o = JSON.parse(raw);
2782 return Boolean(o && o.v === 1 && o.status === 'dismissed');
2783 } catch (_) {
2784 return false;
2785 }
2786 }
2787
2788 function isSearchResultsView() {
2789 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
2790 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
2791 }
2792
2793 function updateEmptyVaultStripVisibility() {
2794 const strip = el('hub-empty-vault-strip');
2795 if (!strip) return;
2796 const mainVisible = main && !main.classList.contains('hidden');
2797 const notesTab = document.querySelector('.tabs .tab.active')?.dataset?.tab === 'notes';
2798 const q = searchQuery && String(searchQuery.value).trim();
2799 const show =
2800 Boolean(mainVisible && token) &&
2801 readOnboardingDismissedSync() &&
2802 hubBrowseListEmptyUnfiltered &&
2803 notesTab &&
2804 !q &&
2805 !isSearchResultsView();
2806 strip.classList.toggle('hidden', !show);
2807 }
2808
2809 async function loadNotes() {
2810 const q = new URLSearchParams();
2811 q.set('limit', '100');
2812 if (filterFolder.value) q.set('folder', filterFolder.value);
2813 if (filterProject.value) q.set('project', filterProject.value);
2814 if (filterTag.value) q.set('tag', filterTag.value);
2815 if (filterSince && filterSince.value) q.set('since', filterSince.value);
2816 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
2817 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
2818 // Phase 12 — blockchain filters
2819 const networkVal = filterNetwork ? filterNetwork.value : '';
2820 const walletVal = filterWallet ? filterWallet.value : '';
2821 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
2822 if (networkVal) q.set('network', networkVal);
2823 if (walletVal) q.set('wallet_address', walletVal);
2824 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
2825 notesList.innerHTML = loadingHtml;
2826 notesTotal.textContent = '';
2827 try {
2828 const out = await api('/api/v1/notes?' + q.toString());
2829 let notes = (out.notes || []).map(normalizeHubListItem);
2830 notes = applyVaultListFilters(notes, {
2831 folder: filterFolder.value,
2832 project: filterProject.value,
2833 tag: filterTag.value,
2834 since: filterSince?.value || '',
2835 until: filterUntil?.value || '',
2836 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2837 network: networkVal,
2838 wallet_address: walletVal,
2839 payment_status: paymentStatusVal,
2840 });
2841 notes = applySortedNotesClient(notes);
2842 const totalCount = notes.length;
2843 notes = notes.slice(0, 100);
2844 if (notes.length === 0) {
2845 notesList.innerHTML =
2846 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
2847 const ea = el('empty-add');
2848 if (ea) ea.onclick = () => openCreateModal();
2849 notesTotal.textContent = 'Total: 0';
2850 } else {
2851 notesList.innerHTML = notes.map(renderNoteRow).join('');
2852 notesTotal.textContent = 'Total: ' + totalCount;
2853 bindNoteClicks(notesList);
2854 listSelectedIndex = 0;
2855 updateListSelection();
2856 }
2857 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
2858 updateEmptyVaultStripVisibility();
2859 } catch (e) {
2860 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
2861 notesTotal.textContent = '';
2862 hubBrowseListEmptyUnfiltered = false;
2863 updateEmptyVaultStripVisibility();
2864 }
2865 }
2866
2867 function switchHubMainTab(name) {
2868 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2869 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2870 const tab = document.querySelector('[data-tab="' + name + '"]');
2871 if (tab) tab.classList.add('active');
2872 syncHubListSortUI(name);
2873 setProposalFiltersBarVisible(
2874 name === 'activity' || name === 'suggested' || name === 'problem',
2875 );
2876 refreshNewProposalTabVisibility();
2877 const panel = el(
2878 'tab-' +
2879 (name === 'notes'
2880 ? 'notes'
2881 : name === 'activity'
2882 ? 'activity'
2883 : name === 'suggested'
2884 ? 'suggested'
2885 : 'problem'),
2886 );
2887 if (panel) panel.classList.remove('hidden');
2888 if (name === 'notes') {
2889 loadNotes();
2890 } else {
2891 if (name === 'activity') loadActivity();
2892 if (name === 'suggested' || name === 'problem') loadProposals();
2893 updateEmptyVaultStripVisibility();
2894 }
2895 }
2896
2897 function updateListSelection() {
2898 const container = notesList;
2899 const items = container.querySelectorAll('.list-item');
2900 if (items.length === 0) { listSelectedIndex = 0; return; }
2901 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
2902 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
2903 const sel = items[listSelectedIndex];
2904 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2905 }
2906
2907 btnApplyFilters.onclick = () => {
2908 switchNotesView('list');
2909 loadNotes();
2910 renderFilterChips(null);
2911 };
2912
2913 if (filterContentScope) {
2914 filterContentScope.addEventListener('change', () => {
2915 switchNotesView('list');
2916 loadNotes();
2917 renderFilterChips(null);
2918 });
2919 }
2920
2921 function formatSearchScopeSummary() {
2922 const parts = [];
2923 if (filterProject.value) parts.push('project: ' + filterProject.value);
2924 if (filterTag.value) parts.push('tag: ' + filterTag.value);
2925 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
2926 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
2927 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
2928 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
2929 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
2930 return parts.length ? parts.join(' · ') : '';
2931 }
2932
2933 function semanticMatchStrengthLabel(score) {
2934 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2935 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2936 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
2937 }
2938
2939 function keywordMatchStrengthLabel(score) {
2940 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2941 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2942 return 'Keyword match ~' + pct + '% (text overlap)';
2943 }
2944
2945 if (btnClearSearch) {
2946 btnClearSearch.onclick = () => {
2947 searchQuery.value = '';
2948 clearListFacetFilters();
2949 switchNotesView('list');
2950 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2951 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2952 const notesTab = document.querySelector('[data-tab="notes"]');
2953 if (notesTab) notesTab.classList.add('active');
2954 const tabNotes = el('tab-notes');
2955 if (tabNotes) tabNotes.classList.remove('hidden');
2956 loadNotes();
2957 renderFilterChips(null);
2958 };
2959 }
2960
2961 function showToast(message, isError = false) {
2962 const toast = document.createElement('div');
2963 toast.className = 'toast' + (isError ? ' toast-err' : '');
2964 toast.textContent = message;
2965 toast.setAttribute('role', 'status');
2966 document.body.appendChild(toast);
2967 requestAnimationFrame(() => toast.classList.add('toast-show'));
2968 setTimeout(() => {
2969 toast.classList.remove('toast-show');
2970 setTimeout(() => toast.remove(), 300);
2971 }, 3000);
2972 }
2973
2974 const proposalFilterApply = el('proposal-filter-apply');
2975 if (proposalFilterApply) {
2976 proposalFilterApply.onclick = () => {
2977 loadProposals();
2978 loadActivity();
2979 };
2980 }
2981 const proposalFilterClear = el('proposal-filter-clear');
2982 if (proposalFilterClear) {
2983 proposalFilterClear.onclick = () => {
2984 const lf = el('proposal-filter-label');
2985 const sf = el('proposal-filter-source');
2986 const pf = el('proposal-filter-path-prefix');
2987 const pe = el('proposal-filter-pending-eval');
2988 const rq = el('proposal-filter-review-queue');
2989 const rs = el('proposal-filter-review-severity');
2990 if (lf) lf.value = '';
2991 if (sf) sf.value = '';
2992 if (pf) pf.value = '';
2993 if (pe) pe.checked = false;
2994 if (rq) rq.value = '';
2995 if (rs) rs.value = '';
2996 loadProposals();
2997 loadActivity();
2998 };
2999 }
3000
3001 const hubListSortEl = hubListSortGetSelect();
3002 if (hubListSortEl) {
3003 hubListSortEl.addEventListener('change', () => {
3004 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3005 try {
3006 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3007 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3008 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3009 }
3010 } catch (_) {}
3011 if (tab === 'notes') loadNotes();
3012 else if (tab === 'activity') loadActivity();
3013 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3014 });
3015 }
3016
3017 if (btnReindex) {
3018 btnReindex.onclick = async () => {
3019 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3020 try {
3021 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3022 // (see api() helper). Bridge may return one of three shapes:
3023 // 200 {ok:true, ...} → sync completed
3024 // 202 {status:'background', ...} → routed to bridge-index-background fn
3025 // 409 {status:'already_running'} → another background job in flight
3026 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3027 if (out && out.status === 'background') {
3028 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3029 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3030 } else if (out && out.status === 'already_running') {
3031 showToast(out.message || 'A background re-index is already running for this vault.');
3032 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3033 } else {
3034 const n = out.notesProcessed ?? 0;
3035 const c = out.chunksIndexed ?? 0;
3036 const skipped = out.chunksSkippedCached ?? 0;
3037 const embedded = out.chunksEmbedded ?? c;
3038 const detail = skipped > 0
3039 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3040 : '';
3041 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3042 hubClearSemanticIndexStale();
3043 loadFacets();
3044 loadNotes();
3045 hubLoadIndexStatus().catch(() => {});
3046 }
3047 } catch (e) {
3048 showToast(e.message || 'Re-index failed', true);
3049 }
3050 });
3051 };
3052 }
3053
3054 /*
3055 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3056 * Reads from `GET /api/v1/index/status` which both sync and background paths
3057 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3058 * background job is in flight so the line flips from
3059 * "Re-indexing in background…" → "Last indexed: just now"
3060 * without the user needing to click anything.
3061 */
3062 let _hubIndexStatusPollTimer = null;
3063 function hubFormatRelativeTime(epochMs) {
3064 if (!Number.isFinite(epochMs)) return '';
3065 const ageMs = Date.now() - epochMs;
3066 if (ageMs < 0) return 'just now';
3067 const sec = Math.round(ageMs / 1000);
3068 if (sec < 45) return 'just now';
3069 const min = Math.round(sec / 60);
3070 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3071 const hr = Math.round(min / 60);
3072 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3073 const days = Math.round(hr / 24);
3074 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3075 }
3076 async function hubLoadIndexStatus(opts) {
3077 opts = opts || {};
3078 const el = document.getElementById('hub-index-status');
3079 if (!el) return;
3080 let status;
3081 try {
3082 status = await api('/api/v1/index/status', { method: 'GET' });
3083 } catch (_) {
3084 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3085 el.textContent = '';
3086 el.classList.remove('hub-index-status-running');
3087 return;
3088 }
3089 if (status && status.inProgress) {
3090 el.textContent = 'Re-indexing in background…';
3091 el.classList.add('hub-index-status-running');
3092 // Keep polling so the line auto-clears when the background job finishes.
3093 // 5-second cadence matches typical embedding batch completion granularity
3094 // and stays well under any sane rate limit.
3095 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3096 _hubIndexStatusPollTimer = setInterval(() => {
3097 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3098 }, 5000);
3099 }
3100 return;
3101 }
3102 // No in-flight job — stop polling if we were.
3103 if (_hubIndexStatusPollTimer != null) {
3104 clearInterval(_hubIndexStatusPollTimer);
3105 _hubIndexStatusPollTimer = null;
3106 }
3107 el.classList.remove('hub-index-status-running');
3108 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3109 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3110 el.textContent = 'Last indexed: ' + rel;
3111 el.title =
3112 'Last successful index: ' +
3113 (status.lastIndexed.lastIndexedAt || '') +
3114 ' · ' +
3115 (status.lastIndexed.chunksIndexed || 0) +
3116 ' chunks · mode: ' +
3117 (status.lastIndexed.mode || 'sync');
3118 } else {
3119 el.textContent = '';
3120 el.title = '';
3121 }
3122 }
3123 // Kick off an initial status load once the user is logged in (the API call
3124 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3125 // that already happens after token validation succeeds.
3126 hubLoadIndexStatus().catch(() => {});
3127
3128 const hubIndexStaleRun = el('hub-index-stale-run');
3129 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3130 if (hubIndexStaleRun && btnReindex) {
3131 hubIndexStaleRun.onclick = () => {
3132 btnReindex.click();
3133 };
3134 }
3135 if (hubIndexStaleDismiss) {
3136 hubIndexStaleDismiss.onclick = () => {
3137 hubClearSemanticIndexStale();
3138 };
3139 }
3140
3141 function proposalFilterQuerySuffix() {
3142 const params = [];
3143 const lab = el('proposal-filter-label');
3144 const src = el('proposal-filter-source');
3145 const pre = el('proposal-filter-path-prefix');
3146 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3147 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3148 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3149 const pe = el('proposal-filter-pending-eval');
3150 if (pe && pe.checked) params.push('evaluation_status=pending');
3151 const rq = el('proposal-filter-review-queue');
3152 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3153 const rs = el('proposal-filter-review-severity');
3154 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3155 return params.length ? '&' + params.join('&') : '';
3156 }
3157
3158 // Discard a proposal directly from the list without opening the detail panel.
3159 async function discardProposalInline(id, itemEl) {
3160 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3161 try {
3162 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3163 if (typeof showToast === 'function') showToast('Proposal discarded.');
3164 const panel = el('detail-panel');
3165 if (panel && !panel.classList.contains('hidden')) {
3166 hideDetailPanelChrome();
3167 }
3168 loadProposals();
3169 loadActivity();
3170 } catch (err) {
3171 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3172 }
3173 }
3174
3175 async function loadProposals() {
3176 const emptySuggested =
3177 '<div class="empty-state empty-state-suggested">' +
3178 '<p><strong>No proposals waiting for review.</strong> Agents and the CLI queue edits here; nothing applies to your live vault until you approve.</p>' +
3179 '<p>Use <strong>New proposal</strong> or open a note and choose <strong>Propose change</strong>, or have an agent or the CLI create one.</p>' +
3180 '<p class="empty-state-suggested-actions"><button type="button" class="btn-secondary" id="empty-suggested-how-to">How proposals work</button></p>' +
3181 '</div>';
3182 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3183 const fq = proposalFilterQuerySuffix();
3184 [
3185 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3186 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3187 ].forEach(({ kind, status, empty: emptyHtml }) => {
3188 const container = el('proposals-' + kind);
3189 if (!container) return;
3190 container.innerHTML = loadingHtml;
3191 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3192 .then((out) => {
3193 let list = out.proposals || [];
3194 list = applySortedProposalsClient(list);
3195 if (list.length === 0) {
3196 container.innerHTML = emptyHtml;
3197 if (kind === 'suggested') {
3198 const how = container.querySelector('#empty-suggested-how-to');
3199 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3200 }
3201 return;
3202 }
3203 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3204 container.innerHTML = list
3205 .map((p) => {
3206 const labelChips = (Array.isArray(p.labels) ? p.labels : [])
3207 .slice(0, 4)
3208 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
3209 .join('');
3210 const srcChip = p.source
3211 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3212 : '';
3213 const qChip = p.review_queue
3214 ? '<span class="proposal-chip">queue:' + escapeHtml(String(p.review_queue)) + '</span>'
3215 : '';
3216 const sevChip =
3217 p.review_severity === 'elevated'
3218 ? '<span class="proposal-chip">elevated</span>'
3219 : p.review_severity === 'standard'
3220 ? '<span class="proposal-chip">standard</span>'
3221 : '';
3222 const extraChips = [labelChips, srcChip, qChip, sevChip].filter(Boolean).join('');
3223 const discardBtn = canDiscard
3224 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3225 : '';
3226 return (
3227 '<div class="list-item" data-id="' +
3228 escapeHtml(p.proposal_id) +
3229 '"><span class="row-title">' +
3230 escapeHtml(p.path) +
3231 '</span><div class="status">' +
3232 escapeHtml(p.status) +
3233 (p.updated_at ? ' · ' + (calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) : '') +
3234 (p.evaluation_status ? ' · eval:' + escapeHtml(String(p.evaluation_status)) : '') +
3235 (extraChips ? ' · ' + extraChips : '') +
3236 '</div>' + discardBtn + '</div>'
3237 );
3238 })
3239 .join('');
3240 container.querySelectorAll('.list-item').forEach((item) => {
3241 item.onclick = () => openProposal(item.dataset.id);
3242 const db = item.querySelector('.list-item-delete');
3243 if (db) {
3244 db.onclick = (e) => {
3245 e.stopPropagation();
3246 discardProposalInline(item.dataset.id, item);
3247 };
3248 }
3249 });
3250 })
3251 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3252 });
3253 }
3254
3255 async function loadActivity() {
3256 const container = el('proposals-activity');
3257 if (!container) return;
3258 container.innerHTML = loadingHtml;
3259 try {
3260 const fq = proposalFilterQuerySuffix();
3261 const out = await api('/api/v1/proposals?limit=100' + fq);
3262 let list = out.proposals || [];
3263 list = applySortedProposalsClient(list);
3264 if (list.length === 0) {
3265 container.innerHTML =
3266 '<div class="empty-state empty-state-activity">' +
3267 '<p>No proposal activity yet.</p>' +
3268 '<p class="muted small">Pending reviews from agents or the CLI appear under the <strong>Suggested</strong> tab first; this tab is the timeline once things move.</p>' +
3269 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Suggested tab</button></p>' +
3270 '</div>';
3271 const go = container.querySelector('#empty-activity-goto-suggested');
3272 if (go) go.onclick = () => switchHubMainTab('suggested');
3273 return;
3274 }
3275 const canDiscard = hubUserCanWriteNotes();
3276 container.innerHTML = list
3277 .map((p) => {
3278 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3279 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3280 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3281 // approved records stay as-is unless the user opens them.
3282 const showDiscard = canDiscard && p.status !== 'approved';
3283 const discardBtn = showDiscard
3284 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3285 : '';
3286 return (
3287 '<div class="list-item activity-item ' +
3288 statusClass +
3289 '" data-id="' +
3290 escapeHtml(p.proposal_id) +
3291 '"><span class="row-title">' +
3292 escapeHtml(p.path) +
3293 '</span><div class="status">' +
3294 escapeHtml(p.status) +
3295 ' · ' +
3296 escapeHtml(date) +
3297 '</div>' + discardBtn + '</div>'
3298 );
3299 })
3300 .join('');
3301 container.querySelectorAll('.list-item').forEach((item) => {
3302 item.onclick = () => openProposal(item.dataset.id);
3303 const db = item.querySelector('.list-item-delete');
3304 if (db) {
3305 db.onclick = (e) => {
3306 e.stopPropagation();
3307 discardProposalInline(item.dataset.id, item);
3308 };
3309 }
3310 });
3311 } catch (e) {
3312 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3313 }
3314 }
3315
3316 async function runVaultSearch() {
3317 const query = searchQuery.value.trim();
3318 if (!query) return;
3319 hubBrowseListEmptyUnfiltered = false;
3320 updateEmptyVaultStripVisibility();
3321 const activeMainTab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3322 const useKeyword = searchMode && searchMode.value === 'keyword';
3323 if (activeMainTab && activeMainTab !== 'notes') {
3324 showToast(useKeyword ? 'Keyword results are shown under the Notes tab.' : 'Semantic results are shown under the Notes tab.');
3325 }
3326 switchNotesView('list');
3327 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3328 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3329 document.querySelector('[data-tab="notes"]').classList.add('active');
3330 el('tab-notes').classList.remove('hidden');
3331 notesList.innerHTML = loadingHtml;
3332 notesTotal.textContent = '';
3333 const scopeSummary = formatSearchScopeSummary();
3334 const scopeSuffix = scopeSummary
3335 ? ' · scope: ' + scopeSummary
3336 : ' · scope: entire vault (use dropdowns to narrow)';
3337 try {
3338 const body = { query, limit: 20 };
3339 if (useKeyword) body.mode = 'keyword';
3340 if (filterProject.value) body.project = filterProject.value;
3341 if (filterTag.value) body.tag = filterTag.value;
3342 if (filterFolder.value) body.folder = filterFolder.value;
3343 if (filterSince && filterSince.value) body.since = filterSince.value;
3344 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
3345 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
3346 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
3347 const results = out.results || [];
3348 if (results.length === 0) {
3349 notesList.innerHTML = useKeyword
3350 ? '<div class="empty-state">No notes contained this text under the current filters. Try different words, clear filters, or switch to <strong>Meaning</strong> for similarity search.</div>'
3351 : '<div class="empty-state">No notes matched this query under the current filters. Semantic search finds <em>similar meaning</em>, not exact words — try other phrases, clear filters, use <strong>Keyword</strong> for literal text, or use Quick chips + Apply filters for exact tags/projects.</div>';
3352 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
3353 return;
3354 }
3355 notesList.innerHTML = results
3356 .map((r) => {
3357 const chips = [];
3358 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
3359 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3360 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
3361 const pathStr = String(r.path || '').replace(/\\/g, '/');
3362 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
3363 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3364 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3365 return (
3366 '<div class="' +
3367 rowClass +
3368 '" data-path="' +
3369 escapeHtml(r.path) +
3370 '"><span class="row-title">' +
3371 escapeHtml(r.path) +
3372 badge +
3373 '</span><div class="row-chips">' +
3374 chips.join('') +
3375 '</div>' +
3376 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
3377 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
3378 '</div>'
3379 );
3380 })
3381 .join('');
3382 notesTotal.textContent =
3383 results.length +
3384 (useKeyword ? ' keyword' : ' semantic') +
3385 ' result' +
3386 (results.length === 1 ? '' : 's') +
3387 scopeSuffix;
3388 bindNoteClicks(notesList);
3389 listSelectedIndex = 0;
3390 updateListSelection();
3391 } catch (e) {
3392 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3393 notesTotal.textContent = '';
3394 }
3395 }
3396
3397 btnSearch.onclick = () => {
3398 void runVaultSearch();
3399 };
3400
3401 searchQuery.addEventListener('keydown', (e) => {
3402 if (e.key === 'Enter') {
3403 e.preventDefault();
3404 void runVaultSearch();
3405 }
3406 });
3407 searchQuery.addEventListener('input', () => {
3408 updateEmptyVaultStripVisibility();
3409 });
3410
3411 function switchNotesView(view) {
3412 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
3413 el('notes-view-list').classList.toggle('hidden', view !== 'list');
3414 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
3415 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
3416 if (view === 'calendar') renderCalendar();
3417 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
3418 }
3419
3420 document.querySelectorAll('.view-tab').forEach((t) => {
3421 t.onclick = () => switchNotesView(t.dataset.view);
3422 });
3423
3424 function ymd(d) {
3425 const y = d.getFullYear();
3426 const m = String(d.getMonth() + 1).padStart(2, '0');
3427 const day = String(d.getDate()).padStart(2, '0');
3428 return y + '-' + m + '-' + day;
3429 }
3430
3431 async function renderCalendar() {
3432 const grid = el('calendar-grid');
3433 const title = el('cal-title');
3434 const dayList = el('calendar-day-list');
3435 const dayNotes = el('calendar-day-notes');
3436 dayList.classList.add('hidden');
3437 grid.classList.remove('hidden');
3438 el('calendar-nav').classList.remove('hidden');
3439
3440 const y = calendarMonth.getFullYear();
3441 const m = calendarMonth.getMonth();
3442 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
3443
3444 grid.innerHTML = loadingHtml;
3445 const first = new Date(y, m, 1);
3446 const last = new Date(y, m + 1, 0);
3447 const since = ymd(first);
3448 const until = ymd(last);
3449
3450 let notesInMonth = [];
3451 try {
3452 const q = new URLSearchParams({ since, until, limit: '100' });
3453 const out = await api('/api/v1/notes?' + q.toString());
3454 notesInMonth = (out.notes || [])
3455 .map(normalizeHubListItem)
3456 .filter((n) => {
3457 const ds = noteSortOrCalendarDay(n);
3458 return ds >= since && ds <= until;
3459 });
3460 } catch (_) {
3461 notesInMonth = [];
3462 }
3463
3464 const byDay = {};
3465 notesInMonth.forEach((n) => {
3466 const ds = noteSortOrCalendarDay(n);
3467 if (ds >= since && ds <= until) {
3468 byDay[ds] = (byDay[ds] || 0) + 1;
3469 }
3470 });
3471
3472 const startPad = first.getDay();
3473 const daysInMonth = last.getDate();
3474 const cells = [];
3475 const prevLast = new Date(y, m, 0).getDate();
3476 for (let i = 0; i < startPad; i++) {
3477 const d = prevLast - startPad + i + 1;
3478 cells.push({ out: true, day: d, key: null });
3479 }
3480 for (let d = 1; d <= daysInMonth; d++) {
3481 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
3482 }
3483 let nextMonthDay = 1;
3484 while (cells.length % 7 !== 0 || cells.length < 42) {
3485 cells.push({ out: true, day: nextMonthDay++, key: null });
3486 }
3487
3488 const today = ymd(new Date());
3489 grid.innerHTML = cells
3490 .map((c) => {
3491 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
3492 const cnt = byDay[c.key] || 0;
3493 const isToday = c.key === today;
3494 return (
3495 '<div class="cal-cell' +
3496 (isToday ? ' today' : '') +
3497 '" data-day="' +
3498 escapeHtml(c.key) +
3499 '"><span class="cal-day-num">' +
3500 c.day +
3501 '</span>' +
3502 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
3503 '</div>'
3504 );
3505 })
3506 .join('');
3507
3508 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
3509 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
3510 });
3511 }
3512
3513 el('cal-prev').onclick = () => {
3514 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
3515 renderCalendar();
3516 };
3517 el('cal-next').onclick = () => {
3518 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
3519 renderCalendar();
3520 };
3521 el('cal-back').onclick = () => {
3522 el('calendar-day-list').classList.add('hidden');
3523 el('calendar-grid').classList.remove('hidden');
3524 el('calendar-nav').classList.remove('hidden');
3525 };
3526
3527 function showCalendarDay(dayKey, notesInMonth) {
3528 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
3529 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
3530 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
3531 bindNoteClicks(el('calendar-day-notes'));
3532 el('calendar-grid').classList.add('hidden');
3533 el('calendar-nav').classList.add('hidden');
3534 el('calendar-day-list').classList.remove('hidden');
3535 }
3536
3537 async function fetchNotesForDashboard() {
3538 const all = [];
3539 let offset = 0;
3540 const limit = 100;
3541 let total = Infinity;
3542 while (offset < 500 && all.length < total) {
3543 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
3544 total = out.total ?? 0;
3545 const batch = (out.notes || []).map(normalizeHubListItem);
3546 all.push(...batch);
3547 if (batch.length < limit) break;
3548 offset += limit;
3549 }
3550 return { notes: all, total };
3551 }
3552
3553 async function renderDashboard() {
3554 chartInstances.forEach((c) => c.destroy());
3555 chartInstances = [];
3556 const cards = el('dashboard-cards');
3557 const foot = el('dashboard-footnote');
3558 cards.innerHTML = loadingHtml;
3559 foot.textContent = '';
3560
3561 let notes, total;
3562 try {
3563 const r = await fetchNotesForDashboard();
3564 notes = r.notes;
3565 total = r.total;
3566 } catch (e) {
3567 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3568 return;
3569 }
3570
3571 const weekAgo = new Date();
3572 weekAgo.setDate(weekAgo.getDate() - 7);
3573 const weekStr = ymd(weekAgo);
3574 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
3575
3576 const byProject = {};
3577 const byTag = {};
3578 const byWeek = {};
3579 notes.forEach((n) => {
3580 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
3581 (n.tags || []).forEach((t) => {
3582 byTag[t] = (byTag[t] || 0) + 1;
3583 });
3584 const ds = noteSortOrCalendarDay(n);
3585 if (ds) {
3586 const w = ds.slice(0, 7);
3587 byWeek[w] = (byWeek[w] || 0) + 1;
3588 }
3589 });
3590
3591 const topProjects = Object.entries(byProject)
3592 .sort((a, b) => b[1] - a[1])
3593 .slice(0, 8);
3594 const topTags = Object.entries(byTag)
3595 .sort((a, b) => b[1] - a[1])
3596 .slice(0, 8);
3597 const weeks = Object.keys(byWeek).sort();
3598
3599 cards.innerHTML =
3600 '<div class="dash-card"><div class="dash-value">' +
3601 total +
3602 '</div><div class="dash-label">Notes (indexed)</div></div>' +
3603 '<div class="dash-card"><div class="dash-value">' +
3604 thisWeek +
3605 '</div><div class="dash-label">Last 7 days</div></div>' +
3606 '<div class="dash-card"><div class="dash-value">' +
3607 Object.keys(byProject).length +
3608 '</div><div class="dash-label">Projects</div></div>' +
3609 '<div class="dash-card"><div class="dash-value">' +
3610 Object.keys(byTag).length +
3611 '</div><div class="dash-label">Tags</div></div>';
3612
3613 if (notes.length < total) {
3614 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
3615 }
3616
3617 if (typeof Chart === 'undefined') {
3618 foot.textContent += ' Chart.js failed to load.';
3619 return;
3620 }
3621
3622 const commonOpts = {
3623 responsive: true,
3624 maintainAspectRatio: false,
3625 plugins: { legend: { labels: { color: '#a1a1a1' } } },
3626 scales: {
3627 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3628 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3629 },
3630 };
3631
3632 const ctxP = el('chart-projects').getContext('2d');
3633 chartInstances.push(
3634 new Chart(ctxP, {
3635 type: 'bar',
3636 data: {
3637 labels: topProjects.map((x) => x[0]),
3638 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
3639 },
3640 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
3641 })
3642 );
3643
3644 const ctxT = el('chart-tags').getContext('2d');
3645 chartInstances.push(
3646 new Chart(ctxT, {
3647 type: 'doughnut',
3648 data: {
3649 labels: topTags.map((x) => x[0]),
3650 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
3651 },
3652 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
3653 })
3654 );
3655
3656 const ctxL = el('chart-timeline').getContext('2d');
3657 chartInstances.push(
3658 new Chart(ctxL, {
3659 type: 'line',
3660 data: {
3661 labels: weeks,
3662 datasets: [{ label: 'Notes per month', data: weeks.map((w) => byWeek[w]), borderColor: '#89cff0', backgroundColor: 'rgba(137, 207, 240, 0.1)', fill: true, tension: 0.2 }],
3663 },
3664 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
3665 })
3666 );
3667 }
3668
3669 function resetDuplicateCreateState() {
3670 pendingDuplicateDeleteSource = null;
3671 const ban = el('duplicate-source-banner');
3672 if (ban) ban.classList.add('hidden');
3673 const chk = el('duplicate-delete-after-save');
3674 if (chk) chk.checked = false;
3675 const mt = el('modal-create-title');
3676 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
3677 const fs = el('btn-full-save');
3678 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
3679 }
3680
3681 function openCreateModal() {
3682 resetDuplicateCreateState();
3683 closeCreateProposalModal();
3684 closeFullCreateSimilarModal();
3685 hideDetailPanelChrome();
3686 el('modal-create').classList.remove('hidden');
3687 el('create-msg-quick').textContent = '';
3688 el('create-msg-quick').className = 'create-msg';
3689 el('create-msg-full').textContent = '';
3690 el('create-msg-full').className = 'create-msg';
3691 fullCreateSimilarOverrideOnce = false;
3692 if (token) {
3693 void (async () => {
3694 await refreshFullPathFolderSelect();
3695 if (!lastHubFacets) {
3696 try {
3697 lastHubFacets = await fetchFacetsResolved();
3698 } catch (_) {}
3699 }
3700 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3701 })();
3702 }
3703 }
3704
3705 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
3706 function suggestDuplicateVaultPath(srcPath) {
3707 const t = String(srcPath || '')
3708 .replace(/\\/g, '/')
3709 .trim();
3710 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
3711 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
3712 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
3713 }
3714
3715 function tagsInputFromFrontmatter(tagsVal) {
3716 if (tagsVal == null) return '';
3717 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
3718 return String(tagsVal).trim();
3719 }
3720
3721 /**
3722 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
3723 * Optional checkbox deletes the source path after a successful save (different path only).
3724 */
3725 async function openDuplicateNoteModal() {
3726 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
3727 if (!token) {
3728 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
3729 return;
3730 }
3731 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
3732 closeCreateProposalModal();
3733 closeFullCreateSimilarModal();
3734 el('modal-create').classList.remove('hidden');
3735 el('create-msg-quick').textContent = '';
3736 el('create-msg-quick').className = 'create-msg';
3737 el('create-msg-full').textContent = '';
3738 el('create-msg-full').className = 'create-msg';
3739 fullCreateSimilarOverrideOnce = false;
3740 const mt = el('modal-create-title');
3741 if (mt) mt.textContent = 'Duplicate note';
3742 const fs = el('btn-full-save');
3743 if (fs) fs.textContent = 'Save duplicate';
3744 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
3745 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
3746 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
3747 if (tabFull) tabFull.classList.add('active');
3748 if (tabQuick) tabQuick.classList.remove('active');
3749 el('create-quick').classList.add('hidden');
3750 el('create-full').classList.remove('hidden');
3751 if (token) {
3752 try {
3753 await refreshFullPathFolderSelect();
3754 if (!lastHubFacets) {
3755 try {
3756 lastHubFacets = await fetchFacetsResolved();
3757 } catch (_) {}
3758 }
3759 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3760 } catch (_) {}
3761 }
3762 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
3763 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
3764 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
3765 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
3766 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
3767 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
3768 if (el('full-entity')) {
3769 const ent = fm.entity;
3770 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
3771 }
3772 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
3773 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
3774 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
3775 if (el('full-path')) {
3776 el('full-path').value = sug;
3777 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
3778 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
3779 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
3780 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
3781 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
3782 }
3783 const dsp = el('duplicate-source-path');
3784 if (dsp) dsp.textContent = currentOpenNote.path;
3785 const ban = el('duplicate-source-banner');
3786 if (ban) ban.classList.remove('hidden');
3787 const chk = el('duplicate-delete-after-save');
3788 if (chk) chk.checked = false;
3789 }
3790
3791 function closeCreateModal() {
3792 closeFullCreateSimilarModal();
3793 resetDuplicateCreateState();
3794 el('modal-create').classList.add('hidden');
3795 }
3796 function closeCreateProposalModal() {
3797 const m = el('modal-create-proposal');
3798 if (m) m.classList.add('hidden');
3799 const pathInput = el('proposal-create-path');
3800 if (pathInput) pathInput.readOnly = false;
3801 }
3802 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
3803 function openCreateProposalModal(opts) {
3804 if (!token) {
3805 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
3806 return;
3807 }
3808 if (!hubUserCanWriteNotes()) {
3809 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
3810 return;
3811 }
3812 closeCreateModal();
3813 closeImportModal();
3814 hideDetailPanelChrome();
3815 const modal = el('modal-create-proposal');
3816 const pathInput = el('proposal-create-path');
3817 const hint = el('modal-create-proposal-hint');
3818 const bodyEl = el('proposal-create-body');
3819 const intentEl = el('proposal-create-intent');
3820 const msgEl = el('proposal-create-msg');
3821 if (!modal || !pathInput || !bodyEl || !intentEl) return;
3822 if (opts && opts.fromNote) {
3823 pathInput.readOnly = true;
3824 pathInput.value = opts.path || '';
3825 if (hint)
3826 hint.textContent =
3827 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
3828 } else {
3829 pathInput.readOnly = false;
3830 pathInput.value = (opts && opts.path) || '';
3831 if (hint)
3832 hint.textContent =
3833 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in the Suggested tab.';
3834 }
3835 bodyEl.value = (opts && opts.body) || '';
3836 intentEl.value = (opts && opts.intent) || '';
3837 if (msgEl) {
3838 msgEl.textContent = '';
3839 msgEl.className = 'create-msg';
3840 }
3841 modal.classList.remove('hidden');
3842 }
3843 btnNewNote.onclick = openCreateModal;
3844 el('modal-create-backdrop').onclick = closeCreateModal;
3845 el('modal-create-close').onclick = closeCreateModal;
3846
3847 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
3848 const modalCreateProposalClose = el('modal-create-proposal-close');
3849 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
3850 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
3851
3852 const btnNewProposal = el('btn-new-proposal');
3853 if (btnNewProposal) {
3854 btnNewProposal.onclick = () => openCreateProposalModal({});
3855 }
3856
3857 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
3858 if (btnProposalCreateSubmit) {
3859 btnProposalCreateSubmit.onclick = async () => {
3860 const pathInput = el('proposal-create-path');
3861 const bodyInput = el('proposal-create-body');
3862 const intentInput = el('proposal-create-intent');
3863 const msgEl = el('proposal-create-msg');
3864 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
3865 if (!rawPath) {
3866 if (msgEl) {
3867 msgEl.textContent = 'Path is required.';
3868 msgEl.className = 'create-msg err';
3869 }
3870 return;
3871 }
3872 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
3873 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
3874 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
3875 try {
3876 await api('/api/v1/proposals', {
3877 method: 'POST',
3878 body: JSON.stringify({
3879 path: rawPath,
3880 body,
3881 ...(intent ? { intent } : {}),
3882 source: 'hub_ui',
3883 }),
3884 });
3885 closeCreateProposalModal();
3886 if (typeof showToast === 'function') showToast('Proposal submitted');
3887 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3888 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3889 const suggestedTab = document.querySelector('[data-tab="suggested"]');
3890 const suggestedPanel = el('tab-suggested');
3891 if (suggestedTab) suggestedTab.classList.add('active');
3892 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
3893 syncHubListSortUI('suggested');
3894 setProposalFiltersBarVisible(true);
3895 refreshNewProposalTabVisibility();
3896 loadProposals();
3897 } catch (e) {
3898 if (msgEl) {
3899 msgEl.textContent = e.message || 'Proposal failed';
3900 msgEl.className = 'create-msg err';
3901 }
3902 }
3903 });
3904 };
3905 }
3906
3907 function syncImportSheetsBlock() {
3908 const sel = el('import-source-type');
3909 const block = el('import-sheets-block');
3910 if (block && sel) block.hidden = sel.value !== 'google-sheets';
3911 }
3912
3913 function openImportModal(preselectSourceType) {
3914 if (!token) {
3915 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
3916 return;
3917 }
3918 closeCreateModal();
3919 closeCreateProposalModal();
3920 hideDetailPanelChrome();
3921 el('modal-import').classList.remove('hidden');
3922 el('import-msg').textContent = '';
3923 if (importFileEl) importFileEl.value = '';
3924 if (importFileFolderEl) importFileFolderEl.value = '';
3925 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
3926 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
3927 setImportBatchAria('');
3928 clearImportDropPending();
3929 const urlIn = el('import-url');
3930 if (urlIn) urlIn.value = '';
3931 const sid = el('import-spreadsheet-id');
3932 const srange = el('import-sheets-range');
3933 if (sid) sid.value = '';
3934 if (srange) srange.value = '';
3935 const importSel = el('import-source-type');
3936 if (importSel && preselectSourceType) {
3937 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
3938 if (hasOption) importSel.value = preselectSourceType;
3939 }
3940 syncImportSheetsBlock();
3941 const outDirEl = el('import-output-dir');
3942 if (outDirEl) outDirEl.value = '';
3943 void (async () => {
3944 await refreshImportVaultFolderSelect();
3945 if (!lastHubFacets) {
3946 try {
3947 lastHubFacets = await fetchFacetsResolved();
3948 } catch (_) {}
3949 }
3950 hydrateImportCreateProjectSlugSelect(lastHubFacets);
3951 const out = el('import-output-dir');
3952 if (out) out.value = defaultImportOutputDir();
3953 syncImportFolderSelectToOutputDir();
3954 syncImportPickersFromOutputDir();
3955 updateImportPathLayoutVisibility();
3956 })();
3957 }
3958 function closeImportModal() {
3959 el('modal-import').classList.add('hidden');
3960 clearImportDropPending();
3961 }
3962 if (btnImport) btnImport.onclick = openImportModal;
3963 el('modal-import-backdrop').onclick = closeImportModal;
3964 el('modal-import-close').onclick = closeImportModal;
3965 const importSourceTypeEl = el('import-source-type');
3966 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
3967
3968 function closeProjectsHelpModal() {
3969 const m = el('modal-projects-help');
3970 if (m) m.classList.add('hidden');
3971 }
3972 function openProjectsHelpModal() {
3973 closeCreateModal();
3974 closeCreateProposalModal();
3975 hideDetailPanelChrome();
3976 const m = el('modal-projects-help');
3977 if (m) m.classList.remove('hidden');
3978 }
3979 const btnProjectsHelp = el('btn-projects-help');
3980 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
3981 const btnFullProjectHelp = el('btn-full-project-help');
3982 if (btnFullProjectHelp) {
3983 btnFullProjectHelp.onclick = () => {
3984 const m = el('modal-projects-help');
3985 if (m) m.classList.remove('hidden');
3986 };
3987 }
3988 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
3989 const modalProjectsHelpClose = el('modal-projects-help-close');
3990 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
3991 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
3992
3993 if (btnImportChooseFolder && importFileFolderEl) {
3994 btnImportChooseFolder.onclick = () => {
3995 importFileFolderEl.click();
3996 };
3997 }
3998 if (importFileFolderEl) {
3999 importFileFolderEl.addEventListener('change', () => {
4000 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4001 clearImportDropPending();
4002 if (importFileEl) importFileEl.value = '';
4003 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4004 }
4005 });
4006 }
4007 if (importFileEl) {
4008 importFileEl.addEventListener('change', () => {
4009 clearImportDropPending();
4010 if (importFileFolderEl) importFileFolderEl.value = '';
4011 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4012 });
4013 }
4014 if (importDropZoneEl) {
4015 let dragOverCount = 0;
4016 const setOver = (on) => {
4017 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4018 else importDropZoneEl.classList.remove('import-drop-zone--over');
4019 };
4020 importDropZoneEl.addEventListener('dragenter', (e) => {
4021 e.preventDefault();
4022 dragOverCount += 1;
4023 setOver(true);
4024 });
4025 importDropZoneEl.addEventListener('dragleave', (e) => {
4026 e.preventDefault();
4027 dragOverCount = Math.max(0, dragOverCount - 1);
4028 if (dragOverCount === 0) setOver(false);
4029 });
4030 importDropZoneEl.addEventListener('dragover', (e) => {
4031 e.preventDefault();
4032 e.stopPropagation();
4033 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4034 });
4035 importDropZoneEl.addEventListener('drop', (e) => {
4036 e.preventDefault();
4037 e.stopPropagation();
4038 dragOverCount = 0;
4039 setOver(false);
4040 const msgEl = el('import-msg');
4041 const p = (async () => {
4042 if (!e.dataTransfer) {
4043 if (msgEl) {
4044 msgEl.textContent = 'Drop did not include any files.';
4045 msgEl.className = 'create-msg err';
4046 }
4047 return;
4048 }
4049 let files;
4050 try {
4051 files = await collectFilesFromDataTransfer(e.dataTransfer);
4052 } catch (dropErr) {
4053 if (msgEl) {
4054 msgEl.textContent =
4055 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4056 msgEl.className = 'create-msg err';
4057 }
4058 return;
4059 }
4060 if (!files || files.length === 0) {
4061 if (msgEl) {
4062 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4063 msgEl.className = 'create-msg err';
4064 }
4065 return;
4066 }
4067 importPendingDropFiles = files;
4068 if (importFileEl) importFileEl.value = '';
4069 if (importFileFolderEl) importFileFolderEl.value = '';
4070 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4071 updateImportDropStatusUi();
4072 if (msgEl) {
4073 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4074 msgEl.className = 'create-msg';
4075 }
4076 })();
4077 p.catch((err) => {
4078 if (el('import-msg')) {
4079 const msg = el('import-msg');
4080 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4081 msg.className = 'create-msg err';
4082 }
4083 });
4084 });
4085 }
4086 if (importBatchCancelBtn) {
4087 importBatchCancelBtn.onclick = () => {
4088 if (importBatchAbort) importBatchAbort.abort();
4089 };
4090 }
4091
4092 /**
4093 * @param {string} postPath
4094 * @param {FormData} formData
4095 * @param {Record<string, string>} importHeaders
4096 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4097 */
4098 async function hubPostImportOnce(postPath, formData, importHeaders) {
4099 let res;
4100 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4101 try {
4102 res = await fetch(postPath, {
4103 method: 'POST',
4104 cache: 'no-store',
4105 headers: importHeaders,
4106 body: formData,
4107 });
4108 break;
4109 } catch (importErr) {
4110 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4111 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4112 await new Promise((r) => setTimeout(r, 3000));
4113 continue;
4114 }
4115 return { ok: false, errText: em, status: 0 };
4116 }
4117 }
4118 const text = await res.text();
4119 let data = {};
4120 try {
4121 data = text ? JSON.parse(text) : {};
4122 } catch (_) {
4123 data = {};
4124 }
4125 if (!res.ok) {
4126 let apiErr = '';
4127 if (data && typeof data === 'object') {
4128 const parts = [data.error, data.message, data.detail].filter(
4129 (x) => x != null && String(x).trim().length > 0,
4130 );
4131 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4132 }
4133 if (!apiErr && text) {
4134 const t = text.trim();
4135 if (t.startsWith('<')) {
4136 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4137 } else {
4138 apiErr = t.slice(0, 280);
4139 }
4140 }
4141 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4142 }
4143 return { ok: true, data };
4144 }
4145
4146 el('btn-import-submit').onclick = async () => {
4147 const importSubmitBtn = el('btn-import-submit');
4148 const sourceType = el('import-source-type').value;
4149 const fileInput = el('import-file');
4150 const urlInput = el('import-url');
4151 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4152 const msgEl = el('import-msg');
4153 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4154 const kz = globalThis.knowtationHubImportZip;
4155
4156 if (!token) {
4157 msgEl.textContent = 'Sign in to import.';
4158 msgEl.className = 'create-msg err';
4159 return;
4160 }
4161 const useUrlImport = urlTrim.length > 0;
4162 if (sourceType === 'url' && !useUrlImport) {
4163 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4164 msgEl.className = 'create-msg err';
4165 return;
4166 }
4167 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4168 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4169 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4170 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4171 const fileArr = usedDrop
4172 ? importPendingDropFiles
4173 : usedFolder
4174 ? Array.from(importFileFolderEl.files)
4175 : fileInput && fileInput.files
4176 ? Array.from(fileInput.files)
4177 : [];
4178 if (sourceType === 'google-sheets' && !useUrlImport) {
4179 if (!sheetId) {
4180 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4181 msgEl.className = 'create-msg err';
4182 return;
4183 }
4184 if (fileArr.length > 0) {
4185 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4186 msgEl.className = 'create-msg err';
4187 return;
4188 }
4189 }
4190 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4191 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4192 msgEl.className = 'create-msg err';
4193 return;
4194 }
4195 if (sourceType === 'notion' && fileArr.length > 1) {
4196 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4197 msgEl.className = 'create-msg err';
4198 return;
4199 }
4200
4201 const dest = getImportProjectAndOutputDir();
4202 if (dest.err) {
4203 msgEl.textContent = dest.err;
4204 msgEl.className = 'create-msg err';
4205 return;
4206 }
4207 const project = dest.project || '';
4208 const outputDir = dest.outputDir;
4209 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4210 const urlModeEl = el('import-url-mode');
4211 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4212 const importPostPath = apiBase + '/api/v1/import';
4213 const urlPostPath = apiBase + '/api/v1/import-url';
4214 const mode =
4215 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4216 ? kz.getHubImportFileMode(sourceType, fileArr)
4217 : 'direct';
4218
4219 if (!useUrlImport && !kz && fileArr.length > 1) {
4220 msgEl.textContent =
4221 'Import helpers (JSZip) did not load. Hard-refresh the page, or import one file at a time, or pre-zip a folder and upload a single .zip.';
4222 msgEl.className = 'create-msg err';
4223 return;
4224 }
4225
4226 if (!useUrlImport && mode === 'client_zip' && !kz) {
4227 msgEl.textContent =
4228 'In-browser ZIP helper did not load (JSZip). Hard-refresh the page and try again, or pre-zip the folder and upload a single .zip.';
4229 msgEl.className = 'create-msg err';
4230 return;
4231 }
4232 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4233 msgEl.textContent =
4234 'Too many files for one batch (max ' +
4235 HUB_IMPORT_MAX_SEQUENTIAL +
4236 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4237 msgEl.className = 'create-msg err';
4238 return;
4239 }
4240
4241 if (useUrlImport) {
4242 const jsonBody = { url: urlTrim, mode: urlMode };
4243 if (project) jsonBody.project = project;
4244 if (outputDir) jsonBody.output_dir = outputDir;
4245 if (tags) jsonBody.tags = tags;
4246 msgEl.textContent = 'Importing…';
4247 msgEl.className = 'create-msg';
4248 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4249 try {
4250 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4251 const importVaultId = getCurrentVaultId();
4252 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4253 let res;
4254 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4255 try {
4256 res = await fetch(urlPostPath, {
4257 method: 'POST',
4258 cache: 'no-store',
4259 headers: importHeaders,
4260 body: JSON.stringify(jsonBody),
4261 });
4262 break;
4263 } catch (importErr) {
4264 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4265 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4266 await new Promise((r) => setTimeout(r, 3000));
4267 continue;
4268 }
4269 throw importErr;
4270 }
4271 }
4272 const text = await res.text();
4273 let data = {};
4274 try {
4275 data = text ? JSON.parse(text) : {};
4276 } catch (_) {
4277 data = {};
4278 }
4279 if (!res.ok) {
4280 let apiErr = '';
4281 if (data && typeof data === 'object') {
4282 const parts = [data.error, data.message, data.detail].filter(
4283 (x) => x != null && String(x).trim().length > 0,
4284 );
4285 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4286 }
4287 if (!apiErr && text) {
4288 const t = text.trim();
4289 if (t.startsWith('<')) {
4290 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4291 } else {
4292 apiErr = t.slice(0, 280);
4293 }
4294 }
4295 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4296 msgEl.className = 'create-msg err';
4297 return;
4298 }
4299 const count = data.count ?? data.imported?.length ?? 0;
4300 if (count === 0) {
4301 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
4302 msgEl.className = 'create-msg warn';
4303 } else {
4304 msgEl.textContent = 'Imported ' + count + ' note(s).';
4305 msgEl.className = 'create-msg ok';
4306 }
4307 if (count > 0) hubMarkSemanticIndexStale();
4308 if (typeof loadNotes === 'function') loadNotes();
4309 if (typeof loadFacets === 'function') loadFacets();
4310 if (typeof showToast === 'function') showToast('Import complete');
4311 setTimeout(() => closeImportModal(), 1500);
4312 } catch (e) {
4313 const raw = e && e.message ? String(e.message) : 'Import failed';
4314 const isNetwork =
4315 raw === 'Failed to fetch' ||
4316 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4317 msgEl.textContent = isNetwork
4318 ? raw +
4319 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
4320 : raw;
4321 msgEl.className = 'create-msg err';
4322 }
4323 });
4324 return;
4325 }
4326
4327 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
4328 const importVaultId = getCurrentVaultId();
4329 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
4330
4331 if (mode === 'sequential') {
4332 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
4333 importBatchAbort = new AbortController();
4334 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
4335 msgEl.className = 'create-msg';
4336 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
4337 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4338 const failures = [];
4339 let totalImported = 0;
4340 let okN = 0;
4341 for (let i = 0; i < fileArr.length; i++) {
4342 if (importBatchAbort && importBatchAbort.signal.aborted) {
4343 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
4344 break;
4345 }
4346 const f = fileArr[i];
4347 try {
4348 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
4349 } catch (limErr) {
4350 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
4351 continue;
4352 }
4353 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
4354 const fd = new FormData();
4355 fd.append('source_type', sourceType);
4356 fd.append('file', f);
4357 if (project) fd.append('project', project);
4358 if (outputDir) fd.append('output_dir', outputDir);
4359 if (tags) fd.append('tags', tags);
4360 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4361 if (r.ok && r.data) {
4362 const c = r.data.count ?? r.data.imported?.length ?? 0;
4363 totalImported += typeof c === 'number' ? c : 0;
4364 okN++;
4365 } else {
4366 failures.push({ name: f.name, err: r.errText || 'error' });
4367 }
4368 }
4369 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4370 importBatchAbort = null;
4371 const fl = failures.length
4372 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
4373 : '.';
4374 msgEl.textContent =
4375 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
4376 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
4377 setImportBatchAria(msgEl.textContent);
4378 if (totalImported > 0) hubMarkSemanticIndexStale();
4379 if (typeof loadNotes === 'function') loadNotes();
4380 if (typeof loadFacets === 'function') loadFacets();
4381 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
4382 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
4383 });
4384 return;
4385 }
4386
4387 msgEl.textContent = 'Importing…';
4388 msgEl.className = 'create-msg';
4389 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4390 try {
4391 if (sourceType === 'google-sheets') {
4392 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
4393 ? el('import-spreadsheet-id').value.trim()
4394 : '';
4395 if (!sid) {
4396 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
4397 msgEl.className = 'create-msg err';
4398 return;
4399 }
4400 const rEl = el('import-sheets-range');
4401 const range = rEl && rEl.value ? rEl.value.trim() : '';
4402 const fd = new FormData();
4403 fd.append('source_type', 'google-sheets');
4404 fd.append('spreadsheet_id', sid);
4405 if (range) fd.append('sheets_range', range);
4406 if (project) fd.append('project', project);
4407 if (outputDir) fd.append('output_dir', outputDir);
4408 if (tags) fd.append('tags', tags);
4409 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4410 if (!r.ok) {
4411 msgEl.textContent = r.errText || 'Import failed';
4412 msgEl.className = 'create-msg err';
4413 return;
4414 }
4415 const data = r.data || {};
4416 const count = data.count ?? data.imported?.length ?? 0;
4417 if (count === 0) {
4418 msgEl.textContent =
4419 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
4420 msgEl.className = 'create-msg warn';
4421 } else {
4422 msgEl.textContent = 'Imported ' + count + ' note(s).';
4423 msgEl.className = 'create-msg ok';
4424 }
4425 if (count > 0) hubMarkSemanticIndexStale();
4426 if (typeof loadNotes === 'function') loadNotes();
4427 if (typeof loadFacets === 'function') loadFacets();
4428 if (typeof showToast === 'function') showToast('Import complete');
4429 setTimeout(() => closeImportModal(), 1500);
4430 return;
4431 }
4432 const dupWarn = [];
4433 const warnFn = (s) => {
4434 dupWarn.push(s);
4435 };
4436 /** @type {FormData} */
4437 let formData;
4438 if (mode === 'client_zip' && kz) {
4439 const blob = await kz.buildImportZipBlob(fileArr, {
4440 signal: null,
4441 warn: warnFn,
4442 });
4443 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
4444 formData = new FormData();
4445 formData.append('source_type', sourceType);
4446 formData.append('file', fileOut);
4447 if (project) formData.append('project', project);
4448 if (outputDir) formData.append('output_dir', outputDir);
4449 if (tags) formData.append('tags', tags);
4450 if (dupWarn.length) {
4451 msgEl.className = 'create-msg';
4452 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
4453 }
4454 } else {
4455 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
4456 try {
4457 kz.assertSingleFileWithinLimit(fileArr[0]);
4458 } catch (e1) {
4459 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
4460 msgEl.className = 'create-msg err';
4461 return;
4462 }
4463 }
4464 formData = new FormData();
4465 formData.append('source_type', sourceType);
4466 formData.append('file', fileArr[0]);
4467 if (project) formData.append('project', project);
4468 if (outputDir) formData.append('output_dir', outputDir);
4469 if (tags) formData.append('tags', tags);
4470 }
4471 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
4472 if (!r.ok) {
4473 msgEl.textContent = r.errText || 'Import failed';
4474 msgEl.className = 'create-msg err';
4475 return;
4476 }
4477 const data = r.data || {};
4478 const count = data.count ?? data.imported?.length ?? 0;
4479 let extra = '';
4480 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
4481 if (count === 0) {
4482 const zeroMsg =
4483 sourceType === 'markdown'
4484 ? 'Imported 0 notes. This ZIP or folder had no Markdown files we could use—only .md / .markdown (any case). Other formats are skipped unless you pick the matching source type (e.g. PDF or DOCX).'
4485 : sourceType === 'pdf'
4486 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
4487 : sourceType === 'docx'
4488 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
4489 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
4490 msgEl.textContent = zeroMsg + extra;
4491 msgEl.className = 'create-msg warn';
4492 } else {
4493 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
4494 msgEl.className = 'create-msg ok';
4495 }
4496 if (count > 0) hubMarkSemanticIndexStale();
4497 if (typeof loadNotes === 'function') loadNotes();
4498 if (typeof loadFacets === 'function') loadFacets();
4499 if (typeof showToast === 'function') showToast('Import complete');
4500 setTimeout(() => closeImportModal(), 1500);
4501 } catch (e) {
4502 const raw = e && e.message ? String(e.message) : 'Import failed';
4503 if (e && e.name === 'AbortError') {
4504 msgEl.textContent = 'Cancelled.';
4505 } else {
4506 const isNetwork =
4507 raw === 'Failed to fetch' ||
4508 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4509 msgEl.textContent = isNetwork
4510 ? raw +
4511 ' — Often: CORS, upload too large for the gateway, or timeout. Video/audio need self-hosted Hub plus OPENAI_API_KEY. On hosted, check Network for POST /api/v1/import.'
4512 : raw;
4513 }
4514 msgEl.className = 'create-msg err';
4515 }
4516 });
4517 };
4518
4519 function openHowToUse(tabId, scrollToId) {
4520 const id = tabId || 'setup';
4521 el('modal-how-to-use').classList.remove('hidden');
4522 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
4523 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
4524 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
4525 if (scrollToId) {
4526 requestAnimationFrame(() => {
4527 const target = document.getElementById(scrollToId);
4528 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4529 });
4530 }
4531 }
4532 function closeHowToUse() {
4533 el('modal-how-to-use').classList.add('hidden');
4534 }
4535 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
4536 const btnLoginHowToUse = el('btn-login-how-to-use');
4537 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
4538 const btnSettingsHelp = el('btn-settings-help');
4539 if (btnSettingsHelp) {
4540 btnSettingsHelp.onclick = () => {
4541 closeSettings();
4542 openHowToUse('knowledge-agents');
4543 };
4544 }
4545 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
4546 el('modal-how-to-use-close').onclick = closeHowToUse;
4547
4548 document.querySelectorAll('.how-to-tab').forEach((tab) => {
4549 tab.addEventListener('click', () => {
4550 const id = tab.dataset.howToTab;
4551 document.querySelectorAll('.how-to-tab').forEach((t) => {
4552 t.classList.toggle('active', t.dataset.howToTab === id);
4553 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
4554 });
4555 document.querySelectorAll('.how-to-panel').forEach((p) => {
4556 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
4557 });
4558 });
4559 });
4560
4561 const modalHowTo = el('modal-how-to-use');
4562 if (modalHowTo) {
4563 modalHowTo.addEventListener('click', (e) => {
4564 const t = e.target;
4565 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
4566 e.preventDefault();
4567 openHowToUse('consolidation');
4568 }
4569 });
4570 }
4571
4572 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
4573 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
4574 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
4575 btnHowToOpenOnboarding.addEventListener('click', () => {
4576 closeHowToUse();
4577 void openOnboardingWizard({ restart: false });
4578 });
4579 }
4580 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
4581 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
4582 btnEmptyStripWizard.dataset.knowtationBound = '1';
4583 btnEmptyStripWizard.addEventListener('click', () => {
4584 void openOnboardingWizard({ restart: true });
4585 });
4586 }
4587 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
4588 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
4589 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
4590 btnEmptyStripGettingStarted.addEventListener('click', () => {
4591 openHowToUse('getting-started');
4592 });
4593 }
4594
4595 function openTokenSavingsHowToFromSettings() {
4596 closeSettings();
4597 openHowToUse('token-savings');
4598 }
4599 const btnConsolToken = el('btn-consol-how-token-savings');
4600 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4601 const btnIntegToken = el('btn-integrations-how-token-savings');
4602 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4603 const btnAgentsToken = el('btn-agents-how-token-savings');
4604 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4605
4606 function openSettings() {
4607 refreshApiBaseFootgunBanner();
4608 closeCreateModal();
4609 el('modal-settings').classList.remove('hidden');
4610 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
4611 document.querySelectorAll('.settings-panel').forEach((p) => {
4612 p.classList.toggle('active', p.id === 'settings-panel-backup');
4613 });
4614 syncAccentUI();
4615 syncThemeUI();
4616 syncColorPaletteUI();
4617 refreshIntegApiStatus();
4618 el('settings-sync-msg').textContent = '';
4619 el('settings-sync-msg').className = 'settings-msg';
4620 el('settings-save-msg').textContent = '';
4621 el('settings-save-msg').className = 'settings-msg';
4622 const policyMsg = el('settings-proposal-policy-msg');
4623 if (policyMsg) {
4624 policyMsg.textContent = '';
4625 policyMsg.className = 'settings-msg';
4626 }
4627 el('settings-mode-display').textContent = 'Loading…';
4628 el('settings-vault-display').textContent = 'Loading…';
4629 el('settings-git-status').textContent = 'Loading…';
4630 const ghStatus = el('settings-github-status');
4631 if (ghStatus) ghStatus.textContent = 'Loading…';
4632 fetchSettingsForBackupModal()
4633 .then((s) => {
4634 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
4635 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
4636 throw new Error(
4637 'Settings API returned an empty or invalid JSON body. In DevTools → Network, click the "settings" request → Response. You should see an object with role, user_id, vault_path_display. If the body is empty, fix the gateway/proxy or API route.',
4638 );
4639 }
4640 applySettingsPayloadToHubChrome(s);
4641 const roleEl = el('settings-role-display');
4642 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
4643 const userIdEl = el('settings-user-id');
4644 if (userIdEl) userIdEl.textContent = s.user_id || '—';
4645 const vaultDisplay = s.vault_path_display || '—';
4646 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
4647 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
4648 el('settings-vault-display').textContent = vaultDisplay;
4649 const configureSection = el('settings-configure-backup-section');
4650 const configureHr = el('settings-hr-configure');
4651 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
4652 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
4653 const vg = s.vault_git || {};
4654 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
4655 const step1 = document.getElementById('setup-step-1');
4656 const step4 = document.getElementById('setup-step-4');
4657 const step1Label = el('setup-step-1-label');
4658 const step1Hint = el('setup-step-1-hint');
4659 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
4660 if (step1Hint) {
4661 step1Hint.textContent = isHosted
4662 ? 'Your notes live in your hosted vault'
4663 : 'Set below under Configure backup';
4664 }
4665 if (step1) {
4666 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
4667 step1.classList.toggle('setup-step-done', done);
4668 const icon = step1.querySelector('.setup-step-icon');
4669 if (icon) icon.textContent = done ? '✓' : '';
4670 }
4671 if (step4) {
4672 const done = !!(vg.enabled && vg.has_remote);
4673 step4.classList.toggle('setup-step-done', done);
4674 const icon = step4.querySelector('.setup-step-icon');
4675 if (icon) icon.textContent = done ? '✓' : '';
4676 }
4677 let gitText = 'Not configured';
4678 if (vg.enabled && vg.has_remote) {
4679 gitText = 'Configured';
4680 if (vg.auto_commit) gitText += ' (auto-commit on)';
4681 if (vg.auto_push) gitText += ', auto-push on';
4682 } else if (vg.enabled) gitText = 'Enabled but no remote set';
4683 el('settings-git-status').textContent = gitText;
4684 const evalReqEl = el('settings-proposal-eval-required');
4685 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
4686 const hintsEl = el('settings-proposal-hints-enabled');
4687 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
4688 const enrichStatusEl = el('settings-proposal-enrich-enabled');
4689 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
4690 const evApEl = el('settings-evaluator-may-approve');
4691 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
4692 const syncBtn = el('btn-settings-sync');
4693 const isAdmin = s.role === 'admin';
4694 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
4695 const saveSetupBtn = el('btn-settings-save');
4696 if (saveSetupBtn) {
4697 saveSetupBtn.disabled = false;
4698 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
4699 }
4700 const teamTab = el('settings-tab-team');
4701 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
4702 const vaultsTab = el('settings-tab-vaults');
4703 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
4704 const policyAdmin = el('settings-proposal-policy-admin');
4705 const storedPolicy = s.proposal_policy_stored || {};
4706 const policyLocks = s.proposal_policy_env_locked || {};
4707 if (policyAdmin) {
4708 policyAdmin.classList.toggle('hidden', !isAdmin);
4709 const cEval = el('settings-policy-eval');
4710 const cHints = el('settings-policy-hints');
4711 const cEnrich = el('settings-policy-enrich');
4712 if (cEval && cHints && cEnrich) {
4713 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
4714 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
4715 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
4716 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
4717 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
4718 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
4719 const lockHint =
4720 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4721 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
4722 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
4723 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
4724 }
4725 }
4726 const connectBtn = el('btn-connect-github');
4727 const ghStatus = el('settings-github-status');
4728 const hostedGhHint = el('settings-hosted-connect-github-hint');
4729 if (s.github_connect_available) {
4730 if (connectBtn) {
4731 connectBtn.classList.remove('hidden');
4732 connectBtn.onclick = () => {
4733 const base = apiBase.replace(/\/$/, '');
4734 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
4735 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
4736 };
4737 }
4738 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
4739 } else {
4740 if (connectBtn) {
4741 connectBtn.classList.add('hidden');
4742 connectBtn.onclick = null;
4743 }
4744 if (ghStatus) ghStatus.textContent = '—';
4745 }
4746 if (hostedGhHint) {
4747 const vd = s.vault_path_display || '';
4748 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
4749 }
4750 const hostedRepoSection = el('settings-hosted-backup-repo-section');
4751 const hostedRepoInput = el('settings-hosted-repo');
4752 if (hostedRepoSection) {
4753 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
4754 }
4755 if (hostedRepoInput && isHosted && s.github_connect_available) {
4756 if (!hostedRepoInput.value.trim()) {
4757 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
4758 }
4759 if (!hostedRepoInput.dataset.knowtationBound) {
4760 hostedRepoInput.dataset.knowtationBound = '1';
4761 hostedRepoInput.addEventListener('input', () => {
4762 const syncBtn = el('btn-settings-sync');
4763 if (!syncBtn || !lastBackupSettingsPayload) return;
4764 const vd = lastBackupSettingsPayload.vault_path_display || '';
4765 const ih = (vd + '').toLowerCase() === 'canister';
4766 if (ih && lastBackupSettingsPayload.github_connect_available) {
4767 const vg = lastBackupSettingsPayload.vault_git || {};
4768 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
4769 }
4770 });
4771 }
4772 }
4773 const ed = s.embedding_display || {};
4774 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
4775 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
4776 const ollamaRow = el('agents-ollama-row');
4777 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
4778 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
4779 const apiRow = el('settings-api-base-row');
4780 const apiDisp = el('settings-api-base-display');
4781 if (apiRow && apiDisp) {
4782 if (isLocalHubHostname()) {
4783 apiRow.classList.remove('hidden');
4784 apiDisp.textContent = apiBase;
4785 } else {
4786 apiRow.classList.add('hidden');
4787 }
4788 }
4789 refreshApiBaseFootgunBanner();
4790 void refreshBulkDeletePresetDropdowns();
4791 })
4792 .catch((e) => {
4793 const syncMsg = el('settings-sync-msg');
4794 if (syncMsg) {
4795 const m = e && e.message ? String(e.message) : 'Could not load settings.';
4796 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
4797 syncMsg.className = 'settings-msg err';
4798 }
4799 if (typeof console !== 'undefined' && console.error) {
4800 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
4801 }
4802 const hostedGhHint = el('settings-hosted-connect-github-hint');
4803 if (hostedGhHint) hostedGhHint.classList.add('hidden');
4804 const roleEl = el('settings-role-display');
4805 if (roleEl) roleEl.textContent = '—';
4806 const userIdEl = el('settings-user-id');
4807 if (userIdEl) userIdEl.textContent = '—';
4808 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
4809 el('settings-vault-display').textContent = '—';
4810 el('settings-git-status').textContent = 'Could not load';
4811 const evalReqErr = el('settings-proposal-eval-required');
4812 if (evalReqErr) evalReqErr.textContent = '—';
4813 const hintsErr = el('settings-proposal-hints-enabled');
4814 if (hintsErr) hintsErr.textContent = '—';
4815 const enrichErr = el('settings-proposal-enrich-enabled');
4816 if (enrichErr) enrichErr.textContent = '—';
4817 const evApErr = el('settings-evaluator-may-approve');
4818 if (evApErr) evApErr.textContent = '—';
4819 const configureSection = el('settings-configure-backup-section');
4820 const configureHr = el('settings-hr-configure');
4821 if (configureSection) configureSection.style.display = '';
4822 if (configureHr) configureHr.style.display = '';
4823 const ghStatus = el('settings-github-status');
4824 if (ghStatus) ghStatus.textContent = '—';
4825 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
4826 const apiRowErr = el('settings-api-base-row');
4827 const apiDispErr = el('settings-api-base-display');
4828 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
4829 apiRowErr.classList.remove('hidden');
4830 apiDispErr.textContent = apiBase;
4831 }
4832 refreshApiBaseFootgunBanner();
4833 });
4834 api('/api/v1/setup')
4835 .then((u) => {
4836 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
4837 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
4838 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
4839 })
4840 .catch(() => {});
4841 }
4842 function closeSettings() {
4843 el('modal-settings').classList.add('hidden');
4844 }
4845 function openSettingsBillingTab() {
4846 openSettings();
4847 document.querySelectorAll('.settings-tab').forEach((t) => {
4848 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
4849 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
4850 });
4851 document.querySelectorAll('.settings-panel').forEach((p) => {
4852 p.classList.toggle('active', p.id === 'settings-panel-billing');
4853 });
4854 loadBillingPanel();
4855 }
4856
4857 function openSettingsIntegrationsTab() {
4858 openSettings();
4859 document.querySelectorAll('.settings-tab').forEach((t) => {
4860 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
4861 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
4862 });
4863 document.querySelectorAll('.settings-panel').forEach((p) => {
4864 p.classList.toggle('active', p.id === 'settings-panel-integrations');
4865 });
4866 refreshIntegApiStatus();
4867 applyMuseBridgePanel(lastBackupSettingsPayload);
4868 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
4869 }
4870
4871 if (btnSettings) btnSettings.onclick = openSettings;
4872
4873 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
4874 if (btnSettingsSetupGuide) {
4875 btnSettingsSetupGuide.addEventListener('click', () => {
4876 closeSettings();
4877 void openOnboardingWizard({ restart: true });
4878 });
4879 }
4880
4881 const btnProposalPolicySave = el('btn-proposal-policy-save');
4882 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
4883 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
4884 btnProposalPolicySave.addEventListener('click', async () => {
4885 const msg = el('settings-proposal-policy-msg');
4886 if (msg) {
4887 msg.textContent = '';
4888 msg.className = 'settings-msg';
4889 }
4890 try {
4891 await api('/api/v1/settings/proposal-policy', {
4892 method: 'POST',
4893 body: JSON.stringify({
4894 proposal_evaluation_required: el('settings-policy-eval').checked,
4895 review_hints_enabled: el('settings-policy-hints').checked,
4896 enrich_enabled: el('settings-policy-enrich').checked,
4897 }),
4898 });
4899 if (msg) {
4900 msg.textContent = 'Saved.';
4901 msg.className = 'settings-msg ok';
4902 }
4903 const fresh = await fetchSettingsForBackupModal();
4904 applySettingsPayloadToHubChrome(fresh);
4905 const evalReqEl = el('settings-proposal-eval-required');
4906 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
4907 const hintsEl2 = el('settings-proposal-hints-enabled');
4908 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
4909 const enrichEl2 = el('settings-proposal-enrich-enabled');
4910 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
4911 const st = fresh.proposal_policy_stored || {};
4912 const lk = fresh.proposal_policy_env_locked || {};
4913 const ce = el('settings-policy-eval');
4914 const ch = el('settings-policy-hints');
4915 const cr = el('settings-policy-enrich');
4916 if (ce && ch && cr) {
4917 ce.checked = Boolean(st.proposal_evaluation_required);
4918 ch.checked = Boolean(st.review_hints_enabled);
4919 cr.checked = Boolean(st.enrich_enabled);
4920 ce.disabled = Boolean(lk.proposal_evaluation_required);
4921 ch.disabled = Boolean(lk.review_hints_enabled);
4922 cr.disabled = Boolean(lk.enrich_enabled);
4923 const lockHint =
4924 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4925 ce.title = lk.proposal_evaluation_required ? lockHint : '';
4926 ch.title = lk.review_hints_enabled ? lockHint : '';
4927 cr.title = lk.enrich_enabled ? lockHint : '';
4928 }
4929 } catch (e) {
4930 if (msg) {
4931 msg.textContent = e && e.message ? String(e.message) : String(e);
4932 msg.className = 'settings-msg err';
4933 }
4934 }
4935 });
4936 }
4937 el('modal-settings-backdrop').onclick = closeSettings;
4938 el('modal-settings-close').onclick = closeSettings;
4939
4940 el('btn-copy-env-agentception').onclick = () => {
4941 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
4942 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
4943 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
4944 const lines = [];
4945 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
4946 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
4947 }
4948 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
4949 const snippet = lines.join('\n');
4950 const msg = el('agents-copy-msg');
4951 if (navigator.clipboard && navigator.clipboard.writeText) {
4952 navigator.clipboard.writeText(snippet).then(() => {
4953 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
4954 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
4955 }).catch(() => {
4956 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
4957 });
4958 } else {
4959 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
4960 }
4961 };
4962
4963 function refreshIntegApiStatus() {
4964 var dot = el('integ-api-status');
4965 if (!dot) return;
4966 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
4967 dot.classList.toggle('active', hasToken);
4968 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
4969 }
4970
4971 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
4972 let activeIntegGuide = null;
4973
4974 function closeIntegGuideModal() {
4975 const modal = el('modal-integ-guide');
4976 if (modal) modal.classList.add('hidden');
4977 activeIntegGuide = null;
4978 }
4979
4980 function openIntegGuideModal(guide) {
4981 const mod = globalThis.HubIntegrationGuides;
4982 if (!mod || !guide) return;
4983 const modal = el('modal-integ-guide');
4984 const iconEl = el('modal-integ-guide-icon');
4985 const nameEl = el('modal-integ-guide-name');
4986 const leadEl = el('modal-integ-guide-lead');
4987 const contentEl = el('modal-integ-guide-content');
4988 const importBtn = el('btn-integ-guide-import');
4989 const teamBtn = el('btn-integ-guide-team');
4990 const msgEl = el('modal-integ-guide-msg');
4991 if (!modal || !contentEl) return;
4992 activeIntegGuide = guide;
4993 if (iconEl) iconEl.textContent = guide.icon || '';
4994 if (nameEl) nameEl.textContent = guide.name || 'Integration';
4995 if (leadEl) {
4996 leadEl.textContent =
4997 guide.kind === 'capture'
4998 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
4999 : guide.desc || 'Import files or exports into your vault.';
5000 }
5001 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5002 if (msgEl) msgEl.textContent = '';
5003 if (importBtn) {
5004 const importSel = el('import-source-type');
5005 const canPreselect =
5006 guide.hubImport &&
5007 guide.sourceType &&
5008 importSel &&
5009 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5010 const showImport =
5011 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5012 importBtn.classList.toggle('hidden', !showImport);
5013 importBtn.textContent =
5014 guide.id === 'hermes'
5015 ? 'Open Import (Markdown)'
5016 : guide.id === 'imports'
5017 ? 'Open Import'
5018 : 'Open Import';
5019 }
5020 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5021 modal.classList.remove('hidden');
5022 }
5023
5024 let integGuideControlsBound = false;
5025
5026 function bindIntegrationGuideModalControlsOnce() {
5027 if (integGuideControlsBound) return;
5028 integGuideControlsBound = true;
5029 const backdrop = el('modal-integ-guide-backdrop');
5030 const closeBtn = el('modal-integ-guide-close');
5031 const importBtn = el('btn-integ-guide-import');
5032 const teamBtn = el('btn-integ-guide-team');
5033 const contentEl = el('modal-integ-guide-content');
5034 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5035 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5036 if (contentEl) {
5037 contentEl.addEventListener('click', (ev) => {
5038 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5039 if (!btn) return;
5040 const text = btn.getAttribute('data-copy') || '';
5041 const msgEl = el('modal-integ-guide-msg');
5042 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5043 navigator.clipboard.writeText(text).then(() => {
5044 if (msgEl) {
5045 msgEl.textContent = 'Copied.';
5046 msgEl.className = 'settings-msg ok';
5047 }
5048 setTimeout(() => {
5049 if (msgEl) msgEl.textContent = '';
5050 }, 2000);
5051 }).catch(() => {
5052 if (msgEl) {
5053 msgEl.textContent = 'Copy failed';
5054 msgEl.className = 'settings-msg err';
5055 }
5056 });
5057 } else if (msgEl) {
5058 msgEl.textContent = 'Clipboard not available';
5059 msgEl.className = 'settings-msg err';
5060 }
5061 });
5062 }
5063 if (importBtn) {
5064 importBtn.onclick = () => {
5065 const guide = activeIntegGuide;
5066 closeIntegGuideModal();
5067 closeSettings();
5068 const preselect =
5069 guide && guide.id === 'hermes'
5070 ? 'markdown'
5071 : guide && guide.sourceType
5072 ? guide.sourceType
5073 : undefined;
5074 openImportModal(preselect);
5075 };
5076 }
5077 if (teamBtn) {
5078 teamBtn.onclick = () => {
5079 closeIntegGuideModal();
5080 openSettings();
5081 document.querySelectorAll('.settings-tab').forEach((t) => {
5082 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5083 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5084 });
5085 document.querySelectorAll('.settings-panel').forEach((p) => {
5086 p.classList.toggle('active', p.id === 'settings-panel-team');
5087 });
5088 };
5089 }
5090 document.addEventListener('click', (ev) => {
5091 const tile =
5092 ev.target instanceof Element
5093 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5094 : null;
5095 if (!tile) return;
5096 const mod = globalThis.HubIntegrationGuides;
5097 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5098 if (typeof showToast === 'function') {
5099 showToast('Integration details still loading — try again in a moment.', true);
5100 }
5101 scheduleIntegrationGuidesInit(0);
5102 return;
5103 }
5104 const id = tile.getAttribute('data-integ-id');
5105 const guide = id ? mod.getIntegrationGuide(id) : null;
5106 if (guide) {
5107 ev.preventDefault();
5108 openIntegGuideModal(guide);
5109 }
5110 });
5111 }
5112
5113 function scheduleIntegrationGuidesInit(attempt) {
5114 bindIntegrationGuideModalControlsOnce();
5115 if (globalThis.HubIntegrationGuides) return;
5116 if (attempt >= 80) return;
5117 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5118 }
5119
5120 scheduleIntegrationGuidesInit(0);
5121
5122 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5123 if (btnCopyMcpPrime) {
5124 btnCopyMcpPrime.onclick = () => {
5125 const base = String(apiBase || '').replace(/\/$/, '');
5126 const vaultId = getCurrentVaultId() || 'default';
5127 const msg = el('integrations-hub-api-copy-msg');
5128 const payload = {
5129 schema: 'knowtation.hub_copy_prime/v1',
5130 mcp_read_resource_uri: 'knowtation://hosted/prime',
5131 instructions:
5132 'Non-secret snapshot (no JWT): gateway URL, optional KNOWTATION_MCP_URL, vault id. For secrets and which URL to use for REST vs MCP vs local CLI, use "Copy Hub URL, token & vault" and read ' +
5133 INTEGRATION_DOC_URL,
5134 KNOWTATION_HUB_URL: base,
5135 KNOWTATION_HUB_VAULT_ID: vaultId,
5136 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5137 };
5138 const snippet = JSON.stringify(payload, null, 2);
5139 if (navigator.clipboard && navigator.clipboard.writeText) {
5140 navigator.clipboard.writeText(snippet).then(() => {
5141 if (msg) {
5142 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5143 msg.className = 'settings-msg';
5144 }
5145 setTimeout(() => {
5146 if (msg) msg.textContent = '';
5147 }, 2800);
5148 }).catch(() => {
5149 if (msg) {
5150 msg.textContent = 'Copy failed';
5151 msg.className = 'settings-msg err';
5152 }
5153 });
5154 } else if (msg) {
5155 msg.textContent = 'Clipboard not available';
5156 msg.className = 'settings-msg err';
5157 }
5158 };
5159 }
5160
5161 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5162 if (btnCopyHubApiEnv) {
5163 btnCopyHubApiEnv.onclick = () => {
5164 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5165 const vaultId = getCurrentVaultId() || 'default';
5166 const base = String(apiBase || '').replace(/\/$/, '');
5167 const msg = el('integrations-hub-api-copy-msg');
5168 if (!hubTok) {
5169 if (msg) {
5170 msg.textContent = 'Sign in first, then copy again.';
5171 msg.className = 'settings-msg err';
5172 }
5173 return;
5174 }
5175 const copyLines = [
5176 'KNOWTATION_HUB_URL=' + base,
5177 'KNOWTATION_HUB_TOKEN=' + hubTok,
5178 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5179 ];
5180 if (mcpPublicUrl !== '') {
5181 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5182 }
5183 copyLines.push('');
5184 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5185 copyLines.push(
5186 '# Example curl (append these headers to any Hub REST call): ' +
5187 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5188 '-H "Content-Type: application/json" ' +
5189 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5190 );
5191 const snippet = copyLines.join('\n');
5192 if (navigator.clipboard && navigator.clipboard.writeText) {
5193 navigator.clipboard.writeText(snippet).then(() => {
5194 if (msg) {
5195 msg.textContent = 'Copied URL, token, and vault id.';
5196 msg.className = 'settings-msg';
5197 }
5198 refreshIntegApiStatus();
5199 setTimeout(() => {
5200 if (msg) msg.textContent = '';
5201 }, 2500);
5202 }).catch(() => {
5203 if (msg) {
5204 msg.textContent = 'Copy failed';
5205 msg.className = 'settings-msg err';
5206 }
5207 });
5208 } else if (msg) {
5209 msg.textContent = 'Clipboard not available';
5210 msg.className = 'settings-msg err';
5211 }
5212 };
5213 }
5214
5215 const btnSettingsMuseSave = el('btn-settings-muse-save');
5216 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
5217 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
5218 btnSettingsMuseSave.addEventListener('click', async () => {
5219 const msg = el('settings-muse-msg');
5220 if (msg) {
5221 msg.textContent = '';
5222 msg.className = 'settings-msg';
5223 }
5224 const input = el('settings-muse-url');
5225 const url = input ? String(input.value || '').trim() : '';
5226 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
5227 try {
5228 await api('/api/v1/settings/muse', {
5229 method: 'POST',
5230 body: JSON.stringify({ url }),
5231 });
5232 if (msg) {
5233 msg.textContent = 'Saved.';
5234 msg.className = 'settings-msg ok';
5235 }
5236 const s = await api('/api/v1/settings');
5237 applySettingsPayloadToHubChrome(s);
5238 } catch (e) {
5239 if (msg) {
5240 msg.textContent =
5241 e && e.code === 'ENV_CONFLICT'
5242 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
5243 : (e && e.message) || 'Save failed';
5244 msg.className = 'settings-msg err';
5245 }
5246 }
5247 });
5248 });
5249 }
5250
5251 document.querySelectorAll('.settings-tab').forEach((tab) => {
5252 tab.addEventListener('click', () => {
5253 const id = tab.dataset.settingsTab;
5254 document.querySelectorAll('.settings-tab').forEach((t) => {
5255 t.classList.toggle('active', t.dataset.settingsTab === id);
5256 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
5257 });
5258 document.querySelectorAll('.settings-panel').forEach((p) => {
5259 p.classList.toggle('active', p.id === 'settings-panel-' + id);
5260 });
5261 if (id === 'team') {
5262 loadTeamRolesList();
5263 loadInvitesList();
5264 }
5265 if (id === 'vaults') loadVaultsPanel();
5266 if (id === 'billing') loadBillingPanel();
5267 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
5268 if (id === 'consolidation') loadConsolidationSettings();
5269 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
5270 });
5271 });
5272
5273 function formatTokenCount(n) {
5274 if (n == null || !Number.isFinite(Number(n))) return '—';
5275 return Number(n).toLocaleString();
5276 }
5277
5278 function formatTokenCountShort(n) {
5279 if (n == null || !Number.isFinite(Number(n))) return '—';
5280 const v = Number(n);
5281 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
5282 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
5283 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
5284 return String(v);
5285 }
5286
5287 /**
5288 * Update the token usage progress bar.
5289 * @param {number} used - tokens used this period
5290 * @param {number|null} included - tokens included (null = unlimited)
5291 */
5292 function updateUsageBar(fillId, used, included) {
5293 const fill = el(fillId);
5294 if (!fill) return;
5295 if (included == null) {
5296 fill.style.width = '15%';
5297 fill.className = 'billing-usage-bar-fill';
5298 return;
5299 }
5300 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
5301 fill.style.width = pct + '%';
5302 fill.className =
5303 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
5304 }
5305
5306 const TIER_LABELS = {
5307 free: 'Free',
5308 plus: 'Plus',
5309 growth: 'Growth',
5310 pro: 'Pro',
5311 beta: 'Beta',
5312 starter: 'Plus',
5313 team: 'Team',
5314 };
5315
5316 const TIER_CSS_CLASSES = {
5317 free: 'tier-free',
5318 plus: 'tier-plus',
5319 growth: 'tier-growth',
5320 pro: 'tier-pro',
5321 beta: 'tier-beta',
5322 starter: 'tier-plus',
5323 team: 'tier-pro',
5324 };
5325
5326 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
5327
5328 const TIER_PLAN_DATA = [
5329 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
5330 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
5331 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
5332 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
5333 ];
5334
5335 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
5336 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
5337
5338 /**
5339 * Render the plan comparison grid into #billing-plan-grid.
5340 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
5341 */
5342 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
5343 const grid = el('billing-plan-grid');
5344 if (!grid) return;
5345
5346 const normalized =
5347 currentTier === 'starter' ? 'plus'
5348 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
5349 : currentTier;
5350 const currentRank = TIER_ORDER.indexOf(normalized);
5351
5352 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
5353 const rank = TIER_ORDER.indexOf(tier);
5354 const isCurrent = rank === currentRank;
5355 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
5356
5357 let ctaHtml = '';
5358 if (isCurrent) {
5359 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
5360 } else if (isUpgrade) {
5361 const label = hasSub
5362 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
5363 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
5364 ctaHtml =
5365 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
5366 tier + '">' + label + '</button>';
5367 }
5368
5369 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
5370 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
5371
5372 return (
5373 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
5374 '<div class="billing-plan-card-header">' +
5375 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
5376 '<span class="billing-plan-card-price">' + price + '</span>' +
5377 '</div>' +
5378 '<ul class="billing-plan-card-features">' +
5379 '<li>' + searches + '</li>' +
5380 '<li>' + indexJobs + '</li>' +
5381 '<li>' + notes + '</li>' +
5382 consolLine +
5383 packLine +
5384 '</ul>' +
5385 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
5386 '</div>'
5387 );
5388 });
5389
5390 grid.innerHTML = cards.join('');
5391
5392 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
5393 btn.addEventListener('click', async () => {
5394 const tier = btn.dataset.tier;
5395 setButtonBusy(btn, true, 'Redirecting\u2026');
5396 try {
5397 await redirectToCheckout({ tier });
5398 } catch (e) {
5399 setButtonBusy(btn, false);
5400 const msg = el('billing-panel-msg');
5401 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
5402 }
5403 });
5404 });
5405 }
5406
5407 /**
5408 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
5409 * @param {{ price_id?: string, tier?: string }} opts
5410 */
5411 async function redirectToCheckout(opts) {
5412 const resp = await api('/api/v1/billing/checkout', {
5413 method: 'POST',
5414 headers: { 'Content-Type': 'application/json' },
5415 body: JSON.stringify({
5416 ...opts,
5417 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
5418 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
5419 }),
5420 });
5421 if (resp && resp.url) {
5422 window.location.href = resp.url;
5423 }
5424 }
5425
5426 /**
5427 * Redirect to Stripe Customer Portal.
5428 */
5429 async function redirectToPortal() {
5430 const resp = await api('/api/v1/billing/portal', {
5431 method: 'POST',
5432 headers: { 'Content-Type': 'application/json' },
5433 body: JSON.stringify({
5434 return_url: window.location.origin + window.location.pathname + '?open=billing',
5435 }),
5436 });
5437 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
5438 if (!url) {
5439 throw new Error(
5440 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
5441 );
5442 }
5443 window.location.assign(url);
5444 }
5445
5446 async function loadBillingPanel() {
5447 const msg = el('billing-panel-msg');
5448 const tierEl = el('billing-tier');
5449 const searchesUsedEl = el('billing-searches-used');
5450 const searchesIncEl = el('billing-searches-included');
5451 const indexJobsUsedEl = el('billing-index-jobs-used');
5452 const indexJobsIncEl = el('billing-index-jobs-included');
5453 const packEl = el('billing-pack-balance');
5454 const packRow = el('billing-pack-balance-row');
5455 const periodEl = el('billing-period');
5456 const renewalEl = el('billing-renewal');
5457 const credEl = el('billing-credits-used');
5458 const credRow = el('billing-credits-row');
5459 const polEl = el('billing-indexing-policy');
5460 const noteCap = el('billing-note-cap');
5461 const refreshBtn = el('btn-billing-refresh');
5462 const upgradeBtn = el('btn-billing-upgrade');
5463 const manageBtn = el('btn-billing-manage');
5464 const packSection = el('billing-pack-section');
5465 if (!tierEl || !searchesUsedEl) return;
5466 if (msg) msg.textContent = '';
5467 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
5468
5469 const setDash = () => {
5470 tierEl.textContent = '—';
5471 tierEl.className = 'billing-plan-badge tier-beta';
5472 if (searchesUsedEl) searchesUsedEl.textContent = '—';
5473 if (searchesIncEl) searchesIncEl.textContent = '—';
5474 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
5475 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
5476 if (packEl) packEl.textContent = '0';
5477 if (packRow) packRow.style.display = 'none';
5478 if (periodEl) periodEl.textContent = '—';
5479 if (renewalEl) renewalEl.textContent = '';
5480 if (credEl) credEl.textContent = '—';
5481 if (credRow) credRow.style.display = 'none';
5482 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
5483 if (noteCap) noteCap.textContent = '—';
5484 if (packSection) packSection.style.display = 'none';
5485 if (upgradeBtn) upgradeBtn.style.display = 'none';
5486 if (manageBtn) manageBtn.style.display = 'none';
5487 updateUsageBar('billing-searches-bar-fill', 0, 0);
5488 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
5489 updateUsageBar('billing-consol-bar-fill', 0, 0);
5490 const consolUsedReset = el('billing-consol-used');
5491 const consolIncReset = el('billing-consol-included');
5492 if (consolUsedReset) consolUsedReset.textContent = '—';
5493 if (consolIncReset) consolIncReset.textContent = '—';
5494 renderBillingPlanGrid('beta', false, false);
5495 };
5496
5497 if (!token) {
5498 setDash();
5499 if (msg) msg.textContent = 'Sign in to view billing usage.';
5500 if (refreshBtn) setButtonBusy(refreshBtn, false);
5501 return;
5502 }
5503
5504 try {
5505 const d = await api('/api/v1/billing/summary');
5506 const tier = d.tier != null ? String(d.tier) : 'beta';
5507
5508 // Plan badge
5509 tierEl.textContent = TIER_LABELS[tier] || tier;
5510 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
5511
5512 // Renewal date
5513 if (renewalEl) {
5514 const pe = d.period_end;
5515 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
5516 }
5517
5518 // Plan comparison grid
5519 const hasSub = Boolean(d.has_active_subscription);
5520 const isFreeTier = tier === 'free' || tier === 'beta';
5521 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
5522
5523 // Legacy upgrade button stays hidden (grid handles upgrades now)
5524 if (upgradeBtn) upgradeBtn.style.display = 'none';
5525 // Manage button: visible for active subscribers to reach the Stripe portal
5526 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
5527
5528 // Searches usage bar
5529 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
5530 const searchesInc = d.monthly_searches_included ?? null;
5531 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
5532 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
5533 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
5534
5535 // Index jobs usage bar
5536 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
5537 const indexJobsInc = d.monthly_index_jobs_included ?? null;
5538 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
5539 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
5540 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
5541
5542 // Consolidation jobs usage bar
5543 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
5544 const consolInc = d.monthly_consolidation_jobs_included ?? null;
5545 const consolUsedEl = el('billing-consol-used');
5546 const consolIncEl = el('billing-consol-included');
5547 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
5548 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
5549 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
5550
5551 // Pack balance
5552 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
5553 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
5554 if (packEl) {
5555 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
5556 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
5557 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
5558 let packText = formatTokenCountShort(packBal) +
5559 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
5560 if (packConsolPasses > 0) {
5561 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
5562 }
5563 packEl.textContent = packText;
5564 }
5565 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
5566
5567 // Period
5568 if (periodEl) {
5569 const ps = d.period_start;
5570 const pe = d.period_end;
5571 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
5572 }
5573
5574 // Note cap
5575 if (noteCap) {
5576 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
5577 }
5578
5579 // Legacy credits row (only show if non-zero)
5580 const mu = Number(d.monthly_used_cents) || 0;
5581 const mi = Number(d.monthly_included_effective_cents) || 0;
5582 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
5583 if (credEl && (mu > 0 || mi > 0)) {
5584 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
5585 }
5586
5587 // Token policy
5588 if (polEl) {
5589 const pol = d.indexing_tokens_policy;
5590 if (pol && String(pol).trim()) {
5591 polEl.textContent = String(pol).trim();
5592 polEl.style.display = '';
5593 } else {
5594 polEl.style.display = 'none';
5595 }
5596 }
5597
5598 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
5599 if (packSection) {
5600 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
5601 packSection.style.display = showPacks ? '' : 'none';
5602 }
5603
5604 if (msg) {
5605 msg.textContent = '';
5606 msg.className = 'settings-intro small muted';
5607 }
5608 } catch (e) {
5609 setDash();
5610 const m = e && e.message ? String(e.message) : String(e);
5611 if (msg) {
5612 msg.textContent =
5613 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
5614 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
5615 : m;
5616 msg.className = 'settings-intro small err';
5617 }
5618 }
5619 if (refreshBtn) setButtonBusy(refreshBtn, false);
5620 }
5621
5622 const btnBillingRefresh = el('btn-billing-refresh');
5623 if (btnBillingRefresh) {
5624 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
5625 }
5626
5627 const btnBillingUpgrade = el('btn-billing-upgrade');
5628 if (btnBillingUpgrade) {
5629 btnBillingUpgrade.addEventListener('click', async () => {
5630 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
5631 try {
5632 await redirectToCheckout({ tier: 'plus' });
5633 } catch (e) {
5634 setButtonBusy(btnBillingUpgrade, false);
5635 const packMsg = el('billing-panel-msg');
5636 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
5637 }
5638 });
5639 }
5640
5641 const btnBillingManage = el('btn-billing-manage');
5642 if (btnBillingManage) {
5643 btnBillingManage.addEventListener('click', async () => {
5644 const panelMsg = el('billing-panel-msg');
5645 if (panelMsg) {
5646 panelMsg.textContent = '';
5647 panelMsg.className = 'settings-intro small muted';
5648 }
5649 setButtonBusy(btnBillingManage, true, 'Redirecting…');
5650 try {
5651 await redirectToPortal();
5652 } catch (e) {
5653 setButtonBusy(btnBillingManage, false);
5654 const errText = e?.message || 'Could not open billing portal.';
5655 if (panelMsg) {
5656 panelMsg.textContent = errText;
5657 panelMsg.className = 'settings-intro small err';
5658 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
5659 }
5660 }
5661 });
5662 }
5663
5664 // Token pack purchase buttons
5665 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
5666 btn.addEventListener('click', async () => {
5667 const pack = btn.dataset.pack;
5668 const packMsgEl = el('billing-pack-msg');
5669 setButtonBusy(btn, true, 'Redirecting…');
5670 if (packMsgEl) packMsgEl.textContent = '';
5671 try {
5672 await redirectToCheckout({ pack_size: pack });
5673 } catch (e) {
5674 setButtonBusy(btn, false);
5675 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
5676 }
5677 });
5678 });
5679
5680 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
5681 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
5682 const arr = Array.isArray(vaults) ? vaults : [];
5683 if (arr.length === 0) {
5684 return isHosted
5685 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
5686 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
5687 }
5688 const items = arr
5689 .map((v) => {
5690 if (!v || v.id == null) return '';
5691 const id = escapeHtml(String(v.id).trim());
5692 const lab =
5693 v.label != null && String(v.label).trim()
5694 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
5695 : '';
5696 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
5697 const pathHtml = pathRaw
5698 ? escapeHtml(pathRaw)
5699 : '<span class="muted">—</span>';
5700 return (
5701 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
5702 id +
5703 '</code>' +
5704 lab +
5705 '</div><div class="vaults-summary-path muted small">' +
5706 pathHtml +
5707 '</div></li>'
5708 );
5709 })
5710 .filter(Boolean)
5711 .join('');
5712 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
5713 }
5714
5715 function collectVaultIdsForAccessForm(vaults, settingsRes) {
5716 const set = new Set(['default']);
5717 const allowed =
5718 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
5719 allowed.forEach((id) => {
5720 if (id != null && String(id).trim()) set.add(String(id).trim());
5721 });
5722 (vaults || []).forEach((v) => {
5723 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
5724 });
5725 return Array.from(set).sort((a, b) => {
5726 if (a === 'default') return -1;
5727 if (b === 'default') return 1;
5728 return a.localeCompare(b);
5729 });
5730 }
5731
5732 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
5733 if (!selectEl) return;
5734 const uids = new Set();
5735 (roleIds || []).forEach((id) => {
5736 if (id != null && String(id).trim()) uids.add(String(id).trim());
5737 });
5738 if (currentUserId != null && String(currentUserId).trim()) {
5739 uids.add(String(currentUserId).trim());
5740 }
5741 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
5742 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
5743 sorted.forEach((uid) => {
5744 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
5745 });
5746 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
5747 selectEl.innerHTML = html;
5748 }
5749
5750 function renderAccessVaultCheckboxes(vaultIds) {
5751 const wrap = el('access-form-vault-checkboxes');
5752 if (!wrap) return;
5753 if (!vaultIds.length) {
5754 wrap.innerHTML =
5755 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
5756 return;
5757 }
5758 wrap.innerHTML = vaultIds
5759 .map((id) => {
5760 const idAttr = escapeHtml(id);
5761 return (
5762 '<label><input type="checkbox" name="hub-access-vault" value="' +
5763 idAttr +
5764 '"> <code>' +
5765 idAttr +
5766 '</code></label>'
5767 );
5768 })
5769 .join('');
5770 }
5771
5772 function parseVaultAccessFromTextarea() {
5773 const accessText = el('vault-access-json');
5774 try {
5775 const access = JSON.parse((accessText && accessText.value) || '{}');
5776 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
5777 } catch (_) {
5778 return {};
5779 }
5780 }
5781
5782 function refreshAccessRulesSummary(access) {
5783 const wrap = el('access-rules-summary');
5784 if (!wrap) return;
5785 if (typeof access !== 'object' || access === null) access = {};
5786 const keys = Object.keys(access);
5787 if (keys.length === 0) {
5788 wrap.innerHTML =
5789 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
5790 return;
5791 }
5792 wrap.innerHTML = keys
5793 .sort((a, b) => a.localeCompare(b))
5794 .map((uid) => {
5795 const arr = access[uid];
5796 const vaults =
5797 Array.isArray(arr) && arr.length
5798 ? arr.map((x) => escapeHtml(String(x))).join(', ')
5799 : '<span class="muted">(invalid)</span>';
5800 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
5801 })
5802 .join('');
5803 }
5804
5805 function accessFormToggleOtherInput() {
5806 const sel = el('access-form-user-select');
5807 const wrap = el('access-form-user-other-wrap');
5808 const other = el('access-form-user-other');
5809 if (!sel || !wrap) return;
5810 const show = sel.value === '__other__';
5811 wrap.classList.toggle('hidden', !show);
5812 if (!show && other) other.value = '';
5813 }
5814
5815 function accessFormSyncCheckboxesFromAccessJson() {
5816 const sel = el('access-form-user-select');
5817 const other = el('access-form-user-other');
5818 if (!sel) return;
5819 let uid = '';
5820 if (sel.value === '__other__') {
5821 uid = ((other && other.value) || '').trim();
5822 } else {
5823 uid = (sel.value || '').trim();
5824 }
5825 const access = parseVaultAccessFromTextarea();
5826 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
5827 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
5828 cb.checked = allowed.indexOf(cb.value) !== -1;
5829 });
5830 }
5831
5832 function getAccessFormResolvedUserId() {
5833 const sel = el('access-form-user-select');
5834 const other = el('access-form-user-other');
5835 if (!sel) return '';
5836 if (sel.value === '__other__') return ((other && other.value) || '').trim();
5837 return (sel.value || '').trim();
5838 }
5839
5840 const accessUserSel = el('access-form-user-select');
5841 if (accessUserSel) {
5842 accessUserSel.addEventListener('change', () => {
5843 accessFormToggleOtherInput();
5844 accessFormSyncCheckboxesFromAccessJson();
5845 });
5846 }
5847 const accessUserOther = el('access-form-user-other');
5848 if (accessUserOther) {
5849 accessUserOther.addEventListener('input', () => {
5850 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
5851 accessFormSyncCheckboxesFromAccessJson();
5852 }
5853 });
5854 }
5855 const scopeUserSelInit = el('scope-form-user-select');
5856 if (scopeUserSelInit) {
5857 scopeUserSelInit.addEventListener('change', () => {
5858 const inp = el('scope-form-user-id');
5859 if (scopeUserSelInit.value === '__other__') {
5860 if (inp) inp.focus();
5861 } else if (scopeUserSelInit.value && inp) {
5862 inp.value = scopeUserSelInit.value;
5863 }
5864 });
5865 }
5866
5867 function populateVaultListExistingSelect(vaults) {
5868 const sel = el('vault-list-form-existing');
5869 if (!sel) return;
5870 let html = '<option value="">New vault</option>';
5871 (vaults || []).forEach((v) => {
5872 if (v && v.id != null && String(v.id).trim()) {
5873 const id = String(v.id).trim();
5874 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
5875 }
5876 });
5877 sel.innerHTML = html;
5878 }
5879
5880 function parseVaultsJsonArrayFromTextarea() {
5881 const ta = el('vaults-json');
5882 try {
5883 const arr = JSON.parse((ta && ta.value) || '[]');
5884 return Array.isArray(arr) ? arr : [];
5885 } catch (_) {
5886 return null;
5887 }
5888 }
5889
5890 function fillVaultListFormFromExisting() {
5891 const sel = el('vault-list-form-existing');
5892 const idInp = el('vault-list-form-id');
5893 const pathInp = el('vault-list-form-path');
5894 const labelInp = el('vault-list-form-label');
5895 if (!sel) return;
5896 if (!sel.value) {
5897 if (idInp) {
5898 idInp.value = '';
5899 idInp.readOnly = false;
5900 }
5901 if (pathInp) pathInp.value = '';
5902 if (labelInp) labelInp.value = '';
5903 return;
5904 }
5905 const vaults = parseVaultsJsonArrayFromTextarea();
5906 if (!vaults) return;
5907 const v = vaults.find((x) => x && String(x.id) === sel.value);
5908 if (v) {
5909 if (idInp) {
5910 idInp.value = String(v.id);
5911 idInp.readOnly = true;
5912 }
5913 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
5914 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
5915 }
5916 }
5917
5918 function toggleVaultsInfoPanel(panelId) {
5919 const panel = el(panelId);
5920 const modal = el('modal-settings');
5921 if (!panel || !modal) return;
5922 const wasHidden = panel.classList.contains('hidden');
5923 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
5924 if (wasHidden) panel.classList.remove('hidden');
5925 }
5926
5927 const modalSettingsForVaultsInfo = el('modal-settings');
5928 if (modalSettingsForVaultsInfo) {
5929 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
5930 const infoBtn = e.target.closest('.btn-settings-info');
5931 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
5932 e.stopPropagation();
5933 const tid = infoBtn.getAttribute('data-settings-info-target');
5934 if (tid) toggleVaultsInfoPanel(tid);
5935 return;
5936 }
5937 if (
5938 !e.target.closest('.settings-info-panel') &&
5939 !e.target.closest('.btn-settings-info')
5940 ) {
5941 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
5942 p.classList.add('hidden');
5943 });
5944 }
5945 });
5946 }
5947
5948 const vaultListExistingSel = el('vault-list-form-existing');
5949 if (vaultListExistingSel) {
5950 vaultListExistingSel.addEventListener('change', () => {
5951 fillVaultListFormFromExisting();
5952 const msg = el('vault-list-form-msg');
5953 if (msg) msg.textContent = '';
5954 });
5955 }
5956
5957 const btnVaultListFormApply = el('btn-vault-list-form-apply');
5958 if (btnVaultListFormApply) {
5959 btnVaultListFormApply.onclick = () => {
5960 const msg = el('vault-list-form-msg');
5961 const ta = el('vaults-json');
5962 const idInp = el('vault-list-form-id');
5963 const pathInp = el('vault-list-form-path');
5964 const labelInp = el('vault-list-form-label');
5965 const vaults = parseVaultsJsonArrayFromTextarea();
5966 if (!vaults) {
5967 if (msg) {
5968 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
5969 msg.className = 'settings-msg err';
5970 }
5971 return;
5972 }
5973 const id = ((idInp && idInp.value) || '').trim();
5974 const path = ((pathInp && pathInp.value) || '').trim();
5975 const label = ((labelInp && labelInp.value) || '').trim();
5976 if (!id || !path) {
5977 if (msg) {
5978 msg.textContent = 'Enter vault id and folder path.';
5979 msg.className = 'settings-msg err';
5980 }
5981 return;
5982 }
5983 const entry = { id, path };
5984 if (label) entry.label = label;
5985 const idx = vaults.findIndex((x) => x && String(x.id) === id);
5986 if (idx >= 0) {
5987 vaults[idx] = Object.assign({}, vaults[idx], entry);
5988 } else {
5989 if (idInp && idInp.readOnly) {
5990 if (msg) {
5991 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
5992 msg.className = 'settings-msg err';
5993 }
5994 return;
5995 }
5996 vaults.push(entry);
5997 }
5998 if (ta) ta.value = JSON.stringify(vaults, null, 2);
5999 populateVaultListExistingSelect(vaults);
6000 const sel = el('vault-list-form-existing');
6001 if (sel) sel.value = '';
6002 fillVaultListFormFromExisting();
6003 const lc = el('vaults-list-container');
6004 if (lc && !isHostedHubFromSettings()) {
6005 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6006 }
6007 if (msg) {
6008 msg.textContent = 'Updated. Click Save vault list to persist.';
6009 msg.className = 'settings-msg ok';
6010 }
6011 };
6012 }
6013
6014 async function loadVaultsPanel() {
6015 const listContainer = el('vaults-list-container');
6016 const serverView = el('vaults-server-view');
6017 const vaultsJson = el('vaults-json');
6018 const accessText = el('vault-access-json');
6019 const scopeText = el('scope-json');
6020 const helpHostedBlock = el('vaults-help-hosted-block');
6021 const helpSelfBlock = el('vaults-help-self-block');
6022 const selfHostedEditors = el('vaults-self-hosted-editors');
6023 const yamlOnly = el('vaults-hub-yaml-only');
6024 const hostedCreate = el('vaults-hosted-create');
6025 const workspacePanel = el('vaults-hosted-workspace');
6026 const workspaceInput = el('workspace-owner-input');
6027 const workspaceMsg = el('workspace-save-msg');
6028 if (listContainer) listContainer.textContent = 'Loading…';
6029 if (serverView) serverView.textContent = 'Loading…';
6030 try {
6031 const settingsRes = await api('/api/v1/settings');
6032 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6033 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6034 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6035 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6036 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6037 const ownerFromSettings =
6038 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6039 ? String(settingsRes.workspace_owner_id).trim()
6040 : '';
6041 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
6042 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
6043 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
6044 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
6045 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
6046 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
6047 const hostedCreateMsg = el('vaults-hosted-create-msg');
6048 if (hostedCreateMsg && isHosted) {
6049 hostedCreateMsg.textContent = '';
6050 hostedCreateMsg.className = 'settings-msg';
6051 }
6052 if (workspaceMsg) {
6053 workspaceMsg.textContent = '';
6054 workspaceMsg.className = 'settings-msg';
6055 }
6056
6057 /** @type {{ vaults?: unknown[] }} */
6058 let vRes = { vaults: [] };
6059 try {
6060 vRes = await api('/api/v1/vaults');
6061 } catch (_) {
6062 vRes = { vaults: [] };
6063 }
6064 /** @type {{ access?: Record<string, unknown> }} */
6065 let aRes = { access: {} };
6066 try {
6067 aRes = await api('/api/v1/vault-access');
6068 } catch (_) {
6069 aRes = { access: {} };
6070 }
6071 /** @type {{ scope?: Record<string, unknown> }} */
6072 let sRes = { scope: {} };
6073 try {
6074 sRes = await api('/api/v1/scope');
6075 } catch (_) {
6076 sRes = { scope: {} };
6077 }
6078
6079 if (isHosted && workspaceInput) {
6080 try {
6081 const w = await api('/api/v1/workspace');
6082 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
6083 } catch (e) {
6084 workspaceInput.value = '';
6085 if (workspaceMsg) {
6086 workspaceMsg.textContent =
6087 (e && e.message) ||
6088 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
6089 workspaceMsg.className = 'settings-msg err';
6090 }
6091 }
6092 } else if (workspaceInput && !isHosted) {
6093 workspaceInput.value = '';
6094 }
6095 const vaults = vRes.vaults || [];
6096 if (serverView) {
6097 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
6098 const allowed = settingsRes.allowed_vault_ids;
6099 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
6100 if (isHosted) {
6101 serverView.innerHTML =
6102 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6103 escapeHtml(uid) +
6104 '</code> · <strong>Vaults:</strong> <code>' +
6105 escapeHtml(allowedStr) +
6106 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
6107 } else {
6108 const dataDir =
6109 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
6110 serverView.innerHTML =
6111 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6112 escapeHtml(uid) +
6113 '</code> · <strong>Allowed vaults:</strong> <code>' +
6114 escapeHtml(allowedStr) +
6115 '</code> · <strong>Data:</strong> <code>' +
6116 dataDir +
6117 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
6118 }
6119 }
6120 if (listContainer) {
6121 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
6122 }
6123 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
6124 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
6125 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
6126
6127 const vaultListJsonDetails = el('vault-list-json-details');
6128 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
6129 const vaultAccessDetails = el('vault-access-json-details');
6130 if (vaultAccessDetails) vaultAccessDetails.open = false;
6131 const scopeJsonDetails = el('scope-json-details');
6132 if (scopeJsonDetails) scopeJsonDetails.open = false;
6133
6134 let roleIds = [];
6135 try {
6136 const ro = await api('/api/v1/roles');
6137 roleIds = Object.keys(ro.roles || {});
6138 } catch (_) {
6139 roleIds = [];
6140 }
6141 populateHostedTeamUserSelect(
6142 el('access-form-user-select'),
6143 roleIds,
6144 settingsRes.user_id,
6145 '— Choose a person —',
6146 );
6147 populateHostedTeamUserSelect(
6148 el('scope-form-user-select'),
6149 roleIds,
6150 settingsRes.user_id,
6151 '— Choose or type User ID below —',
6152 );
6153 const asel = el('access-form-user-select');
6154 if (asel) asel.value = '';
6155 const ssel = el('scope-form-user-select');
6156 if (ssel) ssel.value = '';
6157 accessFormToggleOtherInput();
6158 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
6159 renderAccessVaultCheckboxes(vaultIdsForForm);
6160 accessFormSyncCheckboxesFromAccessJson();
6161 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6162
6163 const scopeVaultSelect = el('scope-form-vault-id');
6164 if (scopeVaultSelect) {
6165 scopeVaultSelect.innerHTML =
6166 vaults.length === 0
6167 ? '<option value="default">default</option>'
6168 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
6169 }
6170
6171 if (!isHosted) {
6172 populateVaultListExistingSelect(vaults);
6173 const vSel = el('vault-list-form-existing');
6174 if (vSel) vSel.value = '';
6175 fillVaultListFormFromExisting();
6176 }
6177 } catch (e) {
6178 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
6179 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
6180 }
6181 }
6182
6183 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
6184 function sanitizeNewHostedVaultId(raw) {
6185 const t = String(raw || '').trim();
6186 if (!t) return { error: 'Enter a vault id.' };
6187 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
6188 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
6189 s = s.slice(0, 64);
6190 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
6191 if (s === 'default') {
6192 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
6193 }
6194 return { id: s };
6195 }
6196
6197 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
6198 if (btnHostedVaultCreate) {
6199 btnHostedVaultCreate.onclick = async () => {
6200 const msgEl = el('vaults-hosted-create-msg');
6201 const inp = el('vaults-hosted-new-id');
6202 const setCreateVaultMsg = (text, isErr) => {
6203 if (!msgEl) return;
6204 msgEl.textContent = text;
6205 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6206 };
6207 if (!isHostedHubFromSettings()) {
6208 setCreateVaultMsg('This action is only available on hosted Hub.', true);
6209 return;
6210 }
6211 if (!hubUserCanWriteNotes()) {
6212 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
6213 return;
6214 }
6215 const ws = lastBackupSettingsPayload;
6216 const ownerId =
6217 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
6218 ? String(ws.workspace_owner_id).trim()
6219 : '';
6220 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
6221 if (ownerId && me && me !== ownerId) {
6222 setCreateVaultMsg(
6223 'Only the workspace owner can create new cloud vaults. Ask them to create the vault id here, then an admin can grant access under Vault access.',
6224 true,
6225 );
6226 return;
6227 }
6228 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
6229 if (parsed.error) {
6230 setCreateVaultMsg(parsed.error, true);
6231 return;
6232 }
6233 const { id } = parsed;
6234 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
6235 setCreateVaultMsg('');
6236 try {
6237 const fresh = await api('/api/v1/settings');
6238 const allowed = fresh.allowed_vault_ids || [];
6239 if (Array.isArray(allowed) && allowed.includes(id)) {
6240 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the header to switch to it.', true);
6241 return;
6242 }
6243 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
6244 await api('/api/v1/notes', {
6245 method: 'POST',
6246 headers: { 'X-Vault-Id': id },
6247 body: JSON.stringify({
6248 path,
6249 body:
6250 'This note was created when you added the "' +
6251 id +
6252 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
6253 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
6254 }),
6255 });
6256 hubMarkSemanticIndexStaleForVault(id);
6257 const s = await api('/api/v1/settings');
6258 lastBackupSettingsPayload = s;
6259 if (s.role) window.__hubUserRole = String(s.role);
6260 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6261 applyHostedUiFromSettings(s);
6262 setCurrentVaultId(id);
6263 const sel = el('vault-switcher');
6264 if (sel) sel.value = id;
6265 loadFacets();
6266 loadNotes();
6267 loadProposals();
6268 await loadVaultsPanel();
6269 if (inp) inp.value = '';
6270 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the header to switch.', false);
6271 } catch (e) {
6272 setCreateVaultMsg(e.message || 'Could not create vault', true);
6273 }
6274 });
6275 };
6276 }
6277
6278 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
6279 if (btnSettingsDeleteVault) {
6280 btnSettingsDeleteVault.onclick = async () => {
6281 const msgEl = el('settings-delete-vault-msg');
6282 const setVaultDelMsg = (text, isErr) => {
6283 if (!msgEl) return;
6284 msgEl.textContent = text;
6285 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6286 };
6287 if (!hubUserMayDeleteVault()) {
6288 setVaultDelMsg('You are not allowed to delete vaults.', true);
6289 return;
6290 }
6291 const sel = el('settings-delete-vault-select');
6292 const vaultId = (sel && sel.value) || '';
6293 const vaultIdTrim = String(vaultId).trim();
6294 if (!vaultIdTrim) {
6295 setVaultDelMsg('Choose a vault to delete.', true);
6296 return;
6297 }
6298 if (vaultIdTrim === 'default') {
6299 setVaultDelMsg('The default vault cannot be deleted.', true);
6300 return;
6301 }
6302 const confirmEl = el('settings-delete-vault-confirm');
6303 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
6304 if (confirmVal !== 'DELETE VAULT') {
6305 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
6306 return;
6307 }
6308 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
6309 setVaultDelMsg('', false);
6310 try {
6311 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
6312 method: 'DELETE',
6313 headers: { 'X-Vault-Id': vaultIdTrim },
6314 });
6315 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
6316 if (wasCurrent) {
6317 setCurrentVaultId('default');
6318 const vSel = el('vault-switcher');
6319 if (vSel) vSel.value = 'default';
6320 }
6321 const s = await api('/api/v1/settings');
6322 lastBackupSettingsPayload = s;
6323 if (s.role) window.__hubUserRole = String(s.role);
6324 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6325 applyHostedUiFromSettings(s);
6326 refreshDeleteProjectPanelVisibility();
6327 loadFacets();
6328 loadNotes();
6329 loadProposals();
6330 await loadVaultsPanel();
6331 if (confirmEl) confirmEl.value = '';
6332 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
6333 } catch (e) {
6334 setVaultDelMsg(e.message || 'Could not delete vault', true);
6335 }
6336 });
6337 };
6338 }
6339
6340 const btnScopeFormApply = el('btn-scope-form-apply');
6341 if (btnScopeFormApply) {
6342 btnScopeFormApply.onclick = () => {
6343 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
6344 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
6345 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
6346 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
6347 const msg = el('scope-form-msg');
6348 if (!userId) {
6349 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
6350 return;
6351 }
6352 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
6353 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
6354 const scopeText = el('scope-json');
6355 let scope = {};
6356 if (scopeText && scopeText.value) {
6357 try {
6358 scope = JSON.parse(scopeText.value);
6359 if (typeof scope !== 'object' || scope === null) scope = {};
6360 } catch (_) { scope = {}; }
6361 }
6362 if (!scope[userId]) scope[userId] = {};
6363 scope[userId][vaultId] = { projects, folders };
6364 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
6365 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
6366 };
6367 }
6368
6369 function isHostedHubFromSettings() {
6370 const s = lastBackupSettingsPayload;
6371 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
6372 }
6373
6374 const BULK_PRESET_EMPTY = '';
6375 const BULK_PRESET_CUSTOM = '__custom__';
6376
6377 function fillBulkPresetSelect(sel, items, includeCustom) {
6378 if (!sel) return;
6379 const preserve = sel.value;
6380 sel.innerHTML = '';
6381 const head = document.createElement('option');
6382 head.value = BULK_PRESET_EMPTY;
6383 head.textContent = '— Select or type below —';
6384 sel.appendChild(head);
6385 for (const item of items) {
6386 if (item == null || item === '') continue;
6387 const o = document.createElement('option');
6388 o.value = item;
6389 o.textContent = item;
6390 sel.appendChild(o);
6391 }
6392 if (includeCustom) {
6393 const c = document.createElement('option');
6394 c.value = BULK_PRESET_CUSTOM;
6395 c.textContent = 'Custom (type below)';
6396 sel.appendChild(c);
6397 }
6398 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
6399 else sel.value = BULK_PRESET_EMPTY;
6400 }
6401
6402 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
6403 if (!selectEl || !inputEl) return;
6404 const p = (inputEl.value || '').trim();
6405 if (!p) {
6406 selectEl.value = BULK_PRESET_EMPTY;
6407 return;
6408 }
6409 let best = BULK_PRESET_CUSTOM;
6410 let bestLen = -1;
6411 for (const opt of selectEl.options) {
6412 const v = opt.value;
6413 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
6414 if (p === v || p.startsWith(v + '/')) {
6415 if (v.length > bestLen) {
6416 best = v;
6417 bestLen = v.length;
6418 }
6419 }
6420 }
6421 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
6422 }
6423
6424 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
6425 if (!selectEl || !inputEl) return;
6426 const p = (inputEl.value || '').trim();
6427 if (!p) {
6428 selectEl.value = BULK_PRESET_EMPTY;
6429 return;
6430 }
6431 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
6432 else selectEl.value = BULK_PRESET_CUSTOM;
6433 }
6434
6435 function wireBulkPathPresetPair(selectEl, inputEl) {
6436 if (!selectEl || !inputEl) return;
6437 selectEl.addEventListener('change', () => {
6438 const v = selectEl.value;
6439 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6440 });
6441 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
6442 }
6443
6444 function wireBulkSlugPresetPair(selectEl, inputEl) {
6445 if (!selectEl || !inputEl) return;
6446 selectEl.addEventListener('change', () => {
6447 const v = selectEl.value;
6448 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6449 });
6450 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
6451 }
6452
6453 let bulkPresetDropdownsToken = 0;
6454 async function refreshBulkDeletePresetDropdowns() {
6455 if (!token) return;
6456 const pathSelect = el('settings-bulk-path-prefix-preset');
6457 const delProjSelect = el('settings-bulk-delete-project-preset');
6458 const renameFromSelect = el('settings-bulk-rename-from-preset');
6459 const pathInput = el('settings-delete-prefix');
6460 const delProjInput = el('settings-delete-project-slug');
6461 const renameFromInput = el('settings-rename-project-from');
6462 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
6463 const my = ++bulkPresetDropdownsToken;
6464 let diskFolders = [];
6465 let facets = { projects: [], folders: [] };
6466 try {
6467 const [vf, fc] = await Promise.all([
6468 api('/api/v1/vault/folders'),
6469 api('/api/v1/notes/facets'),
6470 ]);
6471 if (my !== bulkPresetDropdownsToken) return;
6472 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
6473 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
6474 } catch (_) {
6475 if (my !== bulkPresetDropdownsToken) return;
6476 }
6477 const pathSet = new Set();
6478 for (const f of diskFolders) {
6479 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6480 }
6481 for (const f of facets.folders || []) {
6482 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6483 }
6484 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
6485 const pathPrefixes = ['inbox', ...rest];
6486 const projects = [
6487 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
6488 ].sort((a, b) => a.localeCompare(b));
6489
6490 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
6491 fillBulkPresetSelect(delProjSelect, projects, true);
6492 fillBulkPresetSelect(renameFromSelect, projects, true);
6493
6494 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
6495 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
6496 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
6497 }
6498
6499 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
6500 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
6501 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
6502
6503 const btnDeletePrefix = el('btn-settings-delete-prefix');
6504 if (btnDeletePrefix) {
6505 btnDeletePrefix.onclick = async () => {
6506 const msg = el('settings-delete-prefix-msg');
6507 const prefixEl = el('settings-delete-prefix');
6508 const confirmEl = el('settings-delete-confirm');
6509 if (!hubUserCanWriteNotes()) {
6510 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6511 return;
6512 }
6513 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
6514 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6515 if (!raw) {
6516 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
6517 return;
6518 }
6519 if (conf !== 'DELETE') {
6520 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6521 return;
6522 }
6523 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
6524 try {
6525 const out = await api('/api/v1/notes/delete-by-prefix', {
6526 method: 'POST',
6527 headers: { 'Content-Type': 'application/json' },
6528 body: JSON.stringify({ path_prefix: raw }),
6529 });
6530 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6531 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6532 if (confirmEl) confirmEl.value = '';
6533 if (msg) {
6534 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6535 msg.className = 'settings-msg ok';
6536 }
6537 if (typeof showToast === 'function') {
6538 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
6539 }
6540 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6541 loadNotes();
6542 loadFacets();
6543 if (typeof loadProposals === 'function') loadProposals();
6544 void refreshBulkDeletePresetDropdowns();
6545 } catch (e) {
6546 const m = e && e.message ? String(e.message) : String(e);
6547 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6548 }
6549 });
6550 };
6551 }
6552
6553 const btnDeleteByProject = el('btn-settings-delete-by-project');
6554 if (btnDeleteByProject) {
6555 btnDeleteByProject.onclick = async () => {
6556 const msg = el('settings-delete-by-project-msg');
6557 const slugEl = el('settings-delete-project-slug');
6558 const confirmEl = el('settings-delete-project-confirm');
6559 if (!hubUserCanWriteNotes()) {
6560 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6561 return;
6562 }
6563 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
6564 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6565 if (!slug) {
6566 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
6567 return;
6568 }
6569 if (conf !== 'DELETE') {
6570 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6571 return;
6572 }
6573 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
6574 try {
6575 const out = await api('/api/v1/notes/delete-by-project', {
6576 method: 'POST',
6577 headers: { 'Content-Type': 'application/json' },
6578 body: JSON.stringify({ project: slug }),
6579 });
6580 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6581 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6582 if (confirmEl) confirmEl.value = '';
6583 if (msg) {
6584 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6585 msg.className = 'settings-msg ok';
6586 }
6587 if (typeof showToast === 'function') {
6588 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
6589 }
6590 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6591 loadNotes();
6592 loadFacets();
6593 if (typeof loadProposals === 'function') loadProposals();
6594 void refreshBulkDeletePresetDropdowns();
6595 } catch (e) {
6596 const m = e && e.message ? String(e.message) : String(e);
6597 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6598 }
6599 });
6600 };
6601 }
6602
6603 const btnRenameProject = el('btn-settings-rename-project');
6604 if (btnRenameProject) {
6605 btnRenameProject.onclick = async () => {
6606 const msg = el('settings-rename-project-msg');
6607 const fromEl = el('settings-rename-project-from');
6608 const toEl = el('settings-rename-project-to');
6609 const confirmEl = el('settings-rename-project-confirm');
6610 if (!hubUserCanWriteNotes()) {
6611 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
6612 return;
6613 }
6614 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
6615 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
6616 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6617 if (!from || !to) {
6618 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
6619 return;
6620 }
6621 if (conf !== 'RENAME') {
6622 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
6623 return;
6624 }
6625 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
6626 try {
6627 const out = await api('/api/v1/notes/rename-project', {
6628 method: 'POST',
6629 headers: { 'Content-Type': 'application/json' },
6630 body: JSON.stringify({ from, to }),
6631 });
6632 const n = out && typeof out.updated === 'number' ? out.updated : 0;
6633 if (confirmEl) confirmEl.value = '';
6634 if (msg) {
6635 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
6636 msg.className = 'settings-msg ok';
6637 }
6638 if (typeof showToast === 'function') {
6639 showToast('Renamed project on ' + n + ' note(s).', false);
6640 }
6641 if (n > 0) hubMarkSemanticIndexStale();
6642 loadNotes();
6643 loadFacets();
6644 void refreshBulkDeletePresetDropdowns();
6645 } catch (e) {
6646 const m = e && e.message ? String(e.message) : String(e);
6647 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6648 }
6649 });
6650 };
6651 }
6652
6653 const btnVaultsSave = el('btn-vaults-save');
6654 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
6655 const msg = el('vaults-save-msg');
6656 if (isHostedHubFromSettings()) {
6657 if (msg) {
6658 msg.textContent =
6659 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
6660 msg.className = 'settings-msg err';
6661 }
6662 return;
6663 }
6664 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
6665 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
6666 try {
6667 const vaults = JSON.parse(raw);
6668 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
6669 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
6670 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6671 try {
6672 const s = await api('/api/v1/settings');
6673 applySettingsPayloadToHubChrome(s);
6674 } catch (_) {}
6675 loadVaultsPanel();
6676 } catch (e) {
6677 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6678 }
6679 });
6680 };
6681 function validateVaultAccess(access) {
6682 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
6683 for (const [uid, arr] of Object.entries(access)) {
6684 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
6685 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
6686 }
6687 return null;
6688 }
6689 function validateScope(scope) {
6690 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
6691 for (const [userId, perVault] of Object.entries(scope)) {
6692 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
6693 for (const [vaultId, entry] of Object.entries(perVault)) {
6694 if (typeof entry !== 'object' || entry === null) continue;
6695 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
6696 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
6697 }
6698 }
6699 return null;
6700 }
6701 const btnVaultAccessSave = el('btn-vault-access-save');
6702 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
6703 const msg = el('vault-access-save-msg');
6704 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
6705 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
6706 try {
6707 const access = JSON.parse(raw);
6708 const err = validateVaultAccess(access);
6709 if (err) throw new Error(err);
6710 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
6711 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6712 try {
6713 const s = await api('/api/v1/settings');
6714 applySettingsPayloadToHubChrome(s);
6715 } catch (_) {}
6716 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6717 } catch (e) {
6718 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6719 }
6720 });
6721 };
6722
6723 const btnAccessFormApply = el('btn-access-form-apply');
6724 if (btnAccessFormApply) {
6725 btnAccessFormApply.onclick = () => {
6726 const msg = el('access-form-msg');
6727 const uid = getAccessFormResolvedUserId();
6728 if (!uid) {
6729 if (msg) {
6730 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
6731 msg.className = 'settings-msg err';
6732 }
6733 return;
6734 }
6735 const checked = Array.from(
6736 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
6737 ).map((c) => c.value);
6738 if (checked.length === 0) {
6739 if (msg) {
6740 msg.textContent = 'Tick at least one vault.';
6741 msg.className = 'settings-msg err';
6742 }
6743 return;
6744 }
6745 const access = parseVaultAccessFromTextarea();
6746 access[uid] = checked;
6747 const ta = el('vault-access-json');
6748 if (ta) ta.value = JSON.stringify(access, null, 2);
6749 refreshAccessRulesSummary(access);
6750 if (msg) {
6751 msg.textContent =
6752 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
6753 msg.className = 'settings-msg ok';
6754 }
6755 };
6756 }
6757
6758 const btnAccessFormRemove = el('btn-access-form-remove-user');
6759 if (btnAccessFormRemove) {
6760 btnAccessFormRemove.onclick = () => {
6761 const msg = el('access-form-msg');
6762 const uid = getAccessFormResolvedUserId();
6763 if (!uid) {
6764 if (msg) {
6765 msg.textContent = 'Choose a person to remove.';
6766 msg.className = 'settings-msg err';
6767 }
6768 return;
6769 }
6770 const access = parseVaultAccessFromTextarea();
6771 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
6772 if (msg) {
6773 msg.textContent = 'No rule for that user.';
6774 msg.className = 'settings-msg err';
6775 }
6776 return;
6777 }
6778 delete access[uid];
6779 const ta = el('vault-access-json');
6780 if (ta) ta.value = JSON.stringify(access, null, 2);
6781 refreshAccessRulesSummary(access);
6782 accessFormSyncCheckboxesFromAccessJson();
6783 if (msg) {
6784 msg.textContent =
6785 'Removed from draft rules only. Click Save vault access below to persist (required).';
6786 msg.className = 'settings-msg ok';
6787 }
6788 };
6789 }
6790
6791 const btnScopeSave = el('btn-scope-save');
6792 if (btnScopeSave) btnScopeSave.onclick = async () => {
6793 const msg = el('scope-save-msg');
6794 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
6795 const raw = (el('scope-json') && el('scope-json').value) || '{}';
6796 try {
6797 const scope = JSON.parse(raw);
6798 const err = validateScope(scope);
6799 if (err) throw new Error(err);
6800 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
6801 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6802 } catch (e) {
6803 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6804 }
6805 });
6806 };
6807
6808 const btnWorkspaceUseMe = el('btn-workspace-use-me');
6809 if (btnWorkspaceUseMe) {
6810 btnWorkspaceUseMe.onclick = async () => {
6811 const input = el('workspace-owner-input');
6812 const msg = el('workspace-save-msg');
6813 let uid =
6814 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
6815 ? String(lastBackupSettingsPayload.user_id)
6816 : '';
6817 if (!uid) {
6818 try {
6819 const s = await api('/api/v1/settings');
6820 lastBackupSettingsPayload = s;
6821 uid = s.user_id != null ? String(s.user_id) : '';
6822 } catch (e) {
6823 if (msg) {
6824 msg.textContent = e.message || 'Could not load your User ID.';
6825 msg.className = 'settings-msg err';
6826 }
6827 return;
6828 }
6829 }
6830 if (input) input.value = uid;
6831 if (msg) {
6832 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
6833 msg.className = 'settings-msg ok';
6834 }
6835 };
6836 }
6837
6838 const btnWorkspaceSave = el('btn-workspace-save');
6839 if (btnWorkspaceSave) {
6840 btnWorkspaceSave.onclick = async () => {
6841 const msg = el('workspace-save-msg');
6842 const input = el('workspace-owner-input');
6843 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
6844 try {
6845 const raw = (input && input.value) || '';
6846 const trimmed = raw.trim();
6847 const owner_user_id = trimmed === '' ? null : trimmed;
6848 await api('/api/v1/workspace', {
6849 method: 'POST',
6850 body: JSON.stringify({ owner_user_id }),
6851 });
6852 if (msg) {
6853 msg.textContent = 'Saved.';
6854 msg.className = 'settings-msg ok';
6855 }
6856 } catch (e) {
6857 if (msg) {
6858 msg.textContent = e.message || 'Save failed';
6859 msg.className = 'settings-msg err';
6860 }
6861 }
6862 });
6863 };
6864 }
6865
6866 const btnWorkspaceClear = el('btn-workspace-clear');
6867 if (btnWorkspaceClear) {
6868 btnWorkspaceClear.onclick = async () => {
6869 const msg = el('workspace-save-msg');
6870 const input = el('workspace-owner-input');
6871 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
6872 try {
6873 await api('/api/v1/workspace', {
6874 method: 'POST',
6875 body: JSON.stringify({ owner_user_id: null }),
6876 });
6877 if (input) input.value = '';
6878 if (msg) {
6879 msg.textContent = 'Cleared — each person uses their own cloud space.';
6880 msg.className = 'settings-msg ok';
6881 }
6882 } catch (e) {
6883 if (msg) {
6884 msg.textContent = e.message || 'Clear failed';
6885 msg.className = 'settings-msg err';
6886 }
6887 }
6888 });
6889 };
6890 }
6891
6892 async function loadInvitesList() {
6893 const listEl = el('invites-pending-list');
6894 if (!listEl) return;
6895 listEl.textContent = 'Loading…';
6896 try {
6897 const out = await api('/api/v1/invites');
6898 const invites = out.invites || [];
6899 if (invites.length === 0) {
6900 listEl.textContent = 'No pending invites. Create a link above.';
6901 } else {
6902 listEl.innerHTML = invites.map((inv) => {
6903 const tokenShort = inv.token.slice(0, 12) + '…';
6904 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
6905 return '<div class="team-role-row invite-row">' +
6906 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
6907 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
6908 '</div>';
6909 }).join('');
6910 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
6911 btn.onclick = async () => {
6912 const t = btn.dataset.token;
6913 if (!t) return;
6914 try {
6915 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
6916 loadInvitesList();
6917 } catch (e) {
6918 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
6919 }
6920 };
6921 });
6922 }
6923 } catch (e) {
6924 listEl.textContent = 'Could not load: ' + (e.message || '');
6925 }
6926 }
6927
6928 const btnInviteCreate = el('btn-invite-create');
6929 const inviteLinkBlock = el('invite-link-block');
6930 const inviteLinkUrl = el('invite-link-url');
6931 const inviteCreateMsg = el('invite-create-msg');
6932 if (btnInviteCreate) {
6933 btnInviteCreate.onclick = async () => {
6934 const roleSelect = el('invite-role');
6935 const role = (roleSelect && roleSelect.value) || 'editor';
6936 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
6937 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
6938 try {
6939 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
6940 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
6941 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
6942 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
6943 loadInvitesList();
6944 } catch (e) {
6945 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
6946 }
6947 });
6948 };
6949 }
6950 const btnInviteCopy = el('btn-invite-copy');
6951 if (btnInviteCopy && inviteLinkUrl) {
6952 btnInviteCopy.onclick = () => {
6953 inviteLinkUrl.select();
6954 if (navigator.clipboard && navigator.clipboard.writeText) {
6955 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
6956 if (typeof showToast === 'function') showToast('Link copied.');
6957 }).catch(() => {});
6958 }
6959 };
6960 }
6961
6962 function syncTeamAddEvaluatorMayApproveVisibility() {
6963 const wrap = el('team-add-evaluator-may-approve-wrap');
6964 const sel = el('team-role');
6965 if (!wrap || !sel) return;
6966 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
6967 }
6968 const teamRoleSelect = el('team-role');
6969 if (teamRoleSelect) {
6970 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
6971 syncTeamAddEvaluatorMayApproveVisibility();
6972 }
6973
6974 async function loadTeamRolesList() {
6975 const listEl = el('team-roles-list');
6976 if (!listEl) return;
6977 listEl.textContent = 'Loading…';
6978 try {
6979 const out = await api('/api/v1/roles');
6980 const roles = out.roles || {};
6981 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
6982 const entries = Object.entries(roles);
6983 listEl.innerHTML = '';
6984 if (entries.length === 0) {
6985 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
6986 return;
6987 }
6988 for (const [uid, role] of entries) {
6989 const row = document.createElement('div');
6990 row.className = 'team-role-row team-role-row-flex';
6991 const label = document.createElement('span');
6992 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
6993 row.appendChild(label);
6994 if (role === 'evaluator') {
6995 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
6996 const chk = document.createElement('input');
6997 chk.type = 'checkbox';
6998 chk.title = 'May approve proposals';
6999 chk.checked = Boolean(mayMap[uid]);
7000 chk.addEventListener('change', async () => {
7001 chk.disabled = true;
7002 try {
7003 await api('/api/v1/roles/evaluator-may-approve', {
7004 method: 'POST',
7005 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7006 });
7007 } catch (err) {
7008 chk.checked = !chk.checked;
7009 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7010 } finally {
7011 chk.disabled = false;
7012 }
7013 });
7014 const lab = document.createElement('label');
7015 lab.className = 'team-evaluator-approve-inline';
7016 lab.appendChild(chk);
7017 const sp = document.createElement('span');
7018 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7019 lab.appendChild(sp);
7020 row.appendChild(lab);
7021 }
7022 listEl.appendChild(row);
7023 }
7024 } catch (e) {
7025 listEl.textContent = 'Could not load: ' + (e.message || '');
7026 }
7027 }
7028
7029 const btnTeamUserUseMe = el('btn-team-user-use-me');
7030 if (btnTeamUserUseMe) {
7031 btnTeamUserUseMe.onclick = async () => {
7032 const userIdInput = el('team-user-id');
7033 const msgEl = el('team-save-msg');
7034 let uid =
7035 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7036 ? String(lastBackupSettingsPayload.user_id)
7037 : '';
7038 if (!uid) {
7039 try {
7040 const s = await api('/api/v1/settings');
7041 lastBackupSettingsPayload = s;
7042 uid = s.user_id != null ? String(s.user_id) : '';
7043 } catch (e) {
7044 if (msgEl) {
7045 msgEl.textContent = e.message || 'Could not load your User ID.';
7046 msgEl.className = 'settings-msg err';
7047 }
7048 return;
7049 }
7050 }
7051 if (userIdInput) userIdInput.value = uid;
7052 if (msgEl) {
7053 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
7054 msgEl.className = 'settings-msg';
7055 }
7056 };
7057 }
7058
7059 const btnTeamSave = el('btn-team-save');
7060 if (btnTeamSave) {
7061 btnTeamSave.onclick = async () => {
7062 const userIdInput = el('team-user-id');
7063 const roleSelect = el('team-role');
7064 const msgEl = el('team-save-msg');
7065 const userId = (userIdInput && userIdInput.value || '').trim();
7066 const role = (roleSelect && roleSelect.value) || 'editor';
7067 if (!userId) {
7068 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
7069 return;
7070 }
7071 if (msgEl) msgEl.textContent = '';
7072 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
7073 try {
7074 const body = { user_id: userId, role };
7075 if (role === 'evaluator') {
7076 const cb = el('team-add-evaluator-may-approve');
7077 body.evaluator_may_approve = Boolean(cb && cb.checked);
7078 }
7079 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
7080 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
7081 userIdInput.value = '';
7082 loadTeamRolesList();
7083 } catch (e) {
7084 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
7085 }
7086 });
7087 };
7088 }
7089
7090 const currentAccent = () => {
7091 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
7092 if (inline) return inline;
7093 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
7094 return fromCss || DEFAULT_ACCENT;
7095 };
7096 function accentStringToHex6(str) {
7097 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
7098 const t = str.trim();
7099 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
7100 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
7101 const a = t.slice(1);
7102 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
7103 }
7104 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
7105 if (m) {
7106 return (
7107 '#' +
7108 [1, 2, 3]
7109 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
7110 .join('')
7111 ).toLowerCase();
7112 }
7113 return DEFAULT_ACCENT;
7114 }
7115 function updateAccentCustomHexLabel(hex6) {
7116 const out = el('accent-custom-hex');
7117 if (out && hex6) out.textContent = String(hex6).toUpperCase();
7118 }
7119 function setAccentRuntimeOnly(hex) {
7120 if (!hex) return;
7121 document.documentElement.style.setProperty('--accent', hex);
7122 updateAccentCustomHexLabel(accentStringToHex6(hex));
7123 }
7124 let accentIroPicker = null;
7125 let accentIroSuppressChange = false;
7126 function ensureAccentIroPicker() {
7127 if (accentIroPicker) return accentIroPicker;
7128 const mount = el('accent-iro-root');
7129 const Iro = typeof window !== 'undefined' && window.iro;
7130 if (!mount || !Iro || !Iro.ColorPicker) return null;
7131 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
7132 const br = (brRaw && brRaw.trim()) || '';
7133 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
7134 accentIroPicker = new Iro.ColorPicker(mount, {
7135 width: 280,
7136 color: accentStringToHex6(currentAccent()),
7137 borderWidth: 1,
7138 borderColor,
7139 layout: [
7140 { component: Iro.ui.Box, options: {} },
7141 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
7142 ],
7143 });
7144 accentIroPicker.on('color:change', (color) => {
7145 if (accentIroSuppressChange) return;
7146 setAccentRuntimeOnly(color.hexString);
7147 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
7148 });
7149 accentIroPicker.on('input:end', () => {
7150 if (accentIroSuppressChange) return;
7151 const h = accentIroPicker.color.hexString;
7152 if (h) applyAccent(h);
7153 });
7154 return accentIroPicker;
7155 }
7156 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
7157 function setAccentPickerColor(picker, hexNorm) {
7158 if (!picker || !hexNorm) return;
7159 const col = picker.color;
7160 if (col && typeof col.set === 'function') {
7161 col.set(hexNorm);
7162 return;
7163 }
7164 if (typeof picker.setColor === 'function') {
7165 try {
7166 picker.setColor(hexNorm, { silent: true });
7167 } catch (_) {
7168 picker.setColor(hexNorm);
7169 }
7170 }
7171 }
7172 function paintAccentSwatches() {
7173 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7174 const hex = btn.dataset.accent;
7175 if (hex) btn.style.backgroundColor = hex;
7176 });
7177 }
7178 paintAccentSwatches();
7179 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7180 btn.addEventListener('click', () => {
7181 const hex = btn.dataset.accent;
7182 if (hex) {
7183 applyAccent(hex);
7184 const norm = accentStringToHex6(hex);
7185 document.querySelectorAll('.accent-swatch').forEach((b) => {
7186 const bh = b.dataset.accent;
7187 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7188 });
7189 ensureAccentIroPicker();
7190 if (accentIroPicker) {
7191 accentIroSuppressChange = true;
7192 try {
7193 setAccentPickerColor(accentIroPicker, norm);
7194 } finally {
7195 accentIroSuppressChange = false;
7196 }
7197 }
7198 updateAccentCustomHexLabel(norm);
7199 }
7200 });
7201 });
7202 ensureAccentIroPicker();
7203 function syncAccentUI() {
7204 const norm = accentStringToHex6(currentAccent());
7205 document.querySelectorAll('.accent-swatch').forEach((b) => {
7206 const bh = b.dataset.accent;
7207 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7208 });
7209 ensureAccentIroPicker();
7210 if (accentIroPicker) {
7211 accentIroSuppressChange = true;
7212 try {
7213 setAccentPickerColor(accentIroPicker, norm);
7214 } finally {
7215 accentIroSuppressChange = false;
7216 }
7217 }
7218 updateAccentCustomHexLabel(norm);
7219 }
7220 function currentTheme() {
7221 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
7222 }
7223 function syncThemeUI() {
7224 const theme = currentTheme();
7225 document.querySelectorAll('.theme-btn').forEach((btn) => {
7226 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
7227 });
7228 }
7229 function syncColorPaletteUI() {
7230 const p = currentColorPalette();
7231 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
7232 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
7233 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
7234 });
7235 }
7236 const dashboardThemeGrid = el('dashboard-theme-grid');
7237 if (dashboardThemeGrid) {
7238 dashboardThemeGrid.addEventListener('click', (ev) => {
7239 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
7240 if (!card || !dashboardThemeGrid.contains(card)) return;
7241 const pid = card.dataset.palette;
7242 if (pid == null) return;
7243 applyColorPalette(pid);
7244 syncColorPaletteUI();
7245 });
7246 }
7247 document.querySelectorAll('.theme-btn').forEach((btn) => {
7248 btn.addEventListener('click', () => {
7249 const theme = btn.dataset.theme;
7250 if (theme) {
7251 applyTheme(theme);
7252 syncThemeUI();
7253 }
7254 });
7255 });
7256 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
7257 if (scrollDashColorsBtn) {
7258 scrollDashColorsBtn.addEventListener('click', () => {
7259 const target = el('settings-dashboard-color-theme');
7260 if (target && target.scrollIntoView) {
7261 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7262 }
7263 });
7264 }
7265
7266 el('btn-settings-sync').onclick = async () => {
7267 const syncBtn = el('btn-settings-sync');
7268 const msg = el('settings-sync-msg');
7269 msg.textContent = 'Syncing…';
7270 msg.className = 'settings-msg';
7271 const s = lastBackupSettingsPayload;
7272 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
7273 const hostedPath = isHosted && s.github_connect_available;
7274 let opts = { method: 'POST' };
7275 if (hostedPath) {
7276 const slug =
7277 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
7278 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
7279 normalizeGithubRepoSlug(s.repo);
7280 if (!slug) {
7281 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
7282 msg.className = 'settings-msg err';
7283 return;
7284 }
7285 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
7286 opts.body = JSON.stringify({ repo: slug });
7287 }
7288 setButtonBusy(syncBtn, true, 'Backing up…');
7289 try {
7290 const result = await api('/api/v1/vault/sync', opts);
7291 msg.textContent = result.message || 'Done.';
7292 const initBtnOk = el('btn-vault-git-init');
7293 if (initBtnOk) initBtnOk.classList.add('hidden');
7294 if (hostedPath && s) {
7295 const refreshed = await api('/api/v1/settings');
7296 lastBackupSettingsPayload = refreshed;
7297 const vg = refreshed.vault_git || {};
7298 let gitText = 'Not configured';
7299 if (vg.enabled && vg.has_remote) {
7300 gitText = 'Configured';
7301 if (vg.auto_commit) gitText += ' (auto-commit on)';
7302 if (vg.auto_push) gitText += ', auto-push on';
7303 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7304 el('settings-git-status').textContent = gitText;
7305 const step4 = document.getElementById('setup-step-4');
7306 if (step4) {
7307 const done = !!(vg.enabled && vg.has_remote);
7308 step4.classList.toggle('setup-step-done', done);
7309 const icon = step4.querySelector('.setup-step-icon');
7310 if (icon) icon.textContent = done ? '✓' : '';
7311 }
7312 }
7313 } catch (e) {
7314 msg.textContent = e.message || 'Sync failed';
7315 msg.className = 'settings-msg err';
7316 const initBtn = el('btn-vault-git-init');
7317 if (initBtn) {
7318 const st = lastBackupSettingsPayload;
7319 const hosted =
7320 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
7321 const needInit =
7322 e.code === 'GIT_NOT_INITIALIZED' ||
7323 /not a Git repository/i.test(e.message || '');
7324 initBtn.classList.toggle('hidden', hosted || !needInit);
7325 }
7326 } finally {
7327 setButtonBusy(syncBtn, false);
7328 const st = lastBackupSettingsPayload;
7329 if (syncBtn && st) {
7330 const vg = st.vault_git || {};
7331 const vd = st.vault_path_display || '';
7332 const ih = (vd + '').toLowerCase() === 'canister';
7333 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
7334 }
7335 }
7336 };
7337 const btnVaultGitInit = el('btn-vault-git-init');
7338 if (btnVaultGitInit) {
7339 btnVaultGitInit.onclick = async () => {
7340 const msg = el('settings-sync-msg');
7341 msg.textContent = 'Initializing Git…';
7342 msg.className = 'settings-msg';
7343 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
7344 try {
7345 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
7346 msg.textContent = out.message || 'Git initialized. Try Back up now.';
7347 msg.className = 'settings-msg ok';
7348 btnVaultGitInit.classList.add('hidden');
7349 } catch (e) {
7350 msg.textContent = e.message || 'Git init failed';
7351 msg.className = 'settings-msg err';
7352 }
7353 });
7354 };
7355 }
7356 const saveSetupBtn = el('btn-settings-save');
7357 if (saveSetupBtn) {
7358 saveSetupBtn.onclick = async () => {
7359 const msg = el('settings-save-msg');
7360 if (msg) {
7361 msg.textContent = 'Saving…';
7362 msg.className = 'settings-msg';
7363 }
7364 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
7365 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
7366 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
7367 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
7368 try {
7369 await api('/api/v1/setup', {
7370 method: 'POST',
7371 body: JSON.stringify({
7372 vault_path: vault_path || undefined,
7373 vault_git: { enabled, remote: remote || undefined },
7374 }),
7375 });
7376 const successText = 'Saved. Config applied.' + (vault_path !== undefined ? ' If you changed the vault path, run Re-index or restart the Hub so search uses the new path.' : '');
7377 if (msg) {
7378 msg.textContent = successText;
7379 msg.className = 'settings-msg ok';
7380 }
7381 if (typeof showToast === 'function') showToast('Setup saved.');
7382 api('/api/v1/settings').then((s) => {
7383 const vd = s.vault_path_display || '—';
7384 const isHostedNow = (vd + '').toLowerCase() === 'canister';
7385 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
7386 el('settings-vault-display').textContent = vd;
7387 const configureSection = el('settings-configure-backup-section');
7388 const configureHr = el('settings-hr-configure');
7389 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
7390 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
7391 const vg = s.vault_git || {};
7392 let gitText = 'Not configured';
7393 if (vg.enabled && vg.has_remote) {
7394 gitText = 'Configured';
7395 if (vg.auto_commit) gitText += ' (auto-commit on)';
7396 if (vg.auto_push) gitText += ', auto-push on';
7397 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7398 el('settings-git-status').textContent = gitText;
7399 const syncBtn = el('btn-settings-sync');
7400 const isAdmin = s.role === 'admin';
7401 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
7402 if (msg) {
7403 msg.textContent = successText;
7404 msg.className = 'settings-msg ok';
7405 }
7406 }).catch(() => {});
7407 } catch (e) {
7408 const errMsg = e.message || 'Save failed';
7409 if (msg) {
7410 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
7411 ? 'Only admins can save setup. Your role is shown under Status above.'
7412 : errMsg;
7413 msg.className = 'settings-msg err';
7414 }
7415 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
7416 }
7417 });
7418 };
7419 }
7420
7421 function defaultFullPath() {
7422 const sel = el('full-path-folder');
7423 const folder =
7424 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7425 return folder + '/note-' + Date.now() + '.md';
7426 }
7427
7428 let fullPathFolderLoadToken = 0;
7429 async function refreshFullPathFolderSelect() {
7430 const sel = el('full-path-folder');
7431 if (!sel || !token) return;
7432 const my = ++fullPathFolderLoadToken;
7433 let folders = ['inbox'];
7434 try {
7435 const data = await api('/api/v1/vault/folders');
7436 if (my !== fullPathFolderLoadToken) return;
7437 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7438 } catch (_) {
7439 if (my !== fullPathFolderLoadToken) return;
7440 }
7441 lastVaultFoldersForCreate = folders.slice();
7442 const preserve = sel.value;
7443 sel.innerHTML = '';
7444 for (const f of folders) {
7445 const o = document.createElement('option');
7446 o.value = f;
7447 o.textContent = f;
7448 sel.appendChild(o);
7449 }
7450 const custom = document.createElement('option');
7451 custom.value = '__custom__';
7452 custom.textContent = 'Custom (type path below)';
7453 sel.appendChild(custom);
7454 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7455 else sel.value = folders[0] || 'inbox';
7456 refreshFullCreateSubrootSelect();
7457 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
7458 }
7459
7460 let importVaultFolderLoadToken = 0;
7461 async function refreshImportVaultFolderSelect() {
7462 const sel = el('import-vault-folder');
7463 if (!sel || !token) return;
7464 const my = ++importVaultFolderLoadToken;
7465 let folders = ['inbox'];
7466 try {
7467 const data = await api('/api/v1/vault/folders');
7468 if (my !== importVaultFolderLoadToken) return;
7469 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7470 } catch (_) {
7471 if (my !== importVaultFolderLoadToken) return;
7472 }
7473 lastVaultFoldersForCreate = folders.slice();
7474 const preserve = sel.value;
7475 sel.innerHTML = '';
7476 for (const f of folders) {
7477 const o = document.createElement('option');
7478 o.value = f;
7479 o.textContent = f;
7480 sel.appendChild(o);
7481 }
7482 const custom = document.createElement('option');
7483 custom.value = '__custom__';
7484 custom.textContent = 'Custom (type path below)';
7485 sel.appendChild(custom);
7486 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7487 else sel.value = folders[0] || 'inbox';
7488 refreshImportCreateSubrootSelect();
7489 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
7490 }
7491
7492 function syncFolderSelectToPathInput() {
7493 const pathInput = el('full-path');
7494 const sel = el('full-path-folder');
7495 if (!pathInput || !sel) return;
7496 const p = pathInput.value.trim();
7497 if (!p) return;
7498 let best = '__custom__';
7499 let bestLen = -1;
7500 for (const opt of sel.options) {
7501 const v = opt.value;
7502 if (v === '__custom__') continue;
7503 if (p === v || p.startsWith(v + '/')) {
7504 if (v.length > bestLen) {
7505 best = v;
7506 bestLen = v.length;
7507 }
7508 }
7509 }
7510 sel.value = bestLen >= 0 ? best : '__custom__';
7511 }
7512
7513 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
7514 function syncFullProjectFromPath() {
7515 const pi = el('full-path');
7516 const fp = el('full-project');
7517 if (!pi || !fp) return;
7518 const slug = projectSlugFromProjectsPath(pi.value.trim());
7519 if (slug) {
7520 fp.value = slug;
7521 fp.readOnly = true;
7522 fp.title = 'Derived from vault path projects/' + slug + '/';
7523 } else {
7524 fp.readOnly = false;
7525 fp.removeAttribute('title');
7526 }
7527 updateFullPathProjectTypoHint();
7528 }
7529
7530 function updateFullPathProjectTypoHint() {
7531 const pi = el('full-path');
7532 const hint = el('full-path-project-typo-hint');
7533 const fixBtn = el('btn-full-path-fix-typo');
7534 if (!pi || !hint) return;
7535 const raw = pi.value.trim();
7536 const sug = projectsPathTypoSuggestion(raw);
7537 if (sug) {
7538 hint.textContent =
7539 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
7540 hint.className = 'muted small detail-project-hint warn';
7541 hint.classList.remove('hidden');
7542 if (fixBtn) {
7543 fixBtn.classList.remove('hidden');
7544 fixBtn.onclick = () => {
7545 pi.value = sug;
7546 syncFolderSelectToPathInput();
7547 syncFullCreatePickersFromPath();
7548 syncFullProjectFromPath();
7549 scheduleFullCreateSimilarHint();
7550 };
7551 }
7552 } else {
7553 hint.textContent = '';
7554 hint.className = 'muted small detail-project-hint hidden';
7555 hint.classList.add('hidden');
7556 if (fixBtn) {
7557 fixBtn.classList.add('hidden');
7558 fixBtn.onclick = null;
7559 }
7560 }
7561 }
7562
7563 const fullPathFolderEl = () => el('full-path-folder');
7564 const fullPathInputEl = () => el('full-path');
7565 if (fullPathFolderEl() && fullPathInputEl()) {
7566 fullPathFolderEl().addEventListener('change', () => {
7567 const sel = fullPathFolderEl();
7568 if (!sel || sel.value === '__custom__') return;
7569 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
7570 syncFullCreatePickersFromPath();
7571 syncFullProjectFromPath();
7572 updateFullPathProjectTypoHint();
7573 scheduleFullCreateSimilarHint();
7574 });
7575 fullPathInputEl().addEventListener('input', () => {
7576 syncFolderSelectToPathInput();
7577 syncFullCreatePickersFromPath();
7578 syncFullProjectFromPath();
7579 updateFullPathProjectTypoHint();
7580 scheduleFullCreateSimilarHint();
7581 });
7582 fullPathInputEl().addEventListener('change', () => {
7583 syncFullCreatePickersFromPath();
7584 updateFullCreateSimilarInlineHint();
7585 });
7586 }
7587
7588 const fullCreateProjectSlugEl = el('full-create-project-slug');
7589 const fullCreateProjectSubEl = el('full-create-project-subroot');
7590 if (fullCreateProjectSlugEl) {
7591 fullCreateProjectSlugEl.addEventListener('change', () => {
7592 refreshFullCreateSubrootSelect();
7593 updateFullCreatePathLayoutVisibility();
7594 const v = fullCreateProjectSlugEl.value;
7595 const pi = el('full-path');
7596 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
7597 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
7598 syncFolderSelectToPathInput();
7599 syncFullProjectFromPath();
7600 updateFullPathProjectTypoHint();
7601 scheduleFullCreateSimilarHint();
7602 });
7603 }
7604 if (fullCreateProjectSubEl) {
7605 fullCreateProjectSubEl.addEventListener('change', () => {
7606 composeFullPathFromCreatePickers();
7607 syncFolderSelectToPathInput();
7608 syncFullProjectFromPath();
7609 updateFullPathProjectTypoHint();
7610 scheduleFullCreateSimilarHint();
7611 });
7612 }
7613
7614 const importCreateProjectSlugEl = el('import-create-project-slug');
7615 const importCreateProjectSubEl = el('import-create-project-subroot');
7616 const importVaultFolderEl = el('import-vault-folder');
7617 const importOutputDirEl = el('import-output-dir');
7618 if (importVaultFolderEl) {
7619 importVaultFolderEl.addEventListener('change', () => {
7620 const sel = importVaultFolderEl;
7621 const out = el('import-output-dir');
7622 if (!sel || !out || sel.value === '__custom__') return;
7623 out.value = sel.value;
7624 syncImportPickersFromOutputDir();
7625 });
7626 }
7627 if (importOutputDirEl) {
7628 importOutputDirEl.addEventListener('input', () => {
7629 syncImportFolderSelectToOutputDir();
7630 syncImportPickersFromOutputDir();
7631 });
7632 }
7633 if (importCreateProjectSlugEl) {
7634 importCreateProjectSlugEl.addEventListener('change', () => {
7635 refreshImportCreateSubrootSelect();
7636 updateImportPathLayoutVisibility();
7637 const v = importCreateProjectSlugEl.value;
7638 const out = el('import-output-dir');
7639 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
7640 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
7641 const sel = el('import-vault-folder');
7642 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7643 }
7644 syncImportFolderSelectToOutputDir();
7645 syncImportPickersFromOutputDir();
7646 });
7647 }
7648 if (importCreateProjectSubEl) {
7649 importCreateProjectSubEl.addEventListener('change', () => {
7650 composeImportOutputDirFromPickers();
7651 syncImportFolderSelectToOutputDir();
7652 syncImportPickersFromOutputDir();
7653 });
7654 }
7655
7656 document.querySelectorAll('.modal-tab').forEach((t) => {
7657 t.onclick = () => {
7658 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
7659 t.classList.add('active');
7660 const tab = t.dataset.createTab;
7661 el('create-quick').classList.toggle('hidden', tab !== 'quick');
7662 el('create-full').classList.toggle('hidden', tab !== 'full');
7663 if (tab === 'full') {
7664 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
7665 void (async () => {
7666 await refreshFullPathFolderSelect();
7667 if (!lastHubFacets) {
7668 try {
7669 lastHubFacets = await fetchFacetsResolved();
7670 } catch (_) {}
7671 }
7672 hydrateFullCreateProjectSlugSelect(lastHubFacets);
7673 const pi = el('full-path');
7674 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
7675 else syncFolderSelectToPathInput();
7676 syncFullCreatePickersFromPath();
7677 syncFullProjectFromPath();
7678 updateFullPathProjectTypoHint();
7679 updateFullCreateSimilarInlineHint();
7680 })();
7681 }
7682 };
7683 });
7684
7685 el('btn-quick-save').onclick = async () => {
7686 const quickBtn = el('btn-quick-save');
7687 const body = el('quick-body').value.trim();
7688 const msg = el('create-msg-quick');
7689 if (!body) {
7690 msg.textContent = 'Enter some text.';
7691 msg.className = 'create-msg err';
7692 return;
7693 }
7694 const projectRaw = el('quick-project').value.trim();
7695 const pslug = normSlug(projectRaw);
7696 const today = ymd(new Date());
7697 const slug = 'hub_' + Date.now();
7698 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
7699 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
7700 await withButtonBusy(quickBtn, 'Saving…', async () => {
7701 try {
7702 await api('/api/v1/notes', {
7703 method: 'POST',
7704 body: stringifyNotePostPayload(path, body, {
7705 source: 'hub',
7706 date: today,
7707 title,
7708 ...(pslug && { project: pslug }),
7709 }),
7710 });
7711 hubMarkSemanticIndexStale();
7712 msg.textContent = 'Saved: ' + path;
7713 msg.className = 'create-msg ok';
7714 el('quick-body').value = '';
7715 loadFacets();
7716 loadNotes();
7717 closeCreateModal();
7718 } catch (e) {
7719 msg.textContent = e.message;
7720 msg.className = 'create-msg err';
7721 }
7722 });
7723 };
7724
7725 async function submitFullCreateNote() {
7726 const fullBtn = el('btn-full-save');
7727 const notePath = el('full-path').value.trim();
7728 const pathProjFull = projectSlugFromProjectsPath(notePath);
7729 const msg = el('create-msg-full');
7730 if (!notePath) {
7731 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
7732 msg.className = 'create-msg err';
7733 return;
7734 }
7735 const pathTypoSug = projectsPathTypoSuggestion(notePath);
7736 if (pathTypoSug) {
7737 msg.textContent =
7738 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
7739 pathTypoSug;
7740 msg.className = 'create-msg err';
7741 return;
7742 }
7743 if (!notePath.endsWith('.md')) {
7744 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
7745 msg.className = 'create-msg err';
7746 return;
7747 }
7748 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
7749 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
7750 const dest = notePath.replace(/\\/g, '/');
7751 if (src === dest) {
7752 msg.textContent =
7753 'When duplicating, pick a different path than the original (same path would overwrite the original).';
7754 msg.className = 'create-msg err';
7755 return;
7756 }
7757 }
7758 const slugFromPath = projectSlugFromProjectsPath(notePath);
7759 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
7760 const similarGuess =
7761 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
7762 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
7763 : null;
7764 if (similarGuess) {
7765 openFullCreateSimilarModal(notePath, similarGuess);
7766 return;
7767 }
7768 fullCreateSimilarOverrideOnce = false;
7769 const title = el('full-title').value.trim();
7770 const body = el('full-body').value;
7771 const project = pathProjFull || el('full-project').value.trim();
7772 const tags = el('full-tags').value.trim();
7773 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
7774 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
7775 const entityRaw = el('full-entity') && el('full-entity').value.trim();
7776 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
7777 const episode = el('full-episode') && el('full-episode').value.trim();
7778 const followsRaw = el('full-follows') && el('full-follows').value.trim();
7779 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
7780 const fm = {
7781 date: dateVal,
7782 ...(title && { title }),
7783 ...(project && { project }),
7784 ...(tags && { tags }),
7785 ...(causalChain && { causal_chain_id: causalChain }),
7786 ...(entity && entity.length && { entity }),
7787 ...(episode && { episode_id: episode }),
7788 ...(follows && { follows }),
7789 };
7790 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
7791 await withButtonBusy(fullBtn, savingLabel, async () => {
7792 try {
7793 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
7794 hubMarkSemanticIndexStale();
7795 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
7796 msg.className = 'create-msg ok';
7797 const dupSrc = pendingDuplicateDeleteSource;
7798 const delChk = el('duplicate-delete-after-save');
7799 const shouldDeleteOriginal =
7800 dupSrc &&
7801 dupSrc.path &&
7802 delChk &&
7803 delChk.checked &&
7804 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
7805 if (shouldDeleteOriginal) {
7806 try {
7807 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
7808 if (typeof showToast === 'function') showToast('Original note deleted');
7809 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
7810 const bcb = el('btn-detail-copy-body');
7811 if (bcb) bcb.classList.add('hidden');
7812 } catch (delErr) {
7813 if (typeof showToast === 'function') {
7814 showToast(
7815 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
7816 true,
7817 );
7818 }
7819 }
7820 }
7821 void refreshFullPathFolderSelect().then(() => {
7822 el('full-path').value = defaultFullPath();
7823 syncFolderSelectToPathInput();
7824 syncFullCreatePickersFromPath();
7825 syncFullProjectFromPath();
7826 updateFullCreateSimilarInlineHint();
7827 });
7828 el('full-title').value = '';
7829 el('full-body').value = '';
7830 el('full-project').value = '';
7831 el('full-tags').value = '';
7832 if (el('full-date')) el('full-date').value = '';
7833 if (el('full-causal-chain')) el('full-causal-chain').value = '';
7834 if (el('full-entity')) el('full-entity').value = '';
7835 if (el('full-episode')) el('full-episode').value = '';
7836 if (el('full-follows')) el('full-follows').value = '';
7837 loadFacets();
7838 loadNotes();
7839 closeCreateModal();
7840 } catch (e) {
7841 msg.textContent = e.message;
7842 msg.className = 'create-msg err';
7843 }
7844 });
7845 }
7846
7847 el('btn-full-save').onclick = () => {
7848 void submitFullCreateNote();
7849 };
7850
7851 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
7852 const modalSimilarClose = el('modal-create-similar-project-close');
7853 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
7854 const btnSimilarKeep = el('btn-modal-create-similar-keep');
7855 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
7856 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
7857 if (btnSimilarUseExisting) {
7858 btnSimilarUseExisting.onclick = () => {
7859 const path = fullCreateSimilarModalPendingPath;
7860 const slug = fullCreateSimilarModalSuggestedSlug;
7861 closeFullCreateSimilarModal();
7862 if (path && slug) {
7863 const pi = el('full-path');
7864 if (pi) {
7865 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
7866 syncFolderSelectToPathInput();
7867 syncFullCreatePickersFromPath();
7868 syncFullProjectFromPath();
7869 updateFullPathProjectTypoHint();
7870 updateFullCreateSimilarInlineHint();
7871 }
7872 }
7873 fullCreateSimilarOverrideOnce = false;
7874 void submitFullCreateNote();
7875 };
7876 }
7877 if (btnSimilarKeep) {
7878 btnSimilarKeep.onclick = () => {
7879 closeFullCreateSimilarModal();
7880 fullCreateSimilarOverrideOnce = true;
7881 void submitFullCreateNote();
7882 };
7883 }
7884
7885 function formatDetailReadBody(body, fm) {
7886 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
7887 const keys = Object.keys(o);
7888 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
7889 if (keys.length === 0 && hubUserCanWriteNotes()) {
7890 text +=
7891 '\n\n—\nNo metadata is stored for this file on the server yet (common for older hosted notes). Hosted Hub uses the same read view as self-hosted: after you Edit → Save once, the JSON block here fills with keys like title, tags, date, and provenance—same idea as on localhost. Overview and Quick tags then pick that up. To fix many notes at once from a computer, use `npm run resave:hosted-empty-fm` or `node scripts/resave-hosted-empty-frontmatter.mjs` (set `KNOWTATION_HUB_TOKEN` per `scripts/resave-hosted-empty-frontmatter.mjs` header).';
7892 }
7893 return text;
7894 }
7895
7896 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
7897 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
7898
7899 function videoExtToMime(url) {
7900 try {
7901 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
7902 return VIDEO_MIME_MAP[ext] || 'video/mp4';
7903 } catch (_) {
7904 var clean = url.split('?')[0].split('#')[0];
7905 var ext2 = clean.split('.').pop().toLowerCase();
7906 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
7907 }
7908 }
7909
7910 /**
7911 * Ensure standalone video URL lines are surrounded by blank lines in the raw
7912 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
7913 * mode joins adjacent lines (e.g. a video URL followed immediately by image
7914 * markdown) into a single <p>, which prevents the video-URL regex from matching.
7915 */
7916 function isolateVideoUrlLines(md) {
7917 // Match any line whose entire content is a bare https video URL.
7918 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
7919 // and after so marked always puts the URL in its own paragraph.
7920 return md.replace(
7921 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
7922 '\n$1$2\n'
7923 );
7924 }
7925
7926 /**
7927 * Replace bare video URLs (on their own line) with <video> elements.
7928 * Handles two forms that marked produces for a bare URL on its own paragraph:
7929 * 1. GFM autolink: <p><a href="URL">URL</a></p>
7930 * 2. Plain text: <p>URL</p>
7931 * Runs before DOMPurify so the sanitiser validates the output.
7932 */
7933 function expandVideoUrls(html) {
7934 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
7935
7936 // GFM autolink form: <p><a href="URL">...</a></p>
7937 var result = html.replace(
7938 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
7939 function (match, url) {
7940 if (!VIDEO_EXT_PAT.test(url)) return match;
7941 var mime = videoExtToMime(url);
7942 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
7943 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
7944 'Your browser does not support embedded video.</video>';
7945 }
7946 );
7947
7948 // Plain text form: <p>URL</p>
7949 result = result.replace(
7950 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
7951 function (match, url) {
7952 if (!VIDEO_EXT_PAT.test(url)) return match;
7953 var mime = videoExtToMime(url);
7954 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
7955 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
7956 'Your browser does not support embedded video.</video>';
7957 }
7958 );
7959
7960 return result;
7961 }
7962
7963 var SANITIZE_OPTS_NOTE = {
7964 ADD_TAGS: ['details', 'summary', 'video', 'source'],
7965 ADD_ATTR: ['controls', 'preload', 'type'],
7966 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
7967 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
7968 };
7969
7970 /**
7971 * Render markdown text as sanitised HTML.
7972 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
7973 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
7974 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
7975 */
7976 var _imageProxyToken = null;
7977 var _imageProxyTokenExp = 0;
7978
7979 async function getImageProxyToken() {
7980 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
7981 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
7982 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
7983 if (!jwt) return '';
7984 try {
7985 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
7986 headers: { authorization: 'Bearer ' + jwt },
7987 });
7988 if (!res.ok) return '';
7989 var data = await res.json();
7990 _imageProxyToken = data.token || '';
7991 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
7992 return _imageProxyToken;
7993 } catch (_) { return ''; }
7994 }
7995
7996 /**
7997 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
7998 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
7999 * Falls back to no rewrite if no cached image token is available yet.
8000 */
8001 function rewriteGitHubImageUrls(html) {
8002 var tok = _imageProxyToken || '';
8003 if (!tok) {
8004 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8005 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8006 }
8007 if (!tok) return html;
8008 var encodedTok = encodeURIComponent(tok);
8009 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8010 return html.replace(
8011 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8012 function (match, pre, rest) {
8013 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8014 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8015 }
8016 );
8017 }
8018
8019 function renderNoteMarkdownHtml(md) {
8020 try {
8021 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8022 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8023 var withVideo = expandVideoUrls(raw);
8024 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8025 return rewriteGitHubImageUrls(sanitised);
8026 }
8027 } catch (_) { /* fall through */ }
8028 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8029 }
8030
8031 /**
8032 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8033 */
8034 function buildNoteReadHtml(body, fm) {
8035 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8036 const keys = Object.keys(o);
8037 const bodyHtml = renderNoteMarkdownHtml(body || '');
8038 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8039 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8040 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
8041 : '';
8042 return (
8043 bodyHtml +
8044 '<details class="note-meta-block">' +
8045 '<summary>Metadata</summary>' +
8046 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
8047 emptyNote +
8048 '</details>'
8049 );
8050 }
8051
8052 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
8053 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
8054 'absolute_path',
8055 'body',
8056 'body_length',
8057 'byte_offset',
8058 'byte_offsets',
8059 'frontmatter',
8060 'line_range',
8061 'line_ranges',
8062 'mcp_resource_uri',
8063 'provider_payload',
8064 'raw_canister_payload',
8065 'resource_uri',
8066 'section_body',
8067 'section_body_length',
8068 'snippet',
8069 'snippets',
8070 ]);
8071
8072 function normalizeSectionSourcePathForUi(path) {
8073 const value = String(path || '').trim();
8074 if (!value) return '';
8075 if (value.includes('\\') || value.includes('\0')) return '';
8076 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
8077 if (value.split('/').some((part) => part === '..')) return '';
8078 return value;
8079 }
8080
8081 function sectionSourceEndpointForPath(path) {
8082 return '/api/v1/section-source?path=' + encodeURIComponent(path);
8083 }
8084
8085 function sectionSourcePayloadHasForbiddenKeys(value) {
8086 if (!value || typeof value !== 'object') return false;
8087 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
8088 for (const [key, child] of Object.entries(value)) {
8089 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
8090 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
8091 }
8092 return false;
8093 }
8094
8095 function normalizeSectionSourceForRender(data) {
8096 if (!data || typeof data !== 'object' || Array.isArray(data)) {
8097 throw new Error('INVALID_SECTION_SOURCE');
8098 }
8099 if (sectionSourcePayloadHasForbiddenKeys(data)) {
8100 throw new Error('INVALID_SECTION_SOURCE');
8101 }
8102 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
8103 throw new Error('INVALID_SECTION_SOURCE');
8104 }
8105 return {
8106 schema: SECTION_SOURCE_SCHEMA,
8107 path: String(data.path || ''),
8108 title: String(data.title || ''),
8109 truncated: data.truncated === true,
8110 sections: data.sections.map((section) => {
8111 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
8112 const normalized = {
8113 section_id: String(item.section_id || ''),
8114 heading_id: String(item.heading_id || ''),
8115 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
8116 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
8117 heading_text: String(item.heading_text || ''),
8118 child_section_ids: Array.isArray(item.child_section_ids)
8119 ? item.child_section_ids.map((childId) => String(childId))
8120 : [],
8121 body_available: item.body_available === true,
8122 body_returned: item.body_returned === true,
8123 snippet_returned: item.snippet_returned === true,
8124 };
8125 if (normalized.body_returned || normalized.snippet_returned) {
8126 throw new Error('INVALID_SECTION_SOURCE');
8127 }
8128 return normalized;
8129 }),
8130 };
8131 }
8132
8133 function resetDetailSectionSourceState() {
8134 hubSectionSourceSeq += 1;
8135 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
8136 }
8137
8138 function setSectionSourcePanelState(panel, state, message) {
8139 panel.className = 'section-source-panel section-source-panel-' + state;
8140 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
8141 panel.setAttribute('aria-label', 'Body-free section list');
8142 panel.setAttribute('aria-live', 'polite');
8143 panel.replaceChildren();
8144 const text = document.createElement('p');
8145 text.className = 'section-source-state';
8146 text.textContent = message;
8147 panel.appendChild(text);
8148 }
8149
8150 function sectionSourceErrorMessage(error) {
8151 const code = error && error.code ? String(error.code) : '';
8152 const message = error && error.message ? String(error.message) : '';
8153 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
8154 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
8155 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
8156 if (message === 'Unauthorized') return 'Sign in to view sections.';
8157 return 'Sections are unavailable right now.';
8158 }
8159
8160 function appendSectionSourceDebugRow(list, labelText, valueText) {
8161 const label = document.createElement('dt');
8162 label.textContent = labelText;
8163 const value = document.createElement('dd');
8164 value.textContent = valueText;
8165 list.append(label, value);
8166 }
8167
8168 function renderSectionSourceData(panel, source) {
8169 panel.className = 'section-source-panel';
8170 panel.setAttribute('role', 'region');
8171 panel.setAttribute('aria-label', 'Body-free section list');
8172 panel.setAttribute('aria-live', 'polite');
8173 panel.replaceChildren();
8174
8175 const header = document.createElement('div');
8176 header.className = 'section-source-header';
8177 const title = document.createElement('h3');
8178 title.textContent = 'Sections';
8179 const meta = document.createElement('p');
8180 meta.className = 'muted small';
8181 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
8182 header.append(title, meta);
8183 panel.appendChild(header);
8184
8185 if (source.truncated) {
8186 const truncated = document.createElement('p');
8187 truncated.className = 'section-source-state section-source-truncated';
8188 truncated.textContent = 'Section list is capped for display.';
8189 panel.appendChild(truncated);
8190 }
8191
8192 if (source.sections.length === 0) {
8193 const empty = document.createElement('p');
8194 empty.className = 'section-source-state';
8195 empty.textContent = 'No headings are available for this note.';
8196 panel.appendChild(empty);
8197 return;
8198 }
8199
8200 const list = document.createElement('ol');
8201 list.className = 'section-source-list';
8202 for (const section of source.sections) {
8203 const item = document.createElement('li');
8204 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
8205
8206 const heading = document.createElement('p');
8207 heading.className = 'section-source-heading';
8208 const levelBadge = document.createElement('span');
8209 levelBadge.className = 'section-source-level-label';
8210 levelBadge.textContent = 'H' + section.level;
8211 const headingText = document.createElement('span');
8212 headingText.className = 'section-source-heading-text';
8213 headingText.textContent = section.heading_text || '(Untitled section)';
8214 heading.append(levelBadge, headingText);
8215 item.appendChild(heading);
8216
8217 const detail = document.createElement('p');
8218 detail.className = 'section-source-detail muted small';
8219 detail.textContent = 'Heading level: H' + section.level;
8220 item.appendChild(detail);
8221
8222 const pathLine = document.createElement('p');
8223 pathLine.className = 'section-source-path muted small';
8224 pathLine.textContent =
8225 'Heading path: ' +
8226 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
8227 item.appendChild(pathLine);
8228
8229 const childLine = document.createElement('p');
8230 childLine.className = 'section-source-children muted small';
8231 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
8232 item.appendChild(childLine);
8233
8234 const debugDetails = document.createElement('details');
8235 debugDetails.className = 'section-source-debug muted small';
8236 const debugSummary = document.createElement('summary');
8237 debugSummary.textContent = 'IDs';
8238 const debugList = document.createElement('dl');
8239 debugList.className = 'section-source-debug-list';
8240 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
8241 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
8242 appendSectionSourceDebugRow(
8243 debugList,
8244 'Child IDs',
8245 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
8246 );
8247 debugDetails.append(debugSummary, debugList);
8248 item.appendChild(debugDetails);
8249
8250 list.appendChild(item);
8251 }
8252 panel.appendChild(list);
8253 }
8254
8255 async function loadSectionSourceForCurrentNote(actionsEl, button) {
8256 let panel = actionsEl.querySelector('[data-section-source-panel]');
8257 if (!panel) {
8258 panel = document.createElement('div');
8259 panel.dataset.sectionSourcePanel = 'true';
8260 actionsEl.appendChild(panel);
8261 }
8262 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
8263 if (!path) {
8264 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
8265 return;
8266 }
8267 const seq = ++hubSectionSourceSeq;
8268 const openPath = currentOpenNote.path;
8269 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
8270 if (button) {
8271 button.disabled = true;
8272 button.setAttribute('aria-expanded', 'true');
8273 }
8274 try {
8275 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
8276 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8277 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
8278 } catch (error) {
8279 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8280 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
8281 } finally {
8282 if (button && currentOpenNote && currentOpenNote.path === openPath) {
8283 button.disabled = false;
8284 }
8285 }
8286 }
8287
8288 function toggleSectionSourcePanel(actionsEl, button) {
8289 const panel = actionsEl.querySelector('[data-section-source-panel]');
8290 if (panel) {
8291 hubSectionSourceSeq += 1;
8292 panel.remove();
8293 if (button) button.setAttribute('aria-expanded', 'false');
8294 return;
8295 }
8296 void loadSectionSourceForCurrentNote(actionsEl, button);
8297 }
8298
8299 function createSectionSourceButton(actionsEl) {
8300 const sectionBtn = document.createElement('button');
8301 sectionBtn.type = 'button';
8302 sectionBtn.textContent = 'Sections';
8303 sectionBtn.className = 'btn-section-source';
8304 sectionBtn.setAttribute('aria-expanded', 'false');
8305 sectionBtn.setAttribute('aria-controls', 'detail-actions');
8306 sectionBtn.title = 'Show body-free section headings for this note';
8307 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
8308 return sectionBtn;
8309 }
8310
8311 function switchNoteToReadMode() {
8312 if (!currentOpenNote) return;
8313 resetDetailSectionSourceState();
8314 teardownDetailEditBodyLayout();
8315 const bodyEl = el('detail-body');
8316 const actionsEl = el('detail-actions');
8317 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
8318 bodyEl.className = 'note-rendered-body';
8319 actionsEl.innerHTML = '';
8320 attachNoteDetailReadActions(actionsEl);
8321 const bcbRead = el('btn-detail-copy-body');
8322 if (bcbRead) bcbRead.classList.remove('hidden');
8323 }
8324
8325 async function deleteOpenNote() {
8326 if (!currentOpenNote) return;
8327 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
8328 const p = currentOpenNote.path;
8329 try {
8330 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
8331 if (typeof showToast === 'function') showToast('Note deleted');
8332 hubMarkSemanticIndexStale();
8333 currentOpenNote = null;
8334 currentNotePathForCopy = '';
8335 resetDetailSectionSourceState();
8336 teardownDetailEditBodyLayout();
8337 hideDetailPanelChrome();
8338 el('btn-copy-path').classList.add('hidden');
8339 const bcbDel = el('btn-detail-copy-body');
8340 if (bcbDel) bcbDel.classList.add('hidden');
8341 loadNotes();
8342 loadFacets();
8343 } catch (e) {
8344 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
8345 }
8346 }
8347
8348 function attachNoteDetailReadActions(actionsEl) {
8349 const exportBtn = document.createElement('button');
8350 exportBtn.type = 'button';
8351 exportBtn.textContent = 'Export';
8352 exportBtn.onclick = () => exportCurrentNote('md');
8353 const sectionBtn = createSectionSourceButton(actionsEl);
8354
8355 if (hubUserCanWriteNotes()) {
8356 const editBtn = document.createElement('button');
8357 editBtn.type = 'button';
8358 editBtn.textContent = 'Edit';
8359 editBtn.onclick = () => switchNoteToEditMode();
8360 const dupBtn = document.createElement('button');
8361 dupBtn.type = 'button';
8362 dupBtn.textContent = 'Duplicate…';
8363 dupBtn.title =
8364 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
8365 dupBtn.onclick = () => {
8366 void openDuplicateNoteModal();
8367 };
8368 const proposeBtn = document.createElement('button');
8369 proposeBtn.type = 'button';
8370 proposeBtn.textContent = 'Propose change';
8371 proposeBtn.onclick = () => {
8372 if (!currentOpenNote) return;
8373 openCreateProposalModal({
8374 path: currentOpenNote.path,
8375 body: currentOpenNote.body || '',
8376 fromNote: true,
8377 });
8378 };
8379 const delBtn = document.createElement('button');
8380 delBtn.type = 'button';
8381 delBtn.textContent = 'Delete';
8382 delBtn.onclick = () => deleteOpenNote();
8383 if (hubHasMultipleVaultsForCopy()) {
8384 const copyVaultBtn = document.createElement('button');
8385 copyVaultBtn.type = 'button';
8386 copyVaultBtn.textContent = 'Copy to vault…';
8387 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
8388 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
8389 } else {
8390 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
8391 }
8392 return;
8393 }
8394
8395 if (hubUserMayProposeFromNote()) {
8396 const proposeBtn = document.createElement('button');
8397 proposeBtn.type = 'button';
8398 proposeBtn.textContent = 'Propose change';
8399 proposeBtn.onclick = () => {
8400 if (!currentOpenNote) return;
8401 openCreateProposalModal({
8402 path: currentOpenNote.path,
8403 body: currentOpenNote.body || '',
8404 fromNote: true,
8405 });
8406 };
8407 actionsEl.appendChild(proposeBtn);
8408 }
8409 actionsEl.appendChild(sectionBtn);
8410 if (hubUserCanExportNote()) {
8411 actionsEl.appendChild(exportBtn);
8412 }
8413 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
8414 const hint = document.createElement('p');
8415 hint.className = 'muted small';
8416 hint.style.marginTop = '0.5rem';
8417 hint.textContent =
8418 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
8419 actionsEl.appendChild(hint);
8420 }
8421 }
8422
8423 function openCopyNoteToVaultModal() {
8424 if (!currentOpenNote || !token) return;
8425 if (!hubHasMultipleVaultsForCopy()) {
8426 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
8427 return;
8428 }
8429 const existing = document.getElementById('modal-copy-note-vault');
8430 if (existing) existing.remove();
8431 const s = lastBackupSettingsPayload;
8432 const allowed = new Set((s.allowed_vault_ids || []).map(String));
8433 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
8434 const fromId = String(getCurrentVaultId() || 'default');
8435 const targets = vaultList.filter((v) => String(v.id) !== fromId);
8436 if (targets.length === 0) {
8437 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
8438 return;
8439 }
8440 const wrap = document.createElement('div');
8441 wrap.id = 'modal-copy-note-vault';
8442 wrap.className = 'modal';
8443 wrap.setAttribute('role', 'dialog');
8444 wrap.setAttribute('aria-modal', 'true');
8445 wrap.setAttribute('aria-label', 'Copy note to another vault');
8446 const backdrop = document.createElement('div');
8447 backdrop.className = 'modal-backdrop';
8448 const card = document.createElement('div');
8449 card.className = 'modal-card';
8450 card.style.maxWidth = '480px';
8451 const header = document.createElement('div');
8452 header.className = 'modal-header';
8453 const h2 = document.createElement('h2');
8454 h2.textContent = 'Copy to vault';
8455 const btnClose = document.createElement('button');
8456 btnClose.type = 'button';
8457 btnClose.className = 'modal-close';
8458 btnClose.textContent = '×';
8459 btnClose.setAttribute('aria-label', 'Close');
8460 header.appendChild(h2);
8461 header.appendChild(btnClose);
8462 const body = document.createElement('div');
8463 body.style.padding = '1rem 1.25rem';
8464 const lbl = document.createElement('label');
8465 lbl.className = 'detail-field-label';
8466 lbl.textContent = 'Target vault';
8467 lbl.setAttribute('for', 'copy-note-vault-select');
8468 const sel = document.createElement('select');
8469 sel.id = 'copy-note-vault-select';
8470 sel.className = 'vault-switcher-select';
8471 sel.style.width = '100%';
8472 sel.style.marginTop = '0.35rem';
8473 for (const v of targets) {
8474 const id = String(v.id);
8475 const opt = document.createElement('option');
8476 opt.value = id;
8477 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
8478 sel.appendChild(opt);
8479 }
8480 const moveRow = document.createElement('label');
8481 moveRow.style.display = 'flex';
8482 moveRow.style.alignItems = 'center';
8483 moveRow.style.gap = '0.5rem';
8484 moveRow.style.marginTop = '1rem';
8485 moveRow.style.cursor = 'pointer';
8486 const moveChk = document.createElement('input');
8487 moveChk.type = 'checkbox';
8488 moveChk.id = 'copy-note-delete-source';
8489 const moveSpan = document.createElement('span');
8490 moveSpan.textContent = 'Delete from this vault (move)';
8491 moveRow.appendChild(moveChk);
8492 moveRow.appendChild(moveSpan);
8493 const hint = document.createElement('p');
8494 hint.className = 'muted small';
8495 hint.style.marginTop = '0.75rem';
8496 hint.style.fontSize = '0.85rem';
8497 hint.textContent =
8498 'If a note with the same path exists in the target vault, it will be overwritten. On hosted, semantic search catches up after re-index (started automatically).';
8499 const actions = document.createElement('div');
8500 actions.style.display = 'flex';
8501 actions.style.justifyContent = 'flex-end';
8502 actions.style.gap = '0.5rem';
8503 actions.style.marginTop = '1.25rem';
8504 const btnCancel = document.createElement('button');
8505 btnCancel.type = 'button';
8506 btnCancel.className = 'btn-secondary';
8507 btnCancel.textContent = 'Cancel';
8508 const btnGo = document.createElement('button');
8509 btnGo.type = 'button';
8510 btnGo.className = 'btn-primary';
8511 btnGo.textContent = 'Copy';
8512 actions.appendChild(btnCancel);
8513 actions.appendChild(btnGo);
8514 body.appendChild(lbl);
8515 body.appendChild(sel);
8516 body.appendChild(moveRow);
8517 body.appendChild(hint);
8518 body.appendChild(actions);
8519 card.appendChild(header);
8520 card.appendChild(body);
8521 wrap.appendChild(backdrop);
8522 wrap.appendChild(card);
8523 function close() {
8524 wrap.remove();
8525 }
8526 backdrop.onclick = close;
8527 btnClose.onclick = close;
8528 btnCancel.onclick = close;
8529 btnGo.onclick = async () => {
8530 const toId = sel.value;
8531 if (!toId || !currentOpenNote) return;
8532 await withButtonBusy(btnGo, 'Copying…', async () => {
8533 try {
8534 const res = await api('/api/v1/notes/copy', {
8535 method: 'POST',
8536 body: JSON.stringify({
8537 from_vault_id: fromId,
8538 to_vault_id: toId,
8539 path: currentOpenNote.path,
8540 delete_source: moveChk.checked,
8541 }),
8542 });
8543 hubMarkSemanticIndexStaleForVault(toId);
8544 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
8545 close();
8546 if (typeof showToast === 'function') {
8547 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
8548 }
8549 if (res.moved) {
8550 currentOpenNote = null;
8551 currentNotePathForCopy = '';
8552 resetDetailSectionSourceState();
8553 hideDetailPanelChrome();
8554 const bcp = el('btn-copy-path');
8555 if (bcp) bcp.classList.add('hidden');
8556 loadNotes();
8557 loadFacets();
8558 }
8559 } catch (e) {
8560 if (typeof showToast === 'function') showToast(e.message || String(e), true);
8561 }
8562 });
8563 };
8564 document.body.appendChild(wrap);
8565 }
8566
8567 async function exportCurrentNote(format) {
8568 if (!currentOpenNote) return;
8569 try {
8570 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
8571 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
8572 const a = document.createElement('a');
8573 a.href = URL.createObjectURL(blob);
8574 a.download = res.filename || 'export.md';
8575 a.click();
8576 URL.revokeObjectURL(a.href);
8577 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
8578 } catch (e) {
8579 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
8580 }
8581 }
8582
8583 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
8584 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
8585 var MEDIA_URL_SAFE = /^https?:\/\//i;
8586
8587 function teardownDetailEditBodyLayout() {
8588 if (detailEditBodyLayoutAbort) {
8589 detailEditBodyLayoutAbort.abort();
8590 detailEditBodyLayoutAbort = null;
8591 }
8592 }
8593
8594 function detailEditBodyMaxTextareaPx() {
8595 var wrap = el('detail-edit-body-wrap');
8596 var ta = el('detail-edit-body');
8597 if (!wrap || !ta) return 400;
8598 var toolbar = el('media-toolbar');
8599 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8600 var tb = toolbar ? toolbar.offsetHeight : 0;
8601 var gh = grip ? grip.offsetHeight : 0;
8602 var slack = 10;
8603 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
8604 var fallback = Math.round(window.innerHeight * 0.28);
8605 var wr = wrap.getBoundingClientRect();
8606 var next = wrap.nextElementSibling;
8607 var slice = 0;
8608 if (next && next.nodeType === 1) {
8609 var nr = next.getBoundingClientRect();
8610 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
8611 } else {
8612 var body = el('detail-body');
8613 if (body) {
8614 var br = body.getBoundingClientRect();
8615 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
8616 }
8617 }
8618 if (!Number.isFinite(slice) || slice < 120) {
8619 slice = fallback;
8620 }
8621 return Math.max(160, Math.min(hard, slice));
8622 }
8623
8624 function sizeDetailEditBodyToFill() {
8625 var ta = el('detail-edit-body');
8626 if (!ta) return;
8627 ta.style.removeProperty('height');
8628 }
8629
8630 function wireDetailEditBodyLayout() {
8631 teardownDetailEditBodyLayout();
8632 var ta = el('detail-edit-body');
8633 var wrap = el('detail-edit-body-wrap');
8634 if (!ta || !wrap) return;
8635 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8636 if (!grip) {
8637 grip = document.createElement('div');
8638 grip.className = 'detail-edit-body-resize-handle';
8639 grip.setAttribute('role', 'separator');
8640 grip.setAttribute('aria-orientation', 'horizontal');
8641 grip.setAttribute('aria-label', 'Resize editor height');
8642 var next = ta.nextSibling;
8643 if (next && next.id === 'media-toolbar') {
8644 wrap.insertBefore(grip, next);
8645 } else {
8646 wrap.appendChild(grip);
8647 }
8648 }
8649 if (grip.dataset.wired !== '1') {
8650 grip.dataset.wired = '1';
8651 function startDrag(clientY) {
8652 var startY = clientY;
8653 var startH = ta.offsetHeight;
8654 document.body.style.userSelect = 'none';
8655 function onMove(e2) {
8656 if (e2.touches && e2.cancelable) e2.preventDefault();
8657 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
8658 var dy = y - startY;
8659 var cap = detailEditBodyMaxTextareaPx();
8660 var nh = Math.max(160, Math.min(cap, startH + dy));
8661 ta.style.height = nh + 'px';
8662 }
8663 function onUp() {
8664 document.body.style.userSelect = '';
8665 document.removeEventListener('mousemove', onMove);
8666 document.removeEventListener('mouseup', onUp);
8667 document.removeEventListener('touchmove', onMove);
8668 document.removeEventListener('touchend', onUp);
8669 }
8670 document.addEventListener('mousemove', onMove);
8671 document.addEventListener('mouseup', onUp);
8672 document.addEventListener('touchmove', onMove, { passive: false });
8673 document.addEventListener('touchend', onUp);
8674 }
8675 grip.addEventListener('mousedown', function (e) {
8676 e.preventDefault();
8677 startDrag(e.clientY);
8678 });
8679 grip.addEventListener('touchstart', function (e) {
8680 if (!e.touches || !e.touches[0]) return;
8681 e.preventDefault();
8682 startDrag(e.touches[0].clientY);
8683 }, { passive: false });
8684 }
8685 window.requestAnimationFrame(function () {
8686 sizeDetailEditBodyToFill();
8687 });
8688 detailEditBodyLayoutAbort = new AbortController();
8689 window.addEventListener(
8690 'resize',
8691 function () {
8692 if (!el('detail-edit-body-wrap')) return;
8693 sizeDetailEditBodyToFill();
8694 },
8695 { signal: detailEditBodyLayoutAbort.signal }
8696 );
8697 }
8698
8699 function attachMediaToolbar() {
8700 var textarea = el('detail-edit-body');
8701 if (!textarea) return;
8702 var existing = document.getElementById('media-toolbar');
8703 if (existing) existing.remove();
8704
8705 var toolbar = document.createElement('div');
8706 toolbar.id = 'media-toolbar';
8707 toolbar.className = 'media-toolbar';
8708
8709 var insertBtn = document.createElement('button');
8710 insertBtn.type = 'button';
8711 insertBtn.textContent = 'Insert Media URL';
8712 insertBtn.className = 'btn-small';
8713 insertBtn.title = 'Paste a public image or video URL (.mp4 / .webm / .mov) to preview and insert it. Direct video file URLs render as inline players; YouTube/Vimeo links appear as clickable links.';
8714 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
8715 toolbar.appendChild(insertBtn);
8716
8717 var s = lastBackupSettingsPayload;
8718 if (s && s.github_connected && hubUserCanWriteNotes()) {
8719 var uploadBtn = document.createElement('button');
8720 uploadBtn.type = 'button';
8721 uploadBtn.textContent = 'Upload Image';
8722 uploadBtn.className = 'btn-small';
8723 uploadBtn.title = 'Upload an image (JPEG, PNG, GIF, WebP) and commit it to your connected GitHub repo. The image embeds inline in the note. Requires a public GitHub repo for the image to display.';
8724 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
8725 toolbar.appendChild(uploadBtn);
8726 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
8727 var connectHint = document.createElement('span');
8728 connectHint.className = 'media-toolbar-hint';
8729 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
8730 connectHint.textContent = 'Connect GitHub to upload images';
8731 toolbar.appendChild(connectHint);
8732 }
8733
8734 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
8735 }
8736
8737 function toggleMediaUrlDialog(toolbar, textarea) {
8738 var existing = document.getElementById('media-url-dialog');
8739 if (existing) { existing.remove(); return; }
8740
8741 var dialog = document.createElement('div');
8742 dialog.id = 'media-url-dialog';
8743 dialog.className = 'media-url-dialog';
8744
8745 var input = document.createElement('input');
8746 input.type = 'text';
8747 input.placeholder = 'Paste image or video URL (https://...)';
8748 input.className = 'media-url-input';
8749
8750 var preview = document.createElement('div');
8751 preview.className = 'media-preview';
8752
8753 var actions = document.createElement('div');
8754 actions.className = 'media-url-actions';
8755
8756 var doInsert = document.createElement('button');
8757 doInsert.type = 'button';
8758 doInsert.textContent = 'Insert';
8759 doInsert.className = 'btn-primary btn-small';
8760 doInsert.disabled = true;
8761
8762 var doCancel = document.createElement('button');
8763 doCancel.type = 'button';
8764 doCancel.textContent = 'Cancel';
8765 doCancel.className = 'btn-small';
8766 doCancel.onclick = function () { dialog.remove(); };
8767
8768 actions.appendChild(doInsert);
8769 actions.appendChild(doCancel);
8770
8771 var detectedType = null;
8772
8773 function onUrlChange() {
8774 var url = input.value.trim();
8775 preview.innerHTML = '';
8776 doInsert.disabled = true;
8777 detectedType = null;
8778 if (!url || !MEDIA_URL_SAFE.test(url)) return;
8779 if (MEDIA_IMAGE_EXTS.test(url)) {
8780 detectedType = 'image';
8781 var img = document.createElement('img');
8782 img.src = url;
8783 img.style.maxHeight = '200px';
8784 img.style.maxWidth = '100%';
8785 img.crossOrigin = 'anonymous';
8786 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8787 preview.appendChild(img);
8788 doInsert.disabled = false;
8789 } else if (MEDIA_VIDEO_EXTS.test(url)) {
8790 detectedType = 'video';
8791 var vid = document.createElement('video');
8792 vid.controls = true;
8793 vid.preload = 'metadata';
8794 vid.style.maxHeight = '200px';
8795 vid.style.maxWidth = '100%';
8796 vid.src = url;
8797 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8798 preview.appendChild(vid);
8799 doInsert.disabled = false;
8800 } else {
8801 preview.innerHTML = '<span class="muted small">Not a recognised image or video URL. Paste a URL ending in .jpg, .png, .gif, .webp, .mp4, .webm, or .mov.</span>';
8802 }
8803 }
8804
8805 input.addEventListener('input', onUrlChange);
8806 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
8807
8808 doInsert.onclick = function () {
8809 var url = input.value.trim();
8810 if (!url) return;
8811 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
8812 insertAtCursor(textarea, insertion);
8813 dialog.remove();
8814 };
8815
8816 dialog.appendChild(input);
8817 dialog.appendChild(preview);
8818 dialog.appendChild(actions);
8819 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
8820 input.focus();
8821 }
8822
8823 function insertAtCursor(textarea, text) {
8824 var start = textarea.selectionStart;
8825 var end = textarea.selectionEnd;
8826 var val = textarea.value;
8827 var before = val.substring(0, start);
8828 var needsNewline = before.length > 0 && !before.endsWith('\n');
8829 var insertion = (needsNewline ? '\n' : '') + text + '\n';
8830 textarea.value = before + insertion + val.substring(end);
8831 var newPos = start + insertion.length;
8832 textarea.setSelectionRange(newPos, newPos);
8833 textarea.focus();
8834 }
8835
8836 /**
8837 * Compress an image File/Blob using the Canvas API so it fits within the
8838 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
8839 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
8840 * Falls back to the original file if Canvas is unavailable or the image is
8841 * already small enough.
8842 */
8843 function compressImageIfNeeded(file) {
8844 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
8845 var MAX_DIM = 2048;
8846 return new Promise(function (resolve) {
8847 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
8848 return resolve(file);
8849 }
8850 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
8851 return resolve(file);
8852 }
8853 var img = new window.Image();
8854 var objectUrl = URL.createObjectURL(file);
8855 img.onload = function () {
8856 URL.revokeObjectURL(objectUrl);
8857 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
8858 var w = Math.round(img.width * ratio);
8859 var h = Math.round(img.height * ratio);
8860 var canvas = document.createElement('canvas');
8861 canvas.width = w;
8862 canvas.height = h;
8863 var ctx = canvas.getContext('2d');
8864 ctx.drawImage(img, 0, 0, w, h);
8865 var tryQuality = function (quality, attempt) {
8866 canvas.toBlob(function (blob) {
8867 if (!blob) return resolve(file); // Canvas failed — upload original
8868 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
8869 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
8870 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
8871 } else {
8872 tryQuality(quality - 0.2, attempt + 1);
8873 }
8874 }, 'image/jpeg', quality);
8875 };
8876 tryQuality(0.82, 0);
8877 };
8878 img.onerror = function () {
8879 URL.revokeObjectURL(objectUrl);
8880 resolve(file);
8881 };
8882 img.src = objectUrl;
8883 });
8884 }
8885
8886 function triggerImageUpload(textarea) {
8887 var fileInput = document.createElement('input');
8888 fileInput.type = 'file';
8889 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
8890 fileInput.onchange = async function () {
8891 var file = fileInput.files && fileInput.files[0];
8892 if (!file || !currentOpenNote) return;
8893 try {
8894 if (typeof showToast === 'function') showToast('Uploading image…');
8895 // Compress before uploading to stay within the Netlify Lambda 6 MB
8896 // payload limit (~4.5 MB binary after base64 overhead).
8897 var uploadFile = await compressImageIfNeeded(file);
8898 var form = new FormData();
8899 form.append('image', uploadFile);
8900 var notePath = encodeURIComponent(currentOpenNote.path);
8901 var vaultIdParam = '';
8902 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
8903 // Build auth headers from the shared helper (omit Content-Type so the
8904 // browser sets the correct multipart/form-data boundary automatically).
8905 var uploadHeaders = headers();
8906 delete uploadHeaders['Content-Type'];
8907 // Use apiBase so this request reaches the gateway when the frontend is served
8908 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
8909 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8910 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
8911 method: 'POST',
8912 headers: uploadHeaders,
8913 body: form,
8914 });
8915 if (!res.ok) {
8916 var errData = await res.json().catch(function () { return {}; });
8917 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
8918 }
8919 var data = await res.json();
8920 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
8921 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
8922 } catch (e) {
8923 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
8924 }
8925 };
8926 fileInput.click();
8927 }
8928
8929 function switchNoteToEditMode() {
8930 if (!currentOpenNote) return;
8931 closeCreateModal();
8932 resetDetailSectionSourceState();
8933 const bcbEdit = el('btn-detail-copy-body');
8934 if (bcbEdit) bcbEdit.classList.add('hidden');
8935 const bodyEl = el('detail-body');
8936 const actionsEl = el('detail-actions');
8937 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
8938 bodyEl.className = 'detail-edit-container create-panel';
8939 bodyEl.innerHTML =
8940 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
8941 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
8942 '<label for="detail-edit-title">Title</label>' +
8943 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
8944 '<label for="detail-edit-body">Body (Markdown)</label>' +
8945 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
8946 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
8947 '</div>' +
8948 '<label for="detail-edit-date">Date</label>' +
8949 '<input type="date" id="detail-edit-date" />' +
8950 '<label for="detail-edit-project">Project (slug)</label>' +
8951 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
8952 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
8953 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
8954 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
8955 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
8956 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
8957 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
8958 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
8959 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
8960 '<label for="detail-edit-episode">Episode ID</label>' +
8961 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
8962 '<label for="detail-edit-follows">Follows (vault path)</label>' +
8963 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
8964 const pathDisp = el('detail-edit-path-display');
8965 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
8966 fillDetailEditFieldsFromFrontmatter(fm);
8967 attachMediaToolbar();
8968 wireDetailEditBodyLayout();
8969 actionsEl.innerHTML = '';
8970 const saveBtn = document.createElement('button');
8971 saveBtn.textContent = 'Save';
8972 saveBtn.className = 'btn-primary';
8973 saveBtn.onclick = async () => {
8974 closeCreateModal();
8975 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
8976 const frontmatter = mergedFrontmatterForDetailSave();
8977 await withButtonBusy(saveBtn, 'Saving…', async () => {
8978 try {
8979 await api('/api/v1/notes', {
8980 method: 'POST',
8981 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
8982 });
8983 hubMarkSemanticIndexStale();
8984 if (typeof showToast === 'function') showToast('Note saved');
8985 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
8986 const nfm = materializeFrontmatter(refreshed.frontmatter);
8987 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
8988 switchNoteToReadMode();
8989 if (typeof loadNotes === 'function') loadNotes();
8990 if (typeof loadFacets === 'function') loadFacets();
8991 } catch (e) {
8992 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
8993 }
8994 });
8995 };
8996 const cancelBtn = document.createElement('button');
8997 cancelBtn.textContent = 'Cancel';
8998 cancelBtn.onclick = () => switchNoteToReadMode();
8999 const delBtn = document.createElement('button');
9000 delBtn.type = 'button';
9001 delBtn.textContent = 'Delete';
9002 delBtn.onclick = () => deleteOpenNote();
9003 actionsEl.append(saveBtn, delBtn, cancelBtn);
9004 }
9005
9006 function openNote(path) {
9007 const seq = ++hubOpenNoteSeq;
9008 resetDetailSectionSourceState();
9009 teardownDetailEditBodyLayout();
9010 closeCreateModal();
9011 currentNotePathForCopy = path;
9012 currentOpenNote = null;
9013 const panel = el('detail-panel');
9014 panel.classList.remove('detail-panel-proposal-wide');
9015 const title = el('detail-title');
9016 const bodyEl = el('detail-body');
9017 const actionsEl = el('detail-actions');
9018 const btnCopy = el('btn-copy-path');
9019 const btnCopyBody = el('btn-detail-copy-body');
9020 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9021 title.textContent = path;
9022 bodyEl.textContent = 'Loading…';
9023 bodyEl.className = '';
9024 actionsEl.innerHTML = '';
9025 btnCopy.classList.remove('hidden');
9026 panel.classList.remove('hidden');
9027 api('/api/v1/notes/' + encodeURIComponent(path))
9028 .then((note) => {
9029 if (seq !== hubOpenNoteSeq) return;
9030 const fm = materializeFrontmatter(note.frontmatter);
9031 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9032 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9033 bodyEl.className = 'note-rendered-body';
9034 actionsEl.innerHTML = '';
9035 attachNoteDetailReadActions(actionsEl);
9036 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9037 })
9038 .catch((e) => {
9039 if (seq !== hubOpenNoteSeq) return;
9040 bodyEl.textContent = 'Error: ' + e.message;
9041 bodyEl.className = '';
9042 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9043 });
9044 }
9045
9046 el('btn-copy-path').onclick = () => {
9047 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
9048 };
9049
9050 const btnDetailCopyBody = el('btn-detail-copy-body');
9051 if (btnDetailCopyBody) {
9052 btnDetailCopyBody.onclick = () => {
9053 if (!currentOpenNote) {
9054 if (typeof showToast === 'function') showToast('Open a note first.', true);
9055 return;
9056 }
9057 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
9058 if (navigator.clipboard && navigator.clipboard.writeText) {
9059 navigator.clipboard.writeText(text).then(
9060 () => {
9061 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
9062 },
9063 () => {
9064 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
9065 },
9066 );
9067 } else if (typeof showToast === 'function') {
9068 showToast('Clipboard not available in this browser.', true);
9069 }
9070 };
9071 }
9072
9073 const btnCopyUserId = el('btn-copy-user-id');
9074 if (btnCopyUserId) {
9075 btnCopyUserId.onclick = () => {
9076 const idEl = el('settings-user-id');
9077 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
9078 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9079 navigator.clipboard.writeText(text).then(() => {
9080 if (typeof showToast === 'function') showToast('User ID copied.');
9081 }).catch(() => {});
9082 }
9083 };
9084 }
9085 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
9086 if (btnCopyAgentceptionEnv) {
9087 btnCopyAgentceptionEnv.onclick = () => {
9088 const envEl = el('integrations-agentception-env');
9089 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
9090 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9091 navigator.clipboard.writeText(text).then(() => {
9092 if (typeof showToast === 'function') showToast('Env snippet copied.');
9093 }).catch(() => {});
9094 }
9095 };
9096 }
9097 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
9098 if (btnIntegrationsHowToAgentception) {
9099 btnIntegrationsHowToAgentception.onclick = () => {
9100 closeSettings();
9101 openHowToUse('setup');
9102 };
9103 }
9104 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
9105 if (btnHowToFlexibleNetwork) {
9106 btnHowToFlexibleNetwork.onclick = () => {
9107 closeSettings();
9108 openHowToUse('setup', 'how-to-flexible-network');
9109 };
9110 }
9111
9112 function renderProposalMarkdownHtml(md) {
9113 try {
9114 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9115 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9116 var withVideo = expandVideoUrls(raw);
9117 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9118 return rewriteGitHubImageUrls(sanitised);
9119 }
9120 } catch (_) {
9121 /* fall through */
9122 }
9123 return escapeHtml(md || '');
9124 }
9125
9126 /** Canister stores checklist as JSON text; Node may return an array. */
9127 function parseProposalEvaluationChecklist(raw) {
9128 if (Array.isArray(raw)) return raw;
9129 if (raw == null || raw === '') return [];
9130 const s = String(raw).trim();
9131 if (!s) return [];
9132 try {
9133 const j = JSON.parse(s);
9134 return Array.isArray(j) ? j : [];
9135 } catch (_) {
9136 return [];
9137 }
9138 }
9139
9140 /**
9141 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
9142 */
9143 function buildProposalEvaluationRecordHtml(p, rubricItems) {
9144 const st = p.status;
9145 if (st !== 'approved' && st !== 'discarded') return '';
9146 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9147 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
9148 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
9149 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
9150 const meaningfulStatus = es && es !== 'none';
9151 let waiverText = '';
9152 const w = p.evaluation_waiver;
9153 if (w != null && w !== '') {
9154 try {
9155 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
9156 if (o && typeof o === 'object') {
9157 const r1 = o.reason != null ? String(o.reason).trim() : '';
9158 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
9159 waiverText = r1 || r2;
9160 }
9161 } catch (_) {
9162 /* ignore */
9163 }
9164 }
9165 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
9166 const rubricById = new Map(
9167 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
9168 String(it.id || '').trim(),
9169 String(it.label || it.id || '').trim(),
9170 ]),
9171 );
9172 const rows = checklist
9173 .map((c) => {
9174 const rid = c && c.id != null ? String(c.id) : '';
9175 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
9176 const pass = c && c.passed === true;
9177 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
9178 })
9179 .join('');
9180 return (
9181 '<div class="proposal-eval proposal-eval-readonly">' +
9182 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
9183 '<p class="small">' +
9184 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
9185 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
9186 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
9187 (p.evaluated_at
9188 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
9189 : '') +
9190 '</p>' +
9191 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
9192 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
9193 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
9194 '</div>'
9195 );
9196 }
9197
9198 function openProposal(id) {
9199 resetDetailSectionSourceState();
9200 currentNotePathForCopy = '';
9201 currentOpenNote = null;
9202 el('btn-copy-path').classList.add('hidden');
9203 const bcbProp = el('btn-detail-copy-body');
9204 if (bcbProp) bcbProp.classList.add('hidden');
9205 const panel = el('detail-panel');
9206 panel.classList.add('detail-panel-proposal-wide');
9207 const title = el('detail-title');
9208 const body = el('detail-body');
9209 const actions = el('detail-actions');
9210 body.className = 'detail-body-proposal';
9211 panel.classList.remove('hidden');
9212 body.innerHTML = '<p class="muted">Loading…</p>';
9213 actions.innerHTML = '';
9214 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
9215 api('/api/v1/proposals/' + encodeURIComponent(id))
9216 .then((p) =>
9217 api('/api/v1/notes/' + pathEnc(p.path)).then(
9218 (note) => ({ p, note }),
9219 () => ({ p, note: null }),
9220 ),
9221 )
9222 .then(({ p, note }) => {
9223 title.textContent = p.path + ' (' + p.status + ')';
9224 const pFm = materializeFrontmatter(p.frontmatter);
9225 const currentBlock = note
9226 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
9227 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
9228 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
9229 const mdHtml = renderProposalMarkdownHtml(p.body || '');
9230 const chips = [];
9231 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
9232 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
9233 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
9234 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
9235 });
9236 if (p.external_ref) {
9237 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
9238 }
9239 const role = window.__hubUserRole || 'member';
9240 const isAdmin = role === 'admin';
9241 const isEvaluator = role === 'evaluator';
9242 const canEvaluate = isAdmin || isEvaluator;
9243 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
9244 const canDiscard = isAdmin;
9245 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
9246 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9247 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
9248 function prevEvalPassed(rid) {
9249 const row = prevChecklist.find((c) => c && c.id === rid);
9250 return Boolean(row && row.passed === true);
9251 }
9252 let evalHtml = '';
9253 let waiverHtml = '';
9254 if (canEvaluate && p.status === 'proposed') {
9255 const es = p.evaluation_status || 'none';
9256 let evalIntro = '';
9257 if (es && es !== 'none' && es !== 'pending') {
9258 evalIntro =
9259 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
9260 escapeHtml(es) +
9261 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
9262 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
9263 (p.evaluation_comment
9264 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
9265 : '') +
9266 '</div>';
9267 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
9268 evalIntro =
9269 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
9270 }
9271 const checks = rubricItems.length
9272 ? rubricItems
9273 .map((it) => {
9274 const rid = String(it.id || '').trim();
9275 if (!rid) return '';
9276 const lab = String(it.label || rid);
9277 const ck = prevEvalPassed(rid) ? ' checked' : '';
9278 return (
9279 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
9280 escapeHtml(rid) +
9281 '"' +
9282 ck +
9283 ' /> ' +
9284 escapeHtml(lab) +
9285 '</label>'
9286 );
9287 })
9288 .join('')
9289 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
9290 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
9291 evalHtml =
9292 '<div class="proposal-eval">' +
9293 '<h4 class="proposal-md-heading">Evaluation</h4>' +
9294 evalIntro +
9295 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
9296 '<option value="pass">Pass</option>' +
9297 '<option value="fail">Fail</option>' +
9298 '<option value="needs_changes">Needs changes</option>' +
9299 '</select></label>' +
9300 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
9301 gradeVal +
9302 '" placeholder="e.g. A or 4" /></label>' +
9303 '<div class="proposal-eval-checklist">' +
9304 checks +
9305 '</div>' +
9306 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
9307 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
9308 '</textarea></label>' +
9309 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
9310 '</div>';
9311 }
9312 if (canApprove && p.status === 'proposed') {
9313 waiverHtml =
9314 '<div class="proposal-eval-waiver">' +
9315 '<label class="proposal-eval-field">Approve waiver reason <textarea id="proposal-waiver-reason" rows="2" placeholder="If approving without a passed evaluation, enter at least 3 characters."></textarea></label>' +
9316 '</div>';
9317 }
9318 let autoFlagHtml = '';
9319 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
9320 autoFlagHtml =
9321 '<p class="small muted">Auto-flagged: ' +
9322 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
9323 '</p>';
9324 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
9325 try {
9326 const ar = JSON.parse(String(p.auto_flag_reasons_json));
9327 if (Array.isArray(ar) && ar.length) {
9328 autoFlagHtml =
9329 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
9330 }
9331 } catch (_) {
9332 /* ignore */
9333 }
9334 }
9335 let hintsHtml = '';
9336 if (p.review_hints) {
9337 hintsHtml =
9338 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
9339 (p.review_hints_model
9340 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
9341 : '') +
9342 (p.review_hints_at
9343 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
9344 : '') +
9345 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">Use as a review checklist; copy into your comment if helpful — you still decide pass or fail.</p>' +
9346 '<pre class="proposal-pre">' +
9347 escapeHtml(String(p.review_hints)) +
9348 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
9349 }
9350 let assistantHtml = '';
9351 if (p.assistant_notes) {
9352 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
9353 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
9354 .join('');
9355 assistantHtml =
9356 '<div class="proposal-assistant"><strong>Assistant</strong>' +
9357 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
9358 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
9359 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">Quick summary and label ideas from the model; verify before trusting or reusing (e.g. paste into your comment or frontmatter after approve).</p>' +
9360 '<p>' +
9361 escapeHtml(String(p.assistant_notes)) +
9362 '</p>' +
9363 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
9364 '</div>';
9365 }
9366 let suggestedFmHtml = '';
9367 {
9368 let fm = p.assistant_suggested_frontmatter;
9369 if (typeof fm === 'string') {
9370 try {
9371 fm = JSON.parse(fm);
9372 } catch {
9373 fm = null;
9374 }
9375 }
9376 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
9377 const keys = Object.keys(fm).filter((k) => {
9378 const v = fm[k];
9379 return v !== undefined && v !== null && v !== '';
9380 });
9381 if (keys.length) {
9382 const rows = keys
9383 .map((k) => {
9384 const v = fm[k];
9385 let cell;
9386 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
9387 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
9388 else cell = String(v);
9389 return (
9390 '<tr><th scope="row">' +
9391 escapeHtml(k) +
9392 '</th><td>' +
9393 escapeHtml(cell) +
9394 '</td></tr>'
9395 );
9396 })
9397 .join('');
9398 suggestedFmHtml =
9399 '<div class="proposal-suggested-fm">' +
9400 '<strong>Suggested frontmatter</strong> ' +
9401 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
9402 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">From the assistant run; not applied on approve — verify before reusing in a note.</p>' +
9403 '<table class="proposal-suggested-fm-table"><tbody>' +
9404 rows +
9405 '</tbody></table></div>';
9406 }
9407 }
9408 }
9409 const openVaultNoteLine = note
9410 ? '<p class="small proposal-open-note-wrap"><button type="button" class="btn-link btn-link-small" id="proposal-open-note-btn">Open vault note to edit</button> <span class="muted">— tags, episode, entity, causal chain (frontmatter); use Activity again to return to this proposal.</span></p>'
9411 : '<p class="small muted">No note file at this path yet — approving creates or overwrites the file from the proposal body; then you can edit frontmatter.</p>';
9412 body.innerHTML =
9413 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
9414 autoFlagHtml +
9415 '<p class="small muted">Intent: ' +
9416 escapeHtml(p.intent || '—') +
9417 ' · base_state_id: ' +
9418 escapeHtml(p.base_state_id || '—') +
9419 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
9420 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
9421 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
9422 '</p>' +
9423 openVaultNoteLine +
9424 '<div class="proposal-diff-grid">' +
9425 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
9426 escapeHtml(currentBlock) +
9427 '</pre></div>' +
9428 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
9429 escapeHtml(proposedBlock) +
9430 '</pre></div>' +
9431 '</div>' +
9432 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
9433 '<div class="proposal-md">' +
9434 mdHtml +
9435 '</div>' +
9436 evalRecordHtml +
9437 evalHtml +
9438 waiverHtml +
9439 assistantHtml +
9440 suggestedFmHtml +
9441 hintsHtml;
9442 actions.innerHTML = '';
9443 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
9444 if (openNoteBtn && note && p.path) {
9445 openNoteBtn.onclick = () => openNote(String(p.path));
9446 }
9447 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
9448 if (copyFmBtn) {
9449 let fmForCopy = p.assistant_suggested_frontmatter;
9450 if (typeof fmForCopy === 'string') {
9451 try {
9452 fmForCopy = JSON.parse(fmForCopy);
9453 } catch {
9454 fmForCopy = null;
9455 }
9456 }
9457 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
9458 copyFmBtn.onclick = async () => {
9459 try {
9460 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
9461 showToast('Copied suggested frontmatter JSON.');
9462 } catch (err) {
9463 showToast(err.message || 'Copy failed', true);
9464 }
9465 };
9466 }
9467 }
9468 const saveEvalBtn = body.querySelector('#proposal-eval-save');
9469 if (saveEvalBtn) {
9470 saveEvalBtn.onclick = async () => {
9471 const outcomeEl = body.querySelector('#proposal-eval-outcome');
9472 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
9473 const gradeEl = body.querySelector('#proposal-eval-grade');
9474 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
9475 const commentEl = body.querySelector('#proposal-eval-comment');
9476 const comment = commentEl ? String(commentEl.value || '').trim() : '';
9477 const checklist = [];
9478 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
9479 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
9480 });
9481 try {
9482 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
9483 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
9484 method: 'POST',
9485 body: JSON.stringify({
9486 outcome,
9487 grade: grade || undefined,
9488 comment: comment || undefined,
9489 checklist,
9490 }),
9491 });
9492 });
9493 showToast('Evaluation saved.');
9494 openProposal(id);
9495 loadProposals();
9496 } catch (err) {
9497 showToast(err.message || 'Evaluation failed', true);
9498 }
9499 };
9500 }
9501 if (p.status === 'proposed') {
9502 if (canApprove) {
9503 const approveBtn = document.createElement('button');
9504 approveBtn.textContent = 'Approve';
9505 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
9506 actions.append(approveBtn);
9507 }
9508 if (canDiscard) {
9509 const discardBtn = document.createElement('button');
9510 discardBtn.textContent = 'Discard';
9511 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
9512 actions.append(discardBtn);
9513 }
9514 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
9515 const enrichBtn = document.createElement('button');
9516 enrichBtn.type = 'button';
9517 enrichBtn.className = 'btn-secondary';
9518 enrichBtn.textContent = 'Enrich (AI)';
9519 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
9520 actions.append(enrichBtn);
9521 }
9522 if (isEvaluator && !canApprove) {
9523 const hintEv = document.createElement('p');
9524 hintEv.className = 'muted small';
9525 hintEv.textContent =
9526 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
9527 actions.append(hintEv);
9528 } else if (!canEvaluate) {
9529 const hint = document.createElement('p');
9530 hint.className = 'muted small';
9531 hint.textContent =
9532 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
9533 actions.append(hint);
9534 }
9535 }
9536 })
9537 .catch((e) => {
9538 body.className = 'detail-body-proposal';
9539 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
9540 });
9541 }
9542
9543 async function enrichProposal(id, panel, btn) {
9544 try {
9545 await withButtonBusy(btn, 'Enriching…', async () => {
9546 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
9547 });
9548 showToast('Proposal enriched.');
9549 openProposal(id);
9550 loadProposals();
9551 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
9552 // is visible instead of the browser staying at whatever scroll position it was at.
9553 const scrollHost = el('detail-body');
9554 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
9555 // Also highlight the matching row in the Suggested/Activity list so the user can see which
9556 // proposal was enriched.
9557 requestAnimationFrame(() => {
9558 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
9559 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
9560 });
9561 } catch (e) {
9562 showToast(e.message || 'Enrich failed', true);
9563 }
9564 }
9565
9566 async function approveProposal(id, panel, btn) {
9567 try {
9568 const db = el('detail-body');
9569 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
9570 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
9571 const approveBody = {};
9572 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
9573 let approveOut = null;
9574 await withButtonBusy(btn, 'Approving…', async () => {
9575 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
9576 method: 'POST',
9577 body: JSON.stringify(approveBody),
9578 });
9579 });
9580 if (approveOut && approveOut.approval_log_written === false) {
9581 showToast(
9582 approveOut.approval_log_error
9583 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
9584 : 'Approved, but approval log was not written. Check server logs and re-index.',
9585 true,
9586 );
9587 }
9588 hideDetailPanelChrome();
9589 hubMarkSemanticIndexStale();
9590 loadProposals();
9591 loadNotes();
9592 loadActivity();
9593 } catch (e) {
9594 const db = el('detail-body');
9595 if (db) {
9596 const extra = document.createElement('p');
9597 extra.className = 'muted';
9598 extra.textContent = 'Approve failed: ' + (e.message || String(e));
9599 db.appendChild(extra);
9600 }
9601 }
9602 }
9603
9604 async function discardProposal(id, panel, btn) {
9605 try {
9606 await withButtonBusy(btn, 'Discarding…', async () => {
9607 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
9608 });
9609 hideDetailPanelChrome();
9610 loadProposals();
9611 loadActivity();
9612 } catch (e) {
9613 const db = el('detail-body');
9614 if (db) {
9615 const extra = document.createElement('p');
9616 extra.className = 'muted';
9617 extra.textContent = 'Discard failed: ' + (e.message || String(e));
9618 db.appendChild(extra);
9619 }
9620 }
9621 }
9622
9623 el('detail-close').onclick = () => closeDetailPanel();
9624 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
9625 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
9626 (function wireDetailPanelFooterClose() {
9627 const panel = el('detail-panel');
9628 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
9629 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
9630 })();
9631
9632 // Resizable detail panel — drag the left edge to widen/narrow.
9633 (function initDetailPanelResize() {
9634 const panel = el('detail-panel');
9635 if (!panel) return;
9636 const handle = document.createElement('div');
9637 handle.className = 'detail-resize-handle';
9638 handle.title = 'Drag to resize panel';
9639 panel.prepend(handle);
9640 const MIN_W = 280;
9641 const MAX_W = Math.round(window.innerWidth * 0.92);
9642 let startX = 0, startW = 0, dragging = false;
9643 const onMove = (e) => {
9644 if (!dragging) return;
9645 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
9646 const delta = startX - clientX;
9647 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
9648 panel.style.width = newW + 'px';
9649 };
9650 const onUp = () => {
9651 if (!dragging) return;
9652 dragging = false;
9653 handle.classList.remove('dragging');
9654 document.removeEventListener('mousemove', onMove);
9655 document.removeEventListener('mouseup', onUp);
9656 document.removeEventListener('touchmove', onMove);
9657 document.removeEventListener('touchend', onUp);
9658 document.body.style.userSelect = '';
9659 };
9660 handle.addEventListener('mousedown', (e) => {
9661 e.preventDefault();
9662 dragging = true;
9663 startX = e.clientX;
9664 startW = panel.offsetWidth;
9665 handle.classList.add('dragging');
9666 document.body.style.userSelect = 'none';
9667 document.addEventListener('mousemove', onMove);
9668 document.addEventListener('mouseup', onUp);
9669 });
9670 handle.addEventListener('touchstart', (e) => {
9671 dragging = true;
9672 startX = e.touches[0].clientX;
9673 startW = panel.offsetWidth;
9674 handle.classList.add('dragging');
9675 document.addEventListener('touchmove', onMove, { passive: true });
9676 document.addEventListener('touchend', onUp);
9677 });
9678 })();
9679
9680 document.addEventListener('keydown', (e) => {
9681 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
9682 if (e.key === 'Escape') {
9683 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
9684 closeDetailPanel();
9685 e.preventDefault();
9686 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
9687 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
9688 closeHowToUse();
9689 e.preventDefault();
9690 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
9691 closeIntegGuideModal();
9692 e.preventDefault();
9693 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
9694 closeSettings();
9695 e.preventDefault();
9696 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
9697 closeProjectsHelpModal();
9698 e.preventDefault();
9699 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
9700 closeOnboardingWizardResume();
9701 e.preventDefault();
9702 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
9703 closeImportModal();
9704 e.preventDefault();
9705 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
9706 closeFullCreateSimilarModal();
9707 e.preventDefault();
9708 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
9709 closeCreateProposalModal();
9710 e.preventDefault();
9711 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
9712 closeCreateModal();
9713 e.preventDefault();
9714 } else if (el('search-key-help')?.open) {
9715 el('search-key-help').open = false;
9716 e.preventDefault();
9717 }
9718 return;
9719 }
9720 if (inInput && e.key !== 'Escape') return;
9721 if (e.key === '/') {
9722 searchQuery.focus();
9723 e.preventDefault();
9724 return;
9725 }
9726 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
9727 // run semantic search instead of opening the selected row (avoids "second search does nothing").
9728 if (e.key === 'Enter') {
9729 const q = (searchQuery.value || '').trim();
9730 if (q) {
9731 e.preventDefault();
9732 void runVaultSearch();
9733 return;
9734 }
9735 }
9736 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
9737 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
9738 const items = notesList.querySelectorAll('.list-item');
9739 if (notesTabActive && listViewVisible && items.length > 0) {
9740 if (e.key === 'j' || e.key === 'J') {
9741 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
9742 updateListSelection();
9743 e.preventDefault();
9744 } else if (e.key === 'k' || e.key === 'K') {
9745 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
9746 updateListSelection();
9747 e.preventDefault();
9748 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
9749 const node = items[listSelectedIndex];
9750 if (node.dataset.path) openNote(node.dataset.path);
9751 else if (node.dataset.id) openProposal(node.dataset.id);
9752 e.preventDefault();
9753 }
9754 }
9755 });
9756
9757 document.addEventListener('click', (e) => {
9758 const keyHelp = el('search-key-help');
9759 if (!keyHelp || !keyHelp.open) return;
9760 if (keyHelp.contains(e.target)) return;
9761 keyHelp.open = false;
9762 });
9763
9764 document.querySelectorAll('.tab').forEach((tab) => {
9765 tab.onclick = () => {
9766 switchHubMainTab(tab.dataset.tab);
9767 };
9768 });
9769 if (btnHeaderSuggested) {
9770 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
9771 }
9772
9773 function escapeHtml(s) {
9774 const div = document.createElement('div');
9775 div.textContent = s == null ? '' : String(s);
9776 return div.innerHTML;
9777 }
9778
9779 // ── Consolidation UI (Stream 2) ───────────────────────────────
9780
9781 function consolModeFromSettings(s) {
9782 if (!s || !s.daemon) return 'off';
9783 if (s.daemon.enabled) return 'daemon';
9784 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
9785 return 'off';
9786 }
9787
9788 function populateConsolSettingsForm(s) {
9789 if (!s || !s.daemon) return;
9790 const d = s.daemon;
9791 const mode = consolModeFromSettings(s);
9792 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
9793 applyConsolModeVisibility(mode);
9794 const iv = el('consol-interval');
9795 if (iv) iv.value = d.interval_minutes ?? 120;
9796 const idle = el('consol-idle-only');
9797 if (idle) idle.checked = d.idle_only !== false;
9798 const idleTh = el('consol-idle-threshold');
9799 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
9800 const ros = el('consol-run-on-start');
9801 if (ros) ros.checked = Boolean(d.run_on_start);
9802 const pc = el('pass-consolidate');
9803 if (pc) pc.checked = d.passes?.consolidate !== false;
9804 const pv = el('pass-verify');
9805 if (pv) pv.checked = d.passes?.verify !== false;
9806 const pd = el('pass-discover');
9807 if (pd) pd.checked = Boolean(d.passes?.discover);
9808 const lp = el('consol-llm-provider');
9809 if (lp) lp.value = d.llm?.provider || '';
9810 const lm = el('consol-llm-model');
9811 if (lm) lm.value = d.llm?.model || '';
9812 const lb = el('consol-llm-base-url');
9813 if (lb) lb.value = d.llm?.base_url || '';
9814 const lbh = el('consol-lookback-hours');
9815 if (lbh) lbh.value = d.lookback_hours ?? 24;
9816 const me = el('consol-max-events');
9817 if (me) me.value = d.max_events_per_pass ?? 200;
9818 const mt = el('consol-max-topics');
9819 if (mt) mt.value = d.max_topics_per_pass ?? 10;
9820 const lmt = el('consol-llm-max-tokens');
9821 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
9822 const cc = el('consol-cost-cap');
9823 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
9824 const chi = el('consol-hosted-interval');
9825 if (chi && d.interval_minutes != null) {
9826 const v = String(d.interval_minutes);
9827 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
9828 chi.value = allowed.includes(v) ? v : '120';
9829 }
9830 }
9831
9832 function buildConsolSettingsPayload() {
9833 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
9834 const mode = modeRadio ? modeRadio.value : 'off';
9835 const hostedSel = el('consol-hosted-interval');
9836 const intervalRaw =
9837 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
9838 const llm = {
9839 provider: el('consol-llm-provider')?.value || '',
9840 model: el('consol-llm-model')?.value || '',
9841 base_url: el('consol-llm-base-url')?.value || '',
9842 };
9843 if (mode === 'daemon') {
9844 llm.max_tokens = Math.max(
9845 64,
9846 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
9847 );
9848 }
9849 const payload = {
9850 mode,
9851 enabled: mode === 'daemon',
9852 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
9853 idle_only: Boolean(el('consol-idle-only')?.checked),
9854 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
9855 run_on_start: Boolean(el('consol-run-on-start')?.checked),
9856 passes: {
9857 consolidate: Boolean(el('pass-consolidate')?.checked),
9858 verify: Boolean(el('pass-verify')?.checked),
9859 discover: Boolean(el('pass-discover')?.checked),
9860 },
9861 llm,
9862 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
9863 };
9864 if (mode === 'daemon') {
9865 payload.lookback_hours = Math.max(
9866 1,
9867 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
9868 );
9869 payload.max_events_per_pass = Math.max(
9870 1,
9871 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
9872 );
9873 payload.max_topics_per_pass = Math.max(
9874 1,
9875 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
9876 );
9877 }
9878 return payload;
9879 }
9880
9881 function applyConsolModeVisibility(mode) {
9882 const daemonSection = el('consol-daemon-settings');
9883 const hostedSection = el('consol-hosted-settings');
9884 const llmSection = el('consol-llm-settings');
9885 const costGuard = el('consol-cost-guard');
9886 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
9887 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
9888 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
9889 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
9890 }
9891
9892 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
9893 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
9894 });
9895
9896 async function loadConsolidationSettings() {
9897 const msg = el('consol-save-status');
9898 if (msg) msg.textContent = '';
9899 try {
9900 const s = await api('/api/v1/settings');
9901 populateConsolSettingsForm(s);
9902 } catch (e) {
9903 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
9904 }
9905 }
9906
9907 const btnConsolSave = el('btn-consol-save');
9908 if (btnConsolSave) {
9909 btnConsolSave.addEventListener('click', async () => {
9910 const msg = el('consol-save-status');
9911 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
9912 const payload = buildConsolSettingsPayload();
9913 if (payload.enabled && payload.interval_minutes < 30) {
9914 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
9915 return;
9916 }
9917 setButtonBusy(btnConsolSave, true, 'Saving…');
9918 try {
9919 await api('/api/v1/settings/consolidation', {
9920 method: 'POST',
9921 body: JSON.stringify(payload),
9922 });
9923 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
9924 } catch (e) {
9925 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
9926 }
9927 setButtonBusy(btnConsolSave, false);
9928 });
9929 }
9930
9931 const linkConsolHelp = el('link-consol-help');
9932 if (linkConsolHelp) {
9933 linkConsolHelp.addEventListener('click', (e) => {
9934 e.preventDefault();
9935 closeSettings();
9936 openHowToUse('consolidation');
9937 });
9938 }
9939
9940 // ── Consolidation Dashboard Card ──────────────────────────────
9941
9942 function formatCostMeter(costUsd, capUsd) {
9943 const cost = Math.max(0, Number(costUsd) || 0);
9944 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
9945 const display = '$' + cost.toFixed(3) + ' today';
9946 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
9947 const pct = Math.min(100, (cost / cap) * 100);
9948 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
9949 }
9950
9951 function renderConsolidationHistory(events, container) {
9952 if (!container) return;
9953 if (!events || events.length === 0) {
9954 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
9955 return;
9956 }
9957 let html = '<table class="consol-history-table"><thead><tr><th>Date</th><th>Topics</th><th>Events Merged</th><th>Status</th></tr></thead><tbody>';
9958 events.forEach((ev) => {
9959 const ts = ev.ts || ev.timestamp || ev.created_at;
9960 const date = ts ? new Date(ts).toLocaleString() : '—';
9961 const rawTopics = ev.data?.topics_count;
9962 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
9963 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
9964 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
9965 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
9966 });
9967 html += '</tbody></table>';
9968 container.innerHTML = html;
9969 }
9970
9971 async function refreshConsolidationCard() {
9972 const card = el('consolidation-card');
9973 const badge = el('consol-status-badge');
9974 const lastPass = el('consol-last-pass');
9975 const nextPass = el('consol-next-pass');
9976 const quotaMeter = el('consol-quota-meter');
9977 const quotaLabel = el('consol-quota-label');
9978 const quotaFill = el('consol-quota-fill');
9979 const btnNow = el('btn-consol-now');
9980 if (!card) return;
9981
9982 try {
9983 const s = await api('/api/v1/settings');
9984 const mode = consolModeFromSettings(s);
9985 if (mode === 'off') {
9986 card.style.display = 'none';
9987 return;
9988 }
9989 card.style.display = '';
9990
9991 if (mode === 'hosted') {
9992 try {
9993 const st = await api('/api/v1/memory/consolidate/status');
9994 if (badge) {
9995 badge.textContent = '● Active (hosted)';
9996 badge.className = 'consol-badge consol-badge-success';
9997 }
9998 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
9999 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
10000
10001 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
10002 const passUsed = st.pass_count_month ?? 0;
10003 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10004 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10005 if (quotaMeter) {
10006 if (passLimit === null) {
10007 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
10008 if (quotaFill) quotaFill.style.width = '0%';
10009 } else if (passLimit > 0) {
10010 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
10011 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
10012 if (quotaFill) quotaFill.style.width = pct + '%';
10013 }
10014 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
10015 }
10016
10017 // Disable "Consolidate Now" during cooldown; show time remaining.
10018 const cooldown = st.cooldown_minutes ?? 0;
10019 if (btnNow && cooldown > 0) {
10020 btnNow.disabled = true;
10021 btnNow.textContent = 'Available in ' + cooldown + ' min';
10022 } else if (btnNow) {
10023 btnNow.disabled = false;
10024 btnNow.textContent = 'Consolidate Now';
10025 }
10026 } catch (_) {
10027 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
10028 }
10029 } else {
10030 if (badge) {
10031 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
10032 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
10033 }
10034 if (lastPass) lastPass.textContent = 'Last pass: —';
10035 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
10036 if (quotaMeter) quotaMeter.style.display = 'none';
10037 }
10038 } catch (_) {
10039 card.style.display = 'none';
10040 }
10041 }
10042
10043 const btnConsolNow = el('btn-consol-now');
10044 if (btnConsolNow) {
10045 btnConsolNow.addEventListener('click', async () => {
10046 setButtonBusy(btnConsolNow, true, 'Previewing…');
10047 try {
10048 const preview = await api('/api/v1/memory/consolidate', {
10049 method: 'POST',
10050 body: JSON.stringify({ dry_run: true }),
10051 });
10052 setButtonBusy(btnConsolNow, false);
10053 const topicsRaw = preview.topics;
10054 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
10055 const events = preview.total_events ?? 0;
10056 // Fetch current quota to show remaining passes in the preview dialog.
10057 let quotaLine = '';
10058 try {
10059 const st = await api('/api/v1/memory/consolidate/status');
10060 const passUsed = st.pass_count_month ?? 0;
10061 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10062 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10063 if (passLimit === null) {
10064 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
10065 } else if (passLimit > 0) {
10066 const remaining = Math.max(0, passLimit - passUsed);
10067 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
10068 }
10069 } catch (_) {}
10070 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
10071 if (!ok) return;
10072 setButtonBusy(btnConsolNow, true, 'Consolidating…');
10073 await api('/api/v1/memory/consolidate', {
10074 method: 'POST',
10075 body: JSON.stringify({ dry_run: false }),
10076 });
10077 if (typeof showToast === 'function') showToast('Consolidation complete.');
10078 refreshConsolidationCard();
10079 } catch (e) {
10080 const msg = e?.message || 'Consolidation failed';
10081 if (typeof showToast === 'function') showToast(msg, true);
10082 // Re-check cooldown after a rate-limit response so the button state updates.
10083 refreshConsolidationCard();
10084 }
10085 setButtonBusy(btnConsolNow, false);
10086 });
10087 }
10088
10089 const btnConsolHistory = el('btn-consol-history');
10090 if (btnConsolHistory) {
10091 btnConsolHistory.addEventListener('click', async () => {
10092 setButtonBusy(btnConsolHistory, true, 'Loading…');
10093 try {
10094 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
10095 const events = res.events || res.history || [];
10096 setButtonBusy(btnConsolHistory, false);
10097 const modal = document.createElement('div');
10098 modal.className = 'modal';
10099 modal.setAttribute('aria-modal', 'true');
10100 modal.innerHTML =
10101 '<div class="modal-backdrop"></div>' +
10102 '<div class="modal-card consol-history-modal">' +
10103 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
10104 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
10105 document.body.appendChild(modal);
10106 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
10107 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
10108 modal.querySelector('.modal-close').onclick = () => modal.remove();
10109 } catch (e) {
10110 setButtonBusy(btnConsolHistory, false);
10111 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
10112 }
10113 });
10114 }
10115
10116 function openSettingsConsolidationTab() {
10117 openSettings();
10118 document.querySelectorAll('.settings-tab').forEach((t) => {
10119 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
10120 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
10121 });
10122 document.querySelectorAll('.settings-panel').forEach((p) => {
10123 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
10124 });
10125 loadConsolidationSettings();
10126 }
10127
10128 const btnConsolSettings = el('btn-consol-settings');
10129 if (btnConsolSettings) {
10130 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
10131 }
10132
10133 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
10134 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
10135 // Billing consolidation row is populated inline in loadBillingPanel's try block.
10136 // We add to the existing billing flow by hooking the billing API response.
10137
10138 // Refresh consolidation card when dashboard renders
10139 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
10140 })();
File History 3 commits
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor 15 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 15 days ago