hub.js javascript
10,623 lines 429.8 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 40 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 if (typeof refreshAgentCredVaultSelect === 'function') refreshAgentCredVaultSelect();
879 applyHostedUiFromSettings(s);
880 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
881 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
882 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
883 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
884 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
885 const metaSelf = el('settings-bulk-metadata-self-only');
886 if (metaSelf) metaSelf.classList.remove('hidden');
887 applyMuseBridgePanel(s);
888 }
889
890 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
891 function applyMuseBridgePanel(s) {
892 if (!s || typeof s !== 'object') return;
893 const mb = s.muse_bridge;
894 const statusEl = el('settings-muse-status');
895 const envHint = el('settings-muse-env-hint');
896 const input = el('settings-muse-url');
897 const saveBtn = el('btn-settings-muse-save');
898 const msg = el('settings-muse-msg');
899 if (msg) {
900 msg.textContent = '';
901 msg.className = 'settings-msg';
902 }
903 if (!mb) {
904 if (statusEl) statusEl.textContent = '—';
905 if (input) {
906 input.value = '';
907 input.disabled = true;
908 }
909 if (saveBtn) saveBtn.classList.add('hidden');
910 return;
911 }
912 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
913 const isAdmin = s.role === 'admin';
914 if (statusEl) {
915 statusEl.textContent =
916 mb.enabled && mb.origin
917 ? 'Server status: linked — ' + mb.origin
918 : 'Server status: Muse link not configured for this Hub.';
919 }
920 if (envHint) {
921 envHint.classList.toggle('hidden', !mb.env_override_active);
922 envHint.textContent = mb.env_override_active
923 ? '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.'
924 : '';
925 }
926 if (input) {
927 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
928 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
929 input.disabled = !canEdit;
930 input.title = canEdit
931 ? ''
932 : isHosted
933 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
934 : !isAdmin
935 ? 'Only admins can save the Muse URL.'
936 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
937 }
938 if (saveBtn) {
939 const show = !isHosted && isAdmin && mb.url_editable === true;
940 saveBtn.classList.toggle('hidden', !show);
941 }
942 }
943
944 function showLoginChrome() {
945 btnLogout.classList.add('hidden');
946 userName.textContent = '';
947 if (!providers) return;
948 if (providers.google) btnLoginGoogle.classList.remove('hidden');
949 if (providers.github) btnLoginGithub.classList.remove('hidden');
950 if (!providers.google && !providers.github) {
951 oauthNotConfigured.classList.remove('hidden');
952 if (loginIntro) loginIntro.classList.add('hidden');
953 }
954 }
955
956 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
957 let onboardingModulePromise = null;
958 function loadOnboardingModule() {
959 if (!onboardingModulePromise) {
960 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
961 }
962 return onboardingModulePromise;
963 }
964
965 function getOnboardingUserKey() {
966 if (!token) return '';
967 try {
968 const payload = JSON.parse(atob(token.split('.')[1]));
969 return String(payload.sub || payload.email || 'unknown');
970 } catch (_) {
971 return 'unknown';
972 }
973 }
974
975 /**
976 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
977 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
978 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
979 */
980 function wizardHostedFromContext(settingsPayload) {
981 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
982 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
983 if (vd === 'canister') return true;
984 try {
985 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
986 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
987 } catch (_) {}
988 return false;
989 }
990
991 function persistOnboardingProgress(mod, partial) {
992 const userKey = getOnboardingUserKey();
993 const isHosted = wizardHostedFromContext();
994 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
995 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
996 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
997 st = mod.createFreshState(userKey, hostingPath);
998 }
999 Object.assign(st, partial);
1000 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1001 }
1002
1003 let onboardingWizardBindingsDone = false;
1004 let onboardingRenderStep = function () {};
1005
1006 function closeOnboardingWizardResume() {
1007 const modal = el('modal-onboarding');
1008 if (!modal || modal.classList.contains('hidden')) return;
1009 modal.classList.add('hidden');
1010 }
1011
1012 function closeOnboardingWizardDismiss() {
1013 loadOnboardingModule()
1014 .then((mod) => {
1015 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1016 updateEmptyVaultStripVisibility();
1017 })
1018 .catch(function () {});
1019 const modal = el('modal-onboarding');
1020 if (modal) modal.classList.add('hidden');
1021 }
1022
1023 function bindOnboardingWizardOnce(mod) {
1024 if (onboardingWizardBindingsDone) return;
1025 onboardingWizardBindingsDone = true;
1026 const modal = el('modal-onboarding');
1027 const closeBtn = el('modal-onboarding-close');
1028 const backdrop = el('modal-onboarding-backdrop');
1029 const btnSkip = el('btn-onboarding-skip');
1030 const btnBack = el('btn-onboarding-back');
1031 const btnNext = el('btn-onboarding-next');
1032 const body = el('onboarding-step-body');
1033 const progress = el('onboarding-progress');
1034 const live = el('onboarding-live');
1035 const secondary = el('onboarding-secondary-actions');
1036
1037 function handleSecondaryAction(id) {
1038 /* 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. */
1039 if (id === 'projectsHelp') {
1040 openProjectsHelpModal();
1041 return;
1042 }
1043 if (id === 'howToKnowledge') {
1044 openHowToUse('knowledge-agents');
1045 return;
1046 }
1047 if (id === 'openSettingsBackup') {
1048 openSettings();
1049 return;
1050 }
1051 if (id === 'openSettingsIntegrations') {
1052 openSettingsIntegrationsTab();
1053 return;
1054 }
1055 if (id === 'howToSetup4') {
1056 openHowToUse('setup', 'how-to-step-selfhosted-index');
1057 return;
1058 }
1059 if (id === 'howToSetup3') {
1060 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1061 return;
1062 }
1063 if (id === 'openWhyTokenDoc') {
1064 window.open(
1065 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1066 '_blank',
1067 'noopener,noreferrer',
1068 );
1069 return;
1070 }
1071 if (id === 'openImportModal') {
1072 closeOnboardingWizardResume();
1073 openImportModal();
1074 return;
1075 }
1076 if (id === 'openImportSourcesDoc') {
1077 window.open(
1078 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1079 '_blank',
1080 'noopener,noreferrer',
1081 );
1082 return;
1083 }
1084 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1085 window.open(
1086 id === 'openAgentDocProposals'
1087 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1088 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1089 '_blank',
1090 'noopener,noreferrer',
1091 );
1092 return;
1093 }
1094 if (id === 'focusSuggestedTab') {
1095 closeOnboardingWizardResume();
1096 switchHubMainTab('suggested');
1097 return;
1098 }
1099 }
1100
1101 onboardingRenderStep = function renderOnboardingStep() {
1102 const userKey = getOnboardingUserKey();
1103 const isHosted = wizardHostedFromContext();
1104 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1105 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1106 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1107 st = mod.createFreshState(userKey, hostingPath);
1108 }
1109 const total = mod.getStepCount(isHosted);
1110 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1111 const content = mod.getStepContent(isHosted, idx);
1112 if (body) body.innerHTML = content ? content.bodyHtml : '';
1113 if (content && content.id === 'h-imports' && body) {
1114 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1115 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1116 }
1117
1118 if (progress) {
1119 progress.innerHTML = '';
1120 for (let i = 0; i < total; i++) {
1121 const d = document.createElement('span');
1122 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1123 d.title = 'Step ' + (i + 1) + ' of ' + total;
1124 progress.appendChild(d);
1125 }
1126 }
1127 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1128
1129 if (btnBack) btnBack.disabled = idx <= 0;
1130 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1131
1132 if (secondary) {
1133 secondary.innerHTML = '';
1134 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1135 const b = document.createElement('button');
1136 b.type = 'button';
1137 b.className = 'btn-link btn-link-small';
1138 b.textContent = a.label;
1139 b.addEventListener('click', () => handleSecondaryAction(a.id));
1140 secondary.appendChild(b);
1141 });
1142 }
1143 };
1144
1145 if (btnBack) {
1146 btnBack.addEventListener('click', () => {
1147 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1148 if (!st || st.status !== 'in_progress') return;
1149 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1150 onboardingRenderStep();
1151 });
1152 }
1153 if (btnNext) {
1154 btnNext.addEventListener('click', () => {
1155 const userKey = getOnboardingUserKey();
1156 const isHosted = wizardHostedFromContext();
1157 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1158 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1159 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1160 const total = mod.getStepCount(isHosted);
1161 if (st.stepIndex >= total - 1) {
1162 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1163 if (modal) modal.classList.add('hidden');
1164 return;
1165 }
1166 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1167 onboardingRenderStep();
1168 });
1169 }
1170 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1171 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1172 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1173
1174 modal.addEventListener('click', (ev) => {
1175 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1176 if (!copyBtn || !body) return;
1177 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1178 const txt = ta && ta.value ? String(ta.value) : '';
1179 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1180 ev.preventDefault();
1181 void navigator.clipboard.writeText(txt).then(() => {
1182 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1183 });
1184 });
1185 }
1186
1187 async function openOnboardingWizard(opts) {
1188 const restart = opts && opts.restart;
1189 const mod = await loadOnboardingModule();
1190 bindOnboardingWizardOnce(mod);
1191 const userKey = getOnboardingUserKey();
1192 const isHosted = wizardHostedFromContext();
1193 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1194 if (restart) {
1195 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1196 } else {
1197 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1198 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1199 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1200 }
1201 }
1202 const modal = el('modal-onboarding');
1203 if (modal) modal.classList.remove('hidden');
1204 onboardingRenderStep();
1205 const btnNext = el('btn-onboarding-next');
1206 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1207 }
1208
1209 async function scheduleMaybeShowOnboardingWizard(s) {
1210 if (!token) return;
1211 if (params.get('open') === 'billing') return;
1212 try {
1213 const mod = await loadOnboardingModule();
1214 const userKey = getOnboardingUserKey();
1215 const isHosted = wizardHostedFromContext(s);
1216 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1217 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1218 if (st && st.userKey !== userKey) st = null;
1219 if (st && st.hostingPath !== hostingPath) st = null;
1220 if (!mod.shouldAutoOpenWizard(st, userKey, hostingPath)) return;
1221 const delayMs = pageLoadHadInviteQuery ? 2200 : 0;
1222 setTimeout(() => {
1223 void openOnboardingWizard({ restart: false });
1224 }, delayMs);
1225 } catch (_) {}
1226 }
1227
1228 function showMain() {
1229 if (app) app.classList.remove('login-screen');
1230 loginRequired.classList.add('hidden');
1231 main.classList.remove('hidden');
1232 btnHowToUse.classList.remove('hidden');
1233 if (btnSettings) btnSettings.classList.remove('hidden');
1234 browseToolbar.classList.remove('hidden');
1235 if (token) {
1236 btnLoginGoogle.classList.add('hidden');
1237 btnLoginGithub.classList.add('hidden');
1238 oauthNotConfigured.classList.add('hidden');
1239 btnLogout.classList.remove('hidden');
1240 try {
1241 const payload = JSON.parse(atob(token.split('.')[1]));
1242 userName.textContent = payload.name || payload.sub || 'Logged in';
1243 window.__hubUserRole = payload.role || 'member';
1244 const isViewer = window.__hubUserRole === 'viewer';
1245 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1246 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1247 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1248 refreshDeleteProjectPanelVisibility();
1249 } catch (_) {
1250 userName.textContent = 'Logged in';
1251 window.__hubUserRole = 'member';
1252 if (btnNewNote) btnNewNote.classList.remove('hidden');
1253 if (btnImport) btnImport.classList.remove('hidden');
1254 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1255 refreshDeleteProjectPanelVisibility();
1256 }
1257 } else {
1258 if (btnNewNote) btnNewNote.classList.add('hidden');
1259 if (btnImport) btnImport.classList.add('hidden');
1260 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1261 }
1262 hubRefreshIndexStaleBanner();
1263 }
1264
1265 function loginUrl(provider) {
1266 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1267 const invite = params.get('invite');
1268 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1269 }
1270 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1271 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1272 // because a direct window.location.href navigation has no retry mechanism.
1273 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1274 // guaranteed warm, and the OAuth redirect hits a hot instance.
1275 async function oauthNavigate(provider, btn) {
1276 const original = btn.textContent;
1277 btn.disabled = true;
1278 btn.textContent = 'Connecting…';
1279 try {
1280 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1281 // during this time so the user knows something is happening.
1282 await fetch(apiBase + '/api/v1/auth/providers', {
1283 cache: 'no-store',
1284 signal: AbortSignal.timeout(22000),
1285 });
1286 } catch (_) {
1287 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1288 // OAuth handler itself has the full 26 s budget once TCP is established.
1289 }
1290 window.location.href = loginUrl(provider);
1291 // Navigation is underway; restore button state in case the browser returns here.
1292 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1293 }
1294 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1295 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1296
1297 btnLogout.onclick = () => {
1298 // Revoke the refresh token server-side (real logout), then clear local state regardless
1299 // of whether the network call succeeds.
1300 try {
1301 fetch(apiBase + '/api/v1/auth/logout', {
1302 method: 'POST',
1303 credentials: 'include',
1304 cache: 'no-store',
1305 headers: { 'Content-Type': 'application/json' },
1306 }).catch(() => {});
1307 } catch (_) { /* best effort */ }
1308 token = null;
1309 localStorage.removeItem('hub_token');
1310 if (app) app.classList.add('login-screen');
1311 main.classList.add('hidden');
1312 browseToolbar.classList.add('hidden');
1313 btnNewNote.classList.add('hidden');
1314 if (btnImport) btnImport.classList.add('hidden');
1315 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1316 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1317 if (btnSettings) btnSettings.classList.add('hidden');
1318 closeOnboardingWizardResume();
1319 loginRequired.classList.remove('hidden');
1320 if (loginIntro) loginIntro.classList.remove('hidden');
1321 showLoginChrome();
1322 };
1323
1324 async function initProviders() {
1325 for (let attempt = 0; attempt < 3; attempt++) {
1326 try {
1327 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1328 if (!r.ok) throw new Error('providers');
1329 providers = await r.json();
1330 break;
1331 } catch (_) {
1332 if (attempt < 2) {
1333 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1334 continue;
1335 }
1336 providers = { google: false, github: false };
1337 oauthNotConfigured.classList.remove('hidden');
1338 if (loginIntro) loginIntro.classList.add('hidden');
1339 const first = oauthNotConfigured.querySelector('p');
1340 if (first) {
1341 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1342 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1343 if (isHosted && sameOrigin) {
1344 first.innerHTML =
1345 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1346 '</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.';
1347 } else if (isHosted && !sameOrigin) {
1348 first.innerHTML =
1349 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1350 '</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.';
1351 } else {
1352 first.innerHTML =
1353 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1354 escapeHtml(apiBase) +
1355 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1356 }
1357 }
1358 return;
1359 }
1360 }
1361
1362 if (!providers.google && !providers.github) {
1363 oauthNotConfigured.classList.remove('hidden');
1364 if (loginIntro) loginIntro.classList.add('hidden');
1365 } else {
1366 oauthNotConfigured.classList.add('hidden');
1367 if (loginIntro) loginIntro.classList.remove('hidden');
1368 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1369 const loggedIn =
1370 Boolean(token) ||
1371 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1372 if (!loggedIn) {
1373 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1374 if (providers.github) btnLoginGithub.classList.remove('hidden');
1375 }
1376 }
1377 }
1378
1379 if (token) {
1380 if (params.get('invite')) {
1381 (async () => {
1382 const inviteToken = params.get('invite');
1383 let lastErr;
1384 for (let attempt = 0; attempt < 3; attempt++) {
1385 try {
1386 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1387 const u = new URL(location.href);
1388 u.searchParams.delete('invite');
1389 u.searchParams.set('invite_accepted', '1');
1390 history.replaceState({}, '', u.toString());
1391 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1392 return;
1393 } catch (e) {
1394 lastErr = e;
1395 const code = e && e.code;
1396 const msg = String(e && e.message ? e.message : e || '');
1397 const staleInvite =
1398 code === 'NOT_FOUND' ||
1399 code === 'EXPIRED' ||
1400 /not found|already used|expired/i.test(msg);
1401 if (staleInvite) {
1402 const u = new URL(location.href);
1403 u.searchParams.delete('invite');
1404 history.replaceState({}, '', u.toString());
1405 if (code === 'EXPIRED' && typeof showToast === 'function') {
1406 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
1407 }
1408 return;
1409 }
1410 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
1411 }
1412 }
1413 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
1414 })();
1415 }
1416 showMain();
1417 getImageProxyToken().catch(function () {});
1418 (async function ensureVaultAndSwitcherThenLoad() {
1419 let settingsPayload = null;
1420 try {
1421 settingsPayload = await api('/api/v1/settings');
1422 applySettingsPayloadToHubChrome(settingsPayload);
1423 } catch (_) {}
1424 syncHubListSortUI('notes');
1425 setProposalFiltersBarVisible(false);
1426 refreshNewProposalTabVisibility();
1427 loadFacets();
1428 loadNotes();
1429 loadProposals();
1430 loadActivity();
1431 renderPresets();
1432 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
1433 })();
1434 initProviders();
1435 if (params.get('open') === 'billing') {
1436 const checkoutSuccess = params.get('checkout') === 'success';
1437 // Clean up params before opening so back-button doesn't re-trigger.
1438 const u = new URL(location.href);
1439 u.searchParams.delete('open');
1440 u.searchParams.delete('checkout');
1441 history.replaceState({}, '', u.toString());
1442 // Small delay so the main Hub has rendered before the modal opens.
1443 setTimeout(() => {
1444 openSettingsBillingTab();
1445 if (checkoutSuccess && typeof showToast === 'function') {
1446 showToast('Subscription activated — welcome to your new plan!');
1447 }
1448 }, 400);
1449 }
1450 if (params.get('github_connected') === '1') {
1451 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
1452 setTimeout(() => {
1453 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
1454 const u = new URL(location.href);
1455 u.searchParams.delete('github_connected');
1456 history.replaceState({}, '', u.toString());
1457 }, 500);
1458 } else if (params.get('github_connect_error')) {
1459 setTimeout(() => {
1460 const code = params.get('github_connect_error');
1461 const msg =
1462 code === 'blob_storage'
1463 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
1464 : 'GitHub connect: ' + code;
1465 if (typeof showToast === 'function') showToast(msg, true);
1466 const u = new URL(location.href);
1467 u.searchParams.delete('github_connect_error');
1468 history.replaceState({}, '', u.toString());
1469 }, 500);
1470 }
1471 } else {
1472 if (app) app.classList.add('login-screen');
1473 main.classList.add('hidden');
1474 loginRequired.classList.remove('hidden');
1475 btnNewNote.classList.add('hidden');
1476 if (btnImport) btnImport.classList.add('hidden');
1477 const inviteBanner = el('login-invite-banner');
1478 if (inviteBanner && params.get('invite')) {
1479 inviteBanner.textContent = "You've been invited. Sign in to join.";
1480 inviteBanner.classList.remove('hidden');
1481 }
1482 initProviders();
1483 }
1484 refreshApiBaseFootgunBanner();
1485 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
1486 setTimeout(() => {
1487 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1488 const u = new URL(location.href);
1489 u.searchParams.delete('invite_accepted');
1490 history.replaceState({}, '', u.pathname + u.search);
1491 }, 500);
1492 }
1493
1494 function dateSlice(d) {
1495 if (!d || typeof d !== 'string') return '';
1496 return d.trim().slice(0, 10);
1497 }
1498
1499 /** 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. */
1500 function materializeFrontmatter(fm) {
1501 if (fm == null) return {};
1502 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
1503 if (typeof fm === 'string') {
1504 let cur = fm.replace(/^\uFEFF/, '').trim();
1505 if (!cur) return {};
1506 for (let i = 0; i < 8; i++) {
1507 try {
1508 const o = JSON.parse(cur);
1509 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
1510 if (typeof o === 'string') {
1511 const next = o.trim();
1512 if (next === cur) return {};
1513 cur = next;
1514 continue;
1515 }
1516 return {};
1517 } catch {
1518 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
1519 try {
1520 const inner = JSON.parse(cur);
1521 if (typeof inner === 'string') {
1522 cur = inner.trim();
1523 continue;
1524 }
1525 } catch {
1526 /* fall through */
1527 }
1528 }
1529 return {};
1530 }
1531 }
1532 return {};
1533 }
1534 return {};
1535 }
1536
1537 function tagsFromFrontmatter(fm) {
1538 const raw = fm && fm.tags;
1539 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1540 if (typeof raw === 'string' && raw.trim()) {
1541 return raw
1542 .split(/[,\n]/)
1543 .map((s) => s.trim())
1544 .filter(Boolean);
1545 }
1546 return [];
1547 }
1548
1549 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
1550 function isoDateLocalFromMs(ms) {
1551 const d = new Date(ms);
1552 if (Number.isNaN(d.getTime())) return null;
1553 const y = d.getFullYear();
1554 const mo = String(d.getMonth() + 1).padStart(2, '0');
1555 const day = String(d.getDate()).padStart(2, '0');
1556 return y + '-' + mo + '-' + day;
1557 }
1558
1559 /**
1560 * Calendar bucket for Hub list/calendar/overview.
1561 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
1562 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
1563 */
1564 function calendarDisplayDayKey(raw) {
1565 if (raw == null) return null;
1566 const s = String(raw).trim();
1567 if (!s) return null;
1568 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
1569 const ms = Date.parse(s);
1570 if (Number.isNaN(ms)) return s.slice(0, 10);
1571 return isoDateLocalFromMs(ms);
1572 }
1573
1574 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
1575 function inferredDisplayDateFromNotePath(notePath) {
1576 if (!notePath || typeof notePath !== 'string') return null;
1577 const base = notePath.split('/').pop() || '';
1578 const m = /^note-(\d{10,})\.md$/i.exec(base);
1579 if (!m) return null;
1580 const ms = Number(m[1]);
1581 if (!Number.isFinite(ms)) return null;
1582 return isoDateLocalFromMs(ms);
1583 }
1584
1585 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
1586 function listItemDisplayDate(n, fm) {
1587 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
1588 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
1589 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
1590 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
1591 const inferred = inferredDisplayDateFromNotePath(n.path);
1592 return inferred || null;
1593 }
1594
1595 function noteSortOrCalendarDay(n) {
1596 const raw = n.date || n.updated || '';
1597 return calendarDisplayDayKey(raw) || dateSlice(raw);
1598 }
1599
1600 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
1601 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
1602 const HUB_SORT_NOTES_OPTS = [
1603 { v: 'date_desc', l: 'Newest first' },
1604 { v: 'date_asc', l: 'Oldest first' },
1605 { v: 'year_desc', l: 'Year (newest first)' },
1606 { v: 'year_asc', l: 'Year (oldest first)' },
1607 { v: 'path_asc', l: 'Path A–Z' },
1608 { v: 'title_asc', l: 'Title A–Z' },
1609 ];
1610 const HUB_SORT_PROP_OPTS = [
1611 { v: 'updated_desc', l: 'Newest first' },
1612 { v: 'updated_asc', l: 'Oldest first' },
1613 { v: 'path_asc', l: 'Path A–Z' },
1614 { v: 'status_asc', l: 'Status A–Z' },
1615 ];
1616
1617 function hubListSortGetSelect() {
1618 return el('hub-list-sort');
1619 }
1620
1621 function syncHubListSortUI(activeTab) {
1622 const sel = hubListSortGetSelect();
1623 if (!sel) return;
1624 const isNotes = activeTab === 'notes';
1625 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
1626 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
1627 let saved = '';
1628 try {
1629 saved = localStorage.getItem(key) || '';
1630 } catch (_) {}
1631 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
1632 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
1633 sel.value = saved;
1634 }
1635
1636 function setProposalFiltersBarVisible(show) {
1637 const bar = el('proposal-filters-bar');
1638 if (bar) bar.classList.toggle('hidden', !show);
1639 }
1640
1641 function refreshNewProposalTabVisibility() {
1642 const btn = el('btn-new-proposal');
1643 if (!btn) return;
1644 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1645 const show = tab === 'suggested' && hubUserCanWriteNotes();
1646 btn.classList.toggle('hidden', !show);
1647 }
1648
1649 function applySortedNotesClient(notes) {
1650 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1651 if (tab !== 'notes') return notes;
1652 const S = globalThis.HubListSort;
1653 const sel = hubListSortGetSelect();
1654 const mode = sel && sel.value ? sel.value : 'date_desc';
1655 if (!S || typeof S.sortNotesList !== 'function') return notes;
1656 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
1657 }
1658
1659 function applySortedProposalsClient(list) {
1660 const S = globalThis.HubListSort;
1661 const sel = hubListSortGetSelect();
1662 const mode = sel && sel.value ? sel.value : 'updated_desc';
1663 if (!S || typeof S.sortProposalsList !== 'function') return list;
1664 return S.sortProposalsList(list, mode);
1665 }
1666
1667 function normalizeHubListItem(n) {
1668 if (!n || typeof n !== 'object') return n;
1669 const fm = materializeFrontmatter(n.frontmatter);
1670 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
1671 const displayDate = listItemDisplayDate(n, fm);
1672 const updated =
1673 n.updated != null
1674 ? String(n.updated)
1675 : fm.knowtation_edited_at != null
1676 ? String(fm.knowtation_edited_at)
1677 : null;
1678 return {
1679 ...n,
1680 frontmatter: fm,
1681 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
1682 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
1683 tags,
1684 date: displayDate,
1685 updated,
1686 };
1687 }
1688
1689 function facetsAreEmpty(f) {
1690 if (!f || typeof f !== 'object') return true;
1691 const pl = f.projects && f.projects.length;
1692 const tl = f.tags && f.tags.length;
1693 const fl = f.folders && f.folders.length;
1694 return !pl && !tl && !fl;
1695 }
1696
1697 async function deriveFacetsFromNotes() {
1698 const out = await api('/api/v1/notes?limit=500&offset=0');
1699 const projects = new Set();
1700 const tags = new Set();
1701 const folders = new Set();
1702 for (const raw of out.notes || []) {
1703 const n = normalizeHubListItem(raw);
1704 if (n.path) {
1705 const seg = String(n.path).split('/')[0];
1706 if (seg) folders.add(seg);
1707 }
1708 if (n.project) projects.add(String(n.project));
1709 (n.tags || []).forEach((t) => tags.add(String(t)));
1710 }
1711 return {
1712 projects: [...projects].sort((a, b) => a.localeCompare(b)),
1713 tags: [...tags].sort((a, b) => a.localeCompare(b)),
1714 folders: [...folders].sort((a, b) => a.localeCompare(b)),
1715 };
1716 }
1717
1718 async function fetchFacetsResolved() {
1719 let facets = await api('/api/v1/notes/facets');
1720 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
1721 return facets;
1722 }
1723
1724 function hubRowIsApprovalLog(n) {
1725 if (!n || !n.path) return false;
1726 const path = String(n.path).replace(/\\/g, '/');
1727 if (path === 'approvals' || path.startsWith('approvals/')) return true;
1728 const k =
1729 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
1730 return String(k) === 'approval_log';
1731 }
1732
1733 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
1734 function applyVaultListFilters(notes, opts) {
1735 let out = notes.slice();
1736 if (opts.folder) {
1737 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
1738 const prefix = f + '/';
1739 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
1740 }
1741 if (opts.project) {
1742 const p = normSlug(opts.project);
1743 out = out.filter(
1744 (n) =>
1745 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
1746 );
1747 }
1748 if (opts.tag) {
1749 const t = normSlug(opts.tag);
1750 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
1751 }
1752 if (opts.since) {
1753 const s = dateSlice(opts.since);
1754 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
1755 }
1756 if (opts.until) {
1757 const u = dateSlice(opts.until);
1758 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
1759 }
1760 const cs = opts.content_scope;
1761 if (cs === 'notes') {
1762 out = out.filter((n) => !hubRowIsApprovalLog(n));
1763 } else if (cs === 'approval_logs') {
1764 out = out.filter((n) => hubRowIsApprovalLog(n));
1765 }
1766 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
1767 if (opts.network) {
1768 const net = String(opts.network).trim().toLowerCase();
1769 out = out.filter((n) => {
1770 const v = n.frontmatter?.network ?? n.network;
1771 return v != null && String(v).trim().toLowerCase() === net;
1772 });
1773 }
1774 if (opts.wallet_address) {
1775 const wa = String(opts.wallet_address).trim().toLowerCase();
1776 out = out.filter((n) => {
1777 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
1778 return v != null && String(v).trim().toLowerCase() === wa;
1779 });
1780 }
1781 if (opts.payment_status) {
1782 const ps = String(opts.payment_status).trim().toLowerCase();
1783 out = out.filter((n) => {
1784 const v = n.frontmatter?.payment_status ?? n.payment_status;
1785 return v != null && String(v).trim().toLowerCase() === ps;
1786 });
1787 }
1788 return out;
1789 }
1790
1791 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
1792 const HUB_RESERVED_FM_KEYS = new Set([
1793 'knowtation_editor',
1794 'knowtation_edited_at',
1795 'author_kind',
1796 'knowtation_proposed_by',
1797 'knowtation_approved_by',
1798 ]);
1799
1800 function stripReservedHubFm(fm) {
1801 const out = {};
1802 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
1803 for (const [k, v] of Object.entries(fm)) {
1804 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
1805 out[k] = v;
1806 }
1807 return out;
1808 }
1809
1810 /**
1811 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
1812 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
1813 */
1814 function stringifyNotePostPayload(path, body, frontmatter) {
1815 const fmStr =
1816 typeof frontmatter === 'string'
1817 ? frontmatter
1818 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
1819 return JSON.stringify({ path, body, frontmatter: fmStr });
1820 }
1821
1822 const DETAIL_EDIT_FM_KEYS = [
1823 'title',
1824 'date',
1825 'project',
1826 'tags',
1827 'causal_chain_id',
1828 'entity',
1829 'episode_id',
1830 'follows',
1831 ];
1832
1833 function mergedFrontmatterForDetailSave() {
1834 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
1835 const preserved = {};
1836 for (const [k, v] of Object.entries(base)) {
1837 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
1838 }
1839 const dateVal =
1840 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
1841 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
1842 const tTitle = title.trim();
1843 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1844 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
1845 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
1846 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
1847 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
1848 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
1849 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
1850 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
1851 const follows = followsRaw
1852 ? followsRaw.includes(',')
1853 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
1854 : followsRaw
1855 : undefined;
1856 const out = { ...preserved, date: dateVal };
1857 if (tTitle) out.title = tTitle;
1858 else delete out.title;
1859 if (project) out.project = project;
1860 else delete out.project;
1861 if (tags) out.tags = tags;
1862 else delete out.tags;
1863 if (causalChain) out.causal_chain_id = causalChain;
1864 else delete out.causal_chain_id;
1865 if (entity.length) out.entity = entity;
1866 else delete out.entity;
1867 if (episode) out.episode_id = episode;
1868 else delete out.episode_id;
1869 if (follows) out.follows = follows;
1870 else delete out.follows;
1871 return out;
1872 }
1873
1874 function fillDetailEditFieldsFromFrontmatter(fm) {
1875 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
1876 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1877 const savedProj = f.project != null ? String(f.project).trim() : '';
1878 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
1879 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
1880 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
1881 if (el('detail-edit-project')) {
1882 const inp = el('detail-edit-project');
1883 if (pathProj) {
1884 inp.value = pathProj;
1885 inp.readOnly = true;
1886 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
1887 } else {
1888 inp.readOnly = false;
1889 inp.title = '';
1890 inp.value = savedProj;
1891 }
1892 }
1893 const hint = el('detail-edit-project-hint');
1894 if (hint) {
1895 if (pathProj) {
1896 hint.classList.remove('hidden');
1897 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
1898 hint.textContent = mismatch
1899 ? 'Path implies project «' +
1900 pathProj +
1901 '»; saved frontmatter had «' +
1902 savedProj +
1903 '». Saving will store «' +
1904 pathProj +
1905 '» to match the path.'
1906 : 'Project slug matches vault path projects/' + pathProj + '/.';
1907 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
1908 } else {
1909 hint.classList.remove('hidden');
1910 hint.className = 'muted small detail-project-hint';
1911 hint.textContent =
1912 '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.';
1913 }
1914 }
1915 const pathTypoEl = el('detail-edit-path-typo-hint');
1916 if (pathTypoEl && currentOpenNote) {
1917 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
1918 if (sug) {
1919 pathTypoEl.textContent =
1920 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
1921 sug +
1922 '. Rename or move the file in your vault (path cannot be edited here).';
1923 pathTypoEl.className = 'muted small detail-project-hint warn';
1924 pathTypoEl.classList.remove('hidden');
1925 } else {
1926 pathTypoEl.textContent = '';
1927 pathTypoEl.className = 'muted small detail-project-hint hidden';
1928 pathTypoEl.classList.add('hidden');
1929 }
1930 }
1931 const tags = f.tags;
1932 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
1933 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
1934 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
1935 const ent = f.entity;
1936 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
1937 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
1938 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
1939 const fol = f.follows;
1940 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
1941 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
1942 }
1943
1944 async function loadFacets() {
1945 try {
1946 const savedProject = filterProject.value;
1947 const savedTag = filterTag.value;
1948 const savedFolder = filterFolder.value;
1949 const savedNetwork = filterNetwork ? filterNetwork.value : '';
1950 const savedWallet = filterWallet ? filterWallet.value : '';
1951 const facets = await fetchFacetsResolved();
1952 lastHubFacets = facets;
1953 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
1954 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
1955 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
1956 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
1957 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
1958 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
1959 // Phase 12 — blockchain filter dropdowns (hidden when no data)
1960 if (filterNetwork) {
1961 const nets = facets.networks || [];
1962 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
1963 filterNetwork.classList.toggle('hidden', nets.length === 0);
1964 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
1965 }
1966 if (filterWallet) {
1967 const wallets = facets.wallets || [];
1968 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
1969 filterWallet.classList.toggle('hidden', wallets.length === 0);
1970 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
1971 }
1972 renderFilterChips(facets);
1973 hydrateFullCreateProjectSlugSelect(facets);
1974 hydrateImportCreateProjectSlugSelect(facets);
1975 } catch (_) {
1976 renderFilterChips(null);
1977 lastHubFacets = null;
1978 hydrateFullCreateProjectSlugSelect(null);
1979 hydrateImportCreateProjectSlugSelect(null);
1980 }
1981 }
1982
1983 function normSlug(s) {
1984 return String(s || '')
1985 .toLowerCase()
1986 .replace(/[^a-z0-9-]/g, '-')
1987 .replace(/-+/g, '-')
1988 .replace(/^-|-$/g, '');
1989 }
1990
1991 /**
1992 * First path segment after `projects/` (vault-relative). Used so project frontmatter
1993 * stays aligned with on-disk layout (projects/<slug>/…).
1994 */
1995 function projectSlugFromProjectsPath(path) {
1996 if (!path || typeof path !== 'string') return null;
1997 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
1998 return m ? m[1] : null;
1999 }
2000
2001 /**
2002 * Common typo: vault path starts with `project/` instead of `projects/`.
2003 * Returns the same path with the corrected prefix, or null if no typo.
2004 */
2005 function projectsPathTypoSuggestion(path) {
2006 const p = String(path || '').trim();
2007 if (!p) return null;
2008 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2009 return null;
2010 }
2011
2012 function normalizeProjectKeyForSimilarity(s) {
2013 return String(s || '')
2014 .toLowerCase()
2015 .trim()
2016 .replace(/[\s_]+/g, '-')
2017 .replace(/-+/g, '-')
2018 .replace(/^-|-$/g, '');
2019 }
2020
2021 function levenshteinHub(a, b) {
2022 const m = a.length;
2023 const n = b.length;
2024 if (!m) return n;
2025 if (!n) return m;
2026 const row = new Array(n + 1);
2027 for (let j = 0; j <= n; j++) row[j] = j;
2028 for (let i = 1; i <= m; i++) {
2029 let prev = row[0];
2030 row[0] = i;
2031 for (let j = 1; j <= n; j++) {
2032 const cur = row[j];
2033 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2034 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2035 prev = cur;
2036 }
2037 }
2038 return row[n];
2039 }
2040
2041 /**
2042 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2043 * Exact normSlug match returns null (no warning).
2044 */
2045 function findSimilarFacetProject(userSlug, projectsArr) {
2046 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2047 const uNorm = normSlug(String(userSlug));
2048 if (!uNorm) return null;
2049 for (const p of projectsArr) {
2050 if (normSlug(String(p)) === uNorm) return null;
2051 }
2052 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2053 let best = null;
2054 let bestScore = Infinity;
2055 for (const p of projectsArr) {
2056 const pv = String(p).trim();
2057 if (!pv) continue;
2058 const pNorm = normSlug(pv);
2059 if (!pNorm) continue;
2060 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2061 let score = Infinity;
2062 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2063 if (score > 0) {
2064 const a = normalizeProjectKeyForSimilarity(userSlug);
2065 const b = normalizeProjectKeyForSimilarity(pv);
2066 const d = levenshteinHub(a, b);
2067 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2068 }
2069 if (score > 0) {
2070 const a = normalizeProjectKeyForSimilarity(userSlug);
2071 const b = normalizeProjectKeyForSimilarity(pv);
2072 const shorter = a.length <= b.length ? a : b;
2073 const longer = a.length <= b.length ? b : a;
2074 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2075 score = Math.min(score, longer.length - shorter.length + 0.5);
2076 }
2077 }
2078 if (score < bestScore) {
2079 bestScore = score;
2080 best = pv;
2081 }
2082 }
2083 return bestScore < 10 ? best : null;
2084 }
2085
2086 function collectProjectSubroots(slug, folderStrings) {
2087 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2088 const subs = new Set();
2089 for (const f of folderStrings || []) {
2090 if (!f || typeof f !== 'string') continue;
2091 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2092 if (!n.startsWith(prefix)) continue;
2093 const rest = n.slice(prefix.length);
2094 if (!rest) continue;
2095 const first = rest.split('/')[0];
2096 if (first) subs.add(first);
2097 }
2098 return [...subs].sort((a, b) => a.localeCompare(b));
2099 }
2100
2101 function fullCreatePathFilename(pathVal) {
2102 const t = String(pathVal || '').trim();
2103 const parts = t.split('/').filter(Boolean);
2104 const last = parts[parts.length - 1];
2105 if (last && /\.md$/i.test(last)) return last;
2106 return 'note-' + Date.now() + '.md';
2107 }
2108
2109 function mergeFolderStringsForSubroots() {
2110 const out = new Set();
2111 for (const f of lastVaultFoldersForCreate || []) {
2112 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2113 }
2114 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2115 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2116 }
2117 return [...out];
2118 }
2119
2120 function updateFullCreatePathLayoutVisibility() {
2121 const slugSel = el('full-create-project-slug');
2122 const subWrap = el('full-create-project-subroot-wrap');
2123 const nonProj = el('full-create-nonproject-folder-wrap');
2124 const subSel = el('full-create-project-subroot');
2125 if (!slugSel) return;
2126 const v = slugSel.value;
2127 const useProject = v && v !== '__custom__';
2128 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2129 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2130 if (subSel) subSel.disabled = !useProject;
2131 }
2132
2133 function refreshFullCreateSubrootSelect() {
2134 const slugSel = el('full-create-project-slug');
2135 const subSel = el('full-create-project-subroot');
2136 if (!slugSel || !subSel) return;
2137 const slug = slugSel.value;
2138 const preserve = subSel.value;
2139 if (!slug || slug === '__custom__') {
2140 subSel.innerHTML = '';
2141 subSel.disabled = true;
2142 return;
2143 }
2144 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2145 const head = document.createElement('option');
2146 head.value = '';
2147 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2148 subSel.innerHTML = '';
2149 subSel.appendChild(head);
2150 for (const s of subs) {
2151 const o = document.createElement('option');
2152 o.value = s;
2153 o.textContent = s;
2154 subSel.appendChild(o);
2155 }
2156 const custom = document.createElement('option');
2157 custom.value = '__custom_sub__';
2158 custom.textContent = 'Custom (edit path)';
2159 subSel.appendChild(custom);
2160 subSel.disabled = false;
2161 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2162 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2163 else if (subs.includes('inbox')) subSel.value = 'inbox';
2164 else if (subs.length === 1) subSel.value = subs[0];
2165 else subSel.value = '';
2166 }
2167
2168 function composeFullPathFromCreatePickers() {
2169 const slugSel = el('full-create-project-slug');
2170 const subSel = el('full-create-project-subroot');
2171 const pathInp = el('full-path');
2172 if (!slugSel || !pathInp) return;
2173 const slugVal = slugSel.value;
2174 if (!slugVal || slugVal === '__custom__') return;
2175 if (subSel && subSel.value === '__custom_sub__') return;
2176 const sub =
2177 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2178 const fname = fullCreatePathFilename(pathInp.value);
2179 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2180 pathInp.value = base;
2181 }
2182
2183 function syncFullCreatePickersFromPath() {
2184 const slugSel = el('full-create-project-slug');
2185 const subSel = el('full-create-project-subroot');
2186 const pathInp = el('full-path');
2187 if (!slugSel || !pathInp) return;
2188 const raw = pathInp.value.trim();
2189 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2190 if (!m) {
2191 slugSel.value = raw ? '__custom__' : '';
2192 refreshFullCreateSubrootSelect();
2193 updateFullCreatePathLayoutVisibility();
2194 return;
2195 }
2196 const diskSlug = m[1];
2197 const rest = m[2];
2198 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2199 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2200 if (match) slugSel.value = match;
2201 else slugSel.value = '__custom__';
2202 refreshFullCreateSubrootSelect();
2203 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2204 const segments = rest.split('/').filter(Boolean);
2205 const lastSeg = segments[segments.length - 1];
2206 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2207 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2208 const firstDir = dirParts[0] || '';
2209 const allowed = new Set(
2210 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2211 );
2212 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2213 else if (firstDir) subSel.value = '__custom_sub__';
2214 else subSel.value = '';
2215 }
2216 updateFullCreatePathLayoutVisibility();
2217 }
2218
2219 function hydrateFullCreateProjectSlugSelect(facets) {
2220 const sel = el('full-create-project-slug');
2221 if (!sel) return;
2222 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2223 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2224 const preserve = sel.value;
2225 sel.innerHTML =
2226 '<option value="">— Not under projects/ —</option>' +
2227 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2228 '<option value="__custom__">Custom (type full path)</option>';
2229 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2230 refreshFullCreateSubrootSelect();
2231 updateFullCreatePathLayoutVisibility();
2232 }
2233
2234 function updateImportPathLayoutVisibility() {
2235 const slugSel = el('import-create-project-slug');
2236 const subWrap = el('import-create-project-subroot-wrap');
2237 const nonProj = el('import-nonproject-folder-wrap');
2238 const subSel = el('import-create-project-subroot');
2239 if (!slugSel) return;
2240 const v = slugSel.value;
2241 const useProject = v && v !== '__custom__';
2242 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2243 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2244 if (subSel) subSel.disabled = !useProject;
2245 }
2246
2247 function refreshImportCreateSubrootSelect() {
2248 const slugSel = el('import-create-project-slug');
2249 const subSel = el('import-create-project-subroot');
2250 if (!slugSel || !subSel) return;
2251 const slug = slugSel.value;
2252 const preserve = subSel.value;
2253 if (!slug || slug === '__custom__') {
2254 subSel.innerHTML = '';
2255 subSel.disabled = true;
2256 return;
2257 }
2258 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2259 const head = document.createElement('option');
2260 head.value = '';
2261 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2262 subSel.innerHTML = '';
2263 subSel.appendChild(head);
2264 for (const s of subs) {
2265 const o = document.createElement('option');
2266 o.value = s;
2267 o.textContent = s;
2268 subSel.appendChild(o);
2269 }
2270 const custom = document.createElement('option');
2271 custom.value = '__custom_sub__';
2272 custom.textContent = 'Custom (edit path)';
2273 subSel.appendChild(custom);
2274 subSel.disabled = false;
2275 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2276 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2277 else if (subs.includes('inbox')) subSel.value = 'inbox';
2278 else if (subs.length === 1) subSel.value = subs[0];
2279 else subSel.value = '';
2280 }
2281
2282 function composeImportOutputDirFromPickers() {
2283 const slugSel = el('import-create-project-slug');
2284 const subSel = el('import-create-project-subroot');
2285 const outInp = el('import-output-dir');
2286 if (!slugSel || !outInp) return;
2287 const slugVal = slugSel.value;
2288 if (!slugVal || slugVal === '__custom__') return;
2289 if (subSel && subSel.value === '__custom_sub__') return;
2290 const sub =
2291 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2292 const subUse = sub || 'inbox';
2293 outInp.value = 'projects/' + slugVal + '/' + subUse;
2294 }
2295
2296 function syncImportPickersFromOutputDir() {
2297 const slugSel = el('import-create-project-slug');
2298 const subSel = el('import-create-project-subroot');
2299 const outInp = el('import-output-dir');
2300 if (!slugSel || !outInp) return;
2301 const raw = outInp.value.trim().replace(/\/+$/, '');
2302 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2303 if (!m) {
2304 slugSel.value = raw ? '__custom__' : '';
2305 refreshImportCreateSubrootSelect();
2306 updateImportPathLayoutVisibility();
2307 return;
2308 }
2309 const diskSlug = m[1];
2310 const rest = m[2] || '';
2311 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2312 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2313 if (match) slugSel.value = match;
2314 else slugSel.value = '__custom__';
2315 refreshImportCreateSubrootSelect();
2316 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2317 const segments = rest.split('/').filter(Boolean);
2318 const firstDir = segments[0] || '';
2319 const allowed = new Set(
2320 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2321 );
2322 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2323 else if (firstDir) subSel.value = '__custom_sub__';
2324 else subSel.value = '';
2325 }
2326 updateImportPathLayoutVisibility();
2327 }
2328
2329 function hydrateImportCreateProjectSlugSelect(facets) {
2330 const sel = el('import-create-project-slug');
2331 if (!sel) return;
2332 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2333 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2334 const preserve = sel.value;
2335 sel.innerHTML =
2336 '<option value="">— Not under projects/ —</option>' +
2337 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2338 '<option value="__custom__">Custom (type full path)</option>';
2339 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2340 refreshImportCreateSubrootSelect();
2341 updateImportPathLayoutVisibility();
2342 }
2343
2344 function syncImportFolderSelectToOutputDir() {
2345 const outInp = el('import-output-dir');
2346 const sel = el('import-vault-folder');
2347 if (!outInp || !sel) return;
2348 const p = outInp.value.trim().replace(/\/+$/, '');
2349 if (!p) return;
2350 let best = '__custom__';
2351 let bestLen = -1;
2352 for (const opt of sel.options) {
2353 const v = opt.value;
2354 if (v === '__custom__') continue;
2355 if (p === v || p.startsWith(v + '/')) {
2356 if (v.length > bestLen) {
2357 best = v;
2358 bestLen = v.length;
2359 }
2360 }
2361 }
2362 sel.value = bestLen >= 0 ? best : '__custom__';
2363 }
2364
2365 function defaultImportOutputDir() {
2366 const slugSel = el('import-create-project-slug');
2367 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2368 const subSel = el('import-create-project-subroot');
2369 const sub =
2370 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2371 const subUse = sub || 'inbox';
2372 return 'projects/' + slugSel.value + '/' + subUse;
2373 }
2374 const sel = el('import-vault-folder');
2375 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2376 return folder;
2377 }
2378
2379 function getImportProjectAndOutputDir() {
2380 const outInp = el('import-output-dir');
2381 const slugSel = el('import-create-project-slug');
2382 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2383 if (raw) {
2384 const sug = projectsPathTypoSuggestion(raw);
2385 if (sug) {
2386 return {
2387 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
2388 project: '',
2389 outputDir: undefined,
2390 };
2391 }
2392 }
2393 const outputDir = raw || undefined;
2394 let project = '';
2395 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2396 project = normSlug(slugSel.value);
2397 }
2398 if (!project && outputDir) {
2399 const m = outputDir.match(/^projects\/([^/]+)/);
2400 if (m) project = normSlug(m[1]);
2401 }
2402 return { err: null, project: project || '', outputDir: outputDir || undefined };
2403 }
2404
2405 function updateFullCreateSimilarInlineHint() {
2406 const hint = el('full-path-similar-hint');
2407 const btn = el('btn-full-path-use-similar-project');
2408 const pathInp = el('full-path');
2409 if (!hint || !pathInp) return;
2410 const notePath = pathInp.value.trim();
2411 const slug = projectSlugFromProjectsPath(notePath);
2412 const similar =
2413 slug && (lastHubFacets && lastHubFacets.projects)
2414 ? findSimilarFacetProject(slug, lastHubFacets.projects)
2415 : null;
2416 if (similar && notePath.startsWith('projects/')) {
2417 hint.textContent =
2418 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
2419 hint.className = 'muted small detail-project-hint warn';
2420 hint.classList.remove('hidden');
2421 if (btn) {
2422 btn.classList.remove('hidden');
2423 btn.onclick = () => {
2424 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
2425 pathInp.value = fixed;
2426 syncFolderSelectToPathInput();
2427 syncFullCreatePickersFromPath();
2428 syncFullProjectFromPath();
2429 updateFullPathProjectTypoHint();
2430 updateFullCreateSimilarInlineHint();
2431 };
2432 }
2433 } else {
2434 hint.textContent = '';
2435 hint.className = 'muted small detail-project-hint hidden';
2436 hint.classList.add('hidden');
2437 if (btn) {
2438 btn.classList.add('hidden');
2439 btn.onclick = null;
2440 }
2441 }
2442 }
2443
2444 function scheduleFullCreateSimilarHint() {
2445 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
2446 fullPathSimilarDebounceTimer = window.setTimeout(() => {
2447 fullPathSimilarDebounceTimer = 0;
2448 updateFullCreateSimilarInlineHint();
2449 }, 220);
2450 }
2451
2452 function openFullCreateSimilarModal(notePath, suggestedSlug) {
2453 const modal = el('modal-create-similar-project');
2454 const body = el('modal-create-similar-project-body');
2455 if (!modal || !body) return;
2456 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
2457 fullCreateSimilarModalPendingPath = notePath;
2458 const bad = projectSlugFromProjectsPath(notePath) || '…';
2459 body.textContent =
2460 'Your path starts with projects/' +
2461 bad +
2462 '/ but an existing project slug is «' +
2463 suggestedSlug +
2464 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
2465 modal.classList.remove('hidden');
2466 const focusBtn = el('btn-modal-create-similar-use-existing');
2467 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
2468 }
2469
2470 function closeFullCreateSimilarModal() {
2471 const modal = el('modal-create-similar-project');
2472 if (modal) modal.classList.add('hidden');
2473 fullCreateSimilarModalSuggestedSlug = '';
2474 fullCreateSimilarModalPendingPath = '';
2475 }
2476
2477 /** True when any list filter used by loadNotes / Quick chips is set. */
2478 function listFacetFiltersActive() {
2479 if (filterProject.value) return true;
2480 if (filterTag.value) return true;
2481 if (filterFolder.value) return true;
2482 if (filterNetwork && filterNetwork.value) return true;
2483 if (filterWallet && filterWallet.value) return true;
2484 const fps = el('filter-payment-status');
2485 if (fps && fps.value) return true;
2486 if (filterSince && filterSince.value) return true;
2487 if (filterUntil && filterUntil.value) return true;
2488 if (filterContentScope && filterContentScope.value) return true;
2489 return false;
2490 }
2491
2492 function clearListFacetFilters() {
2493 filterProject.value = '';
2494 filterTag.value = '';
2495 filterFolder.value = '';
2496 if (filterNetwork) filterNetwork.value = '';
2497 if (filterWallet) filterWallet.value = '';
2498 const fps = el('filter-payment-status');
2499 if (fps) fps.value = '';
2500 if (filterSince) filterSince.value = '';
2501 if (filterUntil) filterUntil.value = '';
2502 if (filterContentScope) filterContentScope.value = '';
2503 }
2504
2505 function renderFilterChips(facets) {
2506 filterChipsEl.innerHTML = '';
2507 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2508
2509 const header = document.createElement('div');
2510 header.className = 'filter-chips-header';
2511
2512 const label = document.createElement('span');
2513 label.className = 'toolbar-label';
2514 label.textContent = 'Quick';
2515
2516 const toggle = document.createElement('button');
2517 toggle.type = 'button';
2518 toggle.className = 'filter-chips-toggle';
2519 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2520 toggle.setAttribute('aria-controls', 'filter-chips-panel');
2521 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2522 toggle.setAttribute(
2523 'aria-label',
2524 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2525 );
2526 toggle.innerHTML =
2527 '<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>';
2528 toggle.onclick = () => {
2529 filterChipsExpanded = !filterChipsExpanded;
2530 try {
2531 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
2532 } catch (_) {}
2533 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2534 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2535 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2536 toggle.setAttribute(
2537 'aria-label',
2538 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2539 );
2540 };
2541
2542 header.appendChild(label);
2543 header.appendChild(toggle);
2544 filterChipsEl.appendChild(header);
2545
2546 const panel = document.createElement('div');
2547 panel.id = 'filter-chips-panel';
2548 panel.className = 'filter-chips-panel';
2549 panel.setAttribute('role', 'region');
2550 panel.setAttribute('aria-label', 'Quick filter chips');
2551 filterChipsEl.appendChild(panel);
2552
2553 const allBtn = document.createElement('button');
2554 allBtn.type = 'button';
2555 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
2556 allBtn.textContent = 'All';
2557 allBtn.title =
2558 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
2559 allBtn.onclick = () => {
2560 searchQuery.value = '';
2561 clearListFacetFilters();
2562 switchNotesView('list');
2563 loadNotes();
2564 renderFilterChips(null);
2565 };
2566 panel.appendChild(allBtn);
2567
2568 const apply = (f) => {
2569 if (!f) return;
2570 (f.projects || []).slice(0, 12).forEach((p) => {
2571 const b = document.createElement('button');
2572 b.type = 'button';
2573 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
2574 b.textContent = 'project:' + p;
2575 b.onclick = () => {
2576 searchQuery.value = '';
2577 filterProject.value = p;
2578 filterTag.value = '';
2579 filterFolder.value = '';
2580 switchNotesView('list');
2581 loadNotes();
2582 renderFilterChips(null);
2583 };
2584 panel.appendChild(b);
2585 });
2586 (f.tags || []).slice(0, 10).forEach((t) => {
2587 const b = document.createElement('button');
2588 b.type = 'button';
2589 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
2590 b.textContent = 'tag:' + t;
2591 b.onclick = () => {
2592 searchQuery.value = '';
2593 filterTag.value = t;
2594 filterProject.value = '';
2595 filterFolder.value = '';
2596 switchNotesView('list');
2597 loadNotes();
2598 renderFilterChips(null);
2599 };
2600 panel.appendChild(b);
2601 });
2602 (f.folders || []).slice(0, 12).forEach((folder) => {
2603 const b = document.createElement('button');
2604 b.type = 'button';
2605 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
2606 b.textContent = 'folder:' + folder;
2607 b.onclick = () => {
2608 searchQuery.value = '';
2609 filterFolder.value = folder;
2610 filterProject.value = '';
2611 filterTag.value = '';
2612 switchNotesView('list');
2613 loadNotes();
2614 renderFilterChips(null);
2615 };
2616 panel.appendChild(b);
2617 });
2618 // Phase 12 — network chips
2619 (f.networks || []).slice(0, 8).forEach((net) => {
2620 const b = document.createElement('button');
2621 b.type = 'button';
2622 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
2623 b.textContent = 'net:' + net;
2624 b.onclick = () => {
2625 searchQuery.value = '';
2626 if (filterNetwork) filterNetwork.value = net;
2627 switchNotesView('list');
2628 loadNotes();
2629 renderFilterChips(null);
2630 };
2631 panel.appendChild(b);
2632 });
2633 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
2634 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
2635 const payStatuses = ['pending', 'settled', 'failed'];
2636 payStatuses.forEach((ps) => {
2637 const b = document.createElement('button');
2638 b.type = 'button';
2639 b.className = 'chip-btn chip-blockchain';
2640 b.textContent = 'status:' + ps;
2641 b.onclick = () => {
2642 searchQuery.value = '';
2643 const fpsEl = el('filter-payment-status');
2644 if (fpsEl) fpsEl.value = ps;
2645 switchNotesView('list');
2646 loadNotes();
2647 renderFilterChips(null);
2648 };
2649 panel.appendChild(b);
2650 });
2651 }
2652 };
2653 if (facets) apply(facets);
2654 else fetchFacetsResolved().then(apply).catch(() => {});
2655 }
2656
2657 function getPresets() {
2658 try {
2659 const raw = localStorage.getItem(PRESETS_KEY);
2660 return raw ? JSON.parse(raw) : [];
2661 } catch (_) {
2662 return [];
2663 }
2664 }
2665
2666 function savePreset() {
2667 const name = (presetNameInput.value || '').trim();
2668 if (!name) return;
2669 const presets = getPresets().filter((p) => p.name !== name);
2670 presets.push({
2671 name,
2672 project: filterProject.value,
2673 tag: filterTag.value,
2674 folder: filterFolder.value,
2675 since: filterSince?.value || '',
2676 until: filterUntil?.value || '',
2677 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2678 });
2679 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
2680 presetNameInput.value = '';
2681 renderPresets();
2682 }
2683
2684 function renderPresets() {
2685 presetsListEl.innerHTML = '';
2686 getPresets().forEach((p) => {
2687 const b = document.createElement('button');
2688 b.type = 'button';
2689 b.className = 'preset-pill';
2690 b.textContent = p.name;
2691 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(' ');
2692 b.onclick = () => {
2693 filterProject.value = p.project || '';
2694 filterTag.value = p.tag || '';
2695 filterFolder.value = p.folder || '';
2696 if (filterSince) filterSince.value = p.since || '';
2697 if (filterUntil) filterUntil.value = p.until || '';
2698 if (filterContentScope) filterContentScope.value = p.content_scope || '';
2699 switchNotesView('list');
2700 loadNotes();
2701 renderFilterChips(null);
2702 };
2703 presetsListEl.appendChild(b);
2704 });
2705 }
2706
2707 el('btn-save-preset').onclick = savePreset;
2708
2709 function renderNoteRow(n) {
2710 const title = n.title || n.path;
2711 const isLog = hubRowIsApprovalLog(n);
2712 const chips = [];
2713 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
2714 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
2715 const meta = [n.date].filter(Boolean).join(' · ');
2716 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
2717 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
2718 return (
2719 '<div class="' +
2720 rowClass +
2721 '" data-path="' +
2722 escapeHtml(n.path) +
2723 '"><span class="row-title">' +
2724 escapeHtml(title) +
2725 badge +
2726 '</span><div class="row-chips">' +
2727 chips.join('') +
2728 '</div>' +
2729 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
2730 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
2731 '</div>'
2732 );
2733 }
2734
2735 function bindNoteClicks(container) {
2736 container.querySelectorAll('.list-item').forEach((item) => {
2737 item.onclick = () => openNote(item.dataset.path);
2738 const delBtn = item.querySelector('.list-item-delete');
2739 if (delBtn) {
2740 delBtn.onclick = async (e) => {
2741 e.stopPropagation();
2742 const path = item.dataset.path;
2743 if (!path) return;
2744 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
2745 try {
2746 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
2747 if (typeof showToast === 'function') showToast('Deleted: ' + path);
2748 hubMarkSemanticIndexStale();
2749 if (currentOpenNote && currentOpenNote.path === path) {
2750 currentOpenNote = null;
2751 resetDetailSectionSourceState();
2752 hideDetailPanelChrome();
2753 }
2754 loadNotes();
2755 loadFacets();
2756 } catch (err) {
2757 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
2758 }
2759 };
2760 }
2761 });
2762 }
2763
2764 function hasActiveNoteListFilters() {
2765 if (filterProject && filterProject.value) return true;
2766 if (filterTag && filterTag.value) return true;
2767 if (filterFolder && filterFolder.value) return true;
2768 if (filterSince && filterSince.value) return true;
2769 if (filterUntil && filterUntil.value) return true;
2770 if (filterContentScope && filterContentScope.value) return true;
2771 if (filterNetwork && filterNetwork.value) return true;
2772 if (filterWallet && filterWallet.value) return true;
2773 const paymentStatusEl = el('filter-payment-status');
2774 if (paymentStatusEl && paymentStatusEl.value) return true;
2775 return false;
2776 }
2777
2778 function readOnboardingDismissedSync() {
2779 try {
2780 const raw = localStorage.getItem('knowtation_onboarding_v1');
2781 if (!raw) return false;
2782 const o = JSON.parse(raw);
2783 return Boolean(o && o.v === 1 && o.status === 'dismissed');
2784 } catch (_) {
2785 return false;
2786 }
2787 }
2788
2789 function isSearchResultsView() {
2790 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
2791 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
2792 }
2793
2794 function updateEmptyVaultStripVisibility() {
2795 const strip = el('hub-empty-vault-strip');
2796 if (!strip) return;
2797 const mainVisible = main && !main.classList.contains('hidden');
2798 const notesTab = document.querySelector('.tabs .tab.active')?.dataset?.tab === 'notes';
2799 const q = searchQuery && String(searchQuery.value).trim();
2800 const show =
2801 Boolean(mainVisible && token) &&
2802 readOnboardingDismissedSync() &&
2803 hubBrowseListEmptyUnfiltered &&
2804 notesTab &&
2805 !q &&
2806 !isSearchResultsView();
2807 strip.classList.toggle('hidden', !show);
2808 }
2809
2810 async function loadNotes() {
2811 const q = new URLSearchParams();
2812 q.set('limit', '100');
2813 if (filterFolder.value) q.set('folder', filterFolder.value);
2814 if (filterProject.value) q.set('project', filterProject.value);
2815 if (filterTag.value) q.set('tag', filterTag.value);
2816 if (filterSince && filterSince.value) q.set('since', filterSince.value);
2817 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
2818 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
2819 // Phase 12 — blockchain filters
2820 const networkVal = filterNetwork ? filterNetwork.value : '';
2821 const walletVal = filterWallet ? filterWallet.value : '';
2822 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
2823 if (networkVal) q.set('network', networkVal);
2824 if (walletVal) q.set('wallet_address', walletVal);
2825 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
2826 notesList.innerHTML = loadingHtml;
2827 notesTotal.textContent = '';
2828 try {
2829 const out = await api('/api/v1/notes?' + q.toString());
2830 let notes = (out.notes || []).map(normalizeHubListItem);
2831 notes = applyVaultListFilters(notes, {
2832 folder: filterFolder.value,
2833 project: filterProject.value,
2834 tag: filterTag.value,
2835 since: filterSince?.value || '',
2836 until: filterUntil?.value || '',
2837 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2838 network: networkVal,
2839 wallet_address: walletVal,
2840 payment_status: paymentStatusVal,
2841 });
2842 notes = applySortedNotesClient(notes);
2843 const totalCount = notes.length;
2844 notes = notes.slice(0, 100);
2845 if (notes.length === 0) {
2846 notesList.innerHTML =
2847 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
2848 const ea = el('empty-add');
2849 if (ea) ea.onclick = () => openCreateModal();
2850 notesTotal.textContent = 'Total: 0';
2851 } else {
2852 notesList.innerHTML = notes.map(renderNoteRow).join('');
2853 notesTotal.textContent = 'Total: ' + totalCount;
2854 bindNoteClicks(notesList);
2855 listSelectedIndex = 0;
2856 updateListSelection();
2857 }
2858 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
2859 updateEmptyVaultStripVisibility();
2860 } catch (e) {
2861 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
2862 notesTotal.textContent = '';
2863 hubBrowseListEmptyUnfiltered = false;
2864 updateEmptyVaultStripVisibility();
2865 }
2866 }
2867
2868 function switchHubMainTab(name) {
2869 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2870 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2871 const tab = document.querySelector('[data-tab="' + name + '"]');
2872 if (tab) tab.classList.add('active');
2873 syncHubListSortUI(name);
2874 setProposalFiltersBarVisible(
2875 name === 'activity' || name === 'suggested' || name === 'problem',
2876 );
2877 refreshNewProposalTabVisibility();
2878 const panel = el(
2879 'tab-' +
2880 (name === 'notes'
2881 ? 'notes'
2882 : name === 'activity'
2883 ? 'activity'
2884 : name === 'suggested'
2885 ? 'suggested'
2886 : 'problem'),
2887 );
2888 if (panel) panel.classList.remove('hidden');
2889 if (name === 'notes') {
2890 loadNotes();
2891 } else {
2892 if (name === 'activity') loadActivity();
2893 if (name === 'suggested' || name === 'problem') loadProposals();
2894 updateEmptyVaultStripVisibility();
2895 }
2896 }
2897
2898 function updateListSelection() {
2899 const container = notesList;
2900 const items = container.querySelectorAll('.list-item');
2901 if (items.length === 0) { listSelectedIndex = 0; return; }
2902 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
2903 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
2904 const sel = items[listSelectedIndex];
2905 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2906 }
2907
2908 btnApplyFilters.onclick = () => {
2909 switchNotesView('list');
2910 loadNotes();
2911 renderFilterChips(null);
2912 };
2913
2914 if (filterContentScope) {
2915 filterContentScope.addEventListener('change', () => {
2916 switchNotesView('list');
2917 loadNotes();
2918 renderFilterChips(null);
2919 });
2920 }
2921
2922 function formatSearchScopeSummary() {
2923 const parts = [];
2924 if (filterProject.value) parts.push('project: ' + filterProject.value);
2925 if (filterTag.value) parts.push('tag: ' + filterTag.value);
2926 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
2927 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
2928 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
2929 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
2930 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
2931 return parts.length ? parts.join(' · ') : '';
2932 }
2933
2934 function semanticMatchStrengthLabel(score) {
2935 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2936 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2937 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
2938 }
2939
2940 function keywordMatchStrengthLabel(score) {
2941 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2942 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2943 return 'Keyword match ~' + pct + '% (text overlap)';
2944 }
2945
2946 if (btnClearSearch) {
2947 btnClearSearch.onclick = () => {
2948 searchQuery.value = '';
2949 clearListFacetFilters();
2950 switchNotesView('list');
2951 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2952 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2953 const notesTab = document.querySelector('[data-tab="notes"]');
2954 if (notesTab) notesTab.classList.add('active');
2955 const tabNotes = el('tab-notes');
2956 if (tabNotes) tabNotes.classList.remove('hidden');
2957 loadNotes();
2958 renderFilterChips(null);
2959 };
2960 }
2961
2962 function showToast(message, isError = false) {
2963 const toast = document.createElement('div');
2964 toast.className = 'toast' + (isError ? ' toast-err' : '');
2965 toast.textContent = message;
2966 toast.setAttribute('role', 'status');
2967 document.body.appendChild(toast);
2968 requestAnimationFrame(() => toast.classList.add('toast-show'));
2969 setTimeout(() => {
2970 toast.classList.remove('toast-show');
2971 setTimeout(() => toast.remove(), 300);
2972 }, 3000);
2973 }
2974
2975 const proposalFilterApply = el('proposal-filter-apply');
2976 if (proposalFilterApply) {
2977 proposalFilterApply.onclick = () => {
2978 loadProposals();
2979 loadActivity();
2980 };
2981 }
2982 const proposalFilterClear = el('proposal-filter-clear');
2983 if (proposalFilterClear) {
2984 proposalFilterClear.onclick = () => {
2985 const lf = el('proposal-filter-label');
2986 const sf = el('proposal-filter-source');
2987 const pf = el('proposal-filter-path-prefix');
2988 const pe = el('proposal-filter-pending-eval');
2989 const rq = el('proposal-filter-review-queue');
2990 const rs = el('proposal-filter-review-severity');
2991 if (lf) lf.value = '';
2992 if (sf) sf.value = '';
2993 if (pf) pf.value = '';
2994 if (pe) pe.checked = false;
2995 if (rq) rq.value = '';
2996 if (rs) rs.value = '';
2997 loadProposals();
2998 loadActivity();
2999 };
3000 }
3001
3002 const hubListSortEl = hubListSortGetSelect();
3003 if (hubListSortEl) {
3004 hubListSortEl.addEventListener('change', () => {
3005 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3006 try {
3007 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3008 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3009 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3010 }
3011 } catch (_) {}
3012 if (tab === 'notes') loadNotes();
3013 else if (tab === 'activity') loadActivity();
3014 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3015 });
3016 }
3017
3018 if (btnReindex) {
3019 btnReindex.onclick = async () => {
3020 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3021 try {
3022 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3023 // (see api() helper). Bridge may return one of three shapes:
3024 // 200 {ok:true, ...} → sync completed
3025 // 202 {status:'background', ...} → routed to bridge-index-background fn
3026 // 409 {status:'already_running'} → another background job in flight
3027 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3028 if (out && out.status === 'background') {
3029 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3030 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3031 } else if (out && out.status === 'already_running') {
3032 showToast(out.message || 'A background re-index is already running for this vault.');
3033 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3034 } else {
3035 const n = out.notesProcessed ?? 0;
3036 const c = out.chunksIndexed ?? 0;
3037 const skipped = out.chunksSkippedCached ?? 0;
3038 const embedded = out.chunksEmbedded ?? c;
3039 const detail = skipped > 0
3040 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3041 : '';
3042 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3043 hubClearSemanticIndexStale();
3044 loadFacets();
3045 loadNotes();
3046 hubLoadIndexStatus().catch(() => {});
3047 }
3048 } catch (e) {
3049 showToast(e.message || 'Re-index failed', true);
3050 }
3051 });
3052 };
3053 }
3054
3055 /*
3056 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3057 * Reads from `GET /api/v1/index/status` which both sync and background paths
3058 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3059 * background job is in flight so the line flips from
3060 * "Re-indexing in background…" → "Last indexed: just now"
3061 * without the user needing to click anything.
3062 */
3063 let _hubIndexStatusPollTimer = null;
3064 function hubFormatRelativeTime(epochMs) {
3065 if (!Number.isFinite(epochMs)) return '';
3066 const ageMs = Date.now() - epochMs;
3067 if (ageMs < 0) return 'just now';
3068 const sec = Math.round(ageMs / 1000);
3069 if (sec < 45) return 'just now';
3070 const min = Math.round(sec / 60);
3071 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3072 const hr = Math.round(min / 60);
3073 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3074 const days = Math.round(hr / 24);
3075 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3076 }
3077 async function hubLoadIndexStatus(opts) {
3078 opts = opts || {};
3079 const el = document.getElementById('hub-index-status');
3080 if (!el) return;
3081 let status;
3082 try {
3083 status = await api('/api/v1/index/status', { method: 'GET' });
3084 } catch (_) {
3085 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3086 el.textContent = '';
3087 el.classList.remove('hub-index-status-running');
3088 return;
3089 }
3090 if (status && status.inProgress) {
3091 el.textContent = 'Re-indexing in background…';
3092 el.classList.add('hub-index-status-running');
3093 // Keep polling so the line auto-clears when the background job finishes.
3094 // 5-second cadence matches typical embedding batch completion granularity
3095 // and stays well under any sane rate limit.
3096 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3097 _hubIndexStatusPollTimer = setInterval(() => {
3098 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3099 }, 5000);
3100 }
3101 return;
3102 }
3103 // No in-flight job — stop polling if we were.
3104 if (_hubIndexStatusPollTimer != null) {
3105 clearInterval(_hubIndexStatusPollTimer);
3106 _hubIndexStatusPollTimer = null;
3107 }
3108 el.classList.remove('hub-index-status-running');
3109 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3110 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3111 el.textContent = 'Last indexed: ' + rel;
3112 el.title =
3113 'Last successful index: ' +
3114 (status.lastIndexed.lastIndexedAt || '') +
3115 ' · ' +
3116 (status.lastIndexed.chunksIndexed || 0) +
3117 ' chunks · mode: ' +
3118 (status.lastIndexed.mode || 'sync');
3119 } else {
3120 el.textContent = '';
3121 el.title = '';
3122 }
3123 }
3124 // Kick off an initial status load once the user is logged in (the API call
3125 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3126 // that already happens after token validation succeeds.
3127 hubLoadIndexStatus().catch(() => {});
3128
3129 const hubIndexStaleRun = el('hub-index-stale-run');
3130 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3131 if (hubIndexStaleRun && btnReindex) {
3132 hubIndexStaleRun.onclick = () => {
3133 btnReindex.click();
3134 };
3135 }
3136 if (hubIndexStaleDismiss) {
3137 hubIndexStaleDismiss.onclick = () => {
3138 hubClearSemanticIndexStale();
3139 };
3140 }
3141
3142 function proposalFilterQuerySuffix() {
3143 const params = [];
3144 const lab = el('proposal-filter-label');
3145 const src = el('proposal-filter-source');
3146 const pre = el('proposal-filter-path-prefix');
3147 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3148 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3149 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3150 const pe = el('proposal-filter-pending-eval');
3151 if (pe && pe.checked) params.push('evaluation_status=pending');
3152 const rq = el('proposal-filter-review-queue');
3153 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3154 const rs = el('proposal-filter-review-severity');
3155 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3156 return params.length ? '&' + params.join('&') : '';
3157 }
3158
3159 // Discard a proposal directly from the list without opening the detail panel.
3160 async function discardProposalInline(id, itemEl) {
3161 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3162 try {
3163 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3164 if (typeof showToast === 'function') showToast('Proposal discarded.');
3165 const panel = el('detail-panel');
3166 if (panel && !panel.classList.contains('hidden')) {
3167 hideDetailPanelChrome();
3168 }
3169 loadProposals();
3170 loadActivity();
3171 } catch (err) {
3172 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3173 }
3174 }
3175
3176 async function loadProposals() {
3177 const emptySuggested =
3178 '<div class="empty-state empty-state-suggested">' +
3179 '<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>' +
3180 '<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>' +
3181 '<p class="empty-state-suggested-actions"><button type="button" class="btn-secondary" id="empty-suggested-how-to">How proposals work</button></p>' +
3182 '</div>';
3183 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3184 const fq = proposalFilterQuerySuffix();
3185 [
3186 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3187 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3188 ].forEach(({ kind, status, empty: emptyHtml }) => {
3189 const container = el('proposals-' + kind);
3190 if (!container) return;
3191 container.innerHTML = loadingHtml;
3192 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3193 .then((out) => {
3194 let list = out.proposals || [];
3195 list = applySortedProposalsClient(list);
3196 if (list.length === 0) {
3197 container.innerHTML = emptyHtml;
3198 if (kind === 'suggested') {
3199 const how = container.querySelector('#empty-suggested-how-to');
3200 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3201 }
3202 return;
3203 }
3204 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3205 container.innerHTML = list
3206 .map((p) => {
3207 const labelChips = (Array.isArray(p.labels) ? p.labels : [])
3208 .slice(0, 4)
3209 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
3210 .join('');
3211 const srcChip = p.source
3212 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3213 : '';
3214 const qChip = p.review_queue
3215 ? '<span class="proposal-chip">queue:' + escapeHtml(String(p.review_queue)) + '</span>'
3216 : '';
3217 const sevChip =
3218 p.review_severity === 'elevated'
3219 ? '<span class="proposal-chip">elevated</span>'
3220 : p.review_severity === 'standard'
3221 ? '<span class="proposal-chip">standard</span>'
3222 : '';
3223 const extraChips = [labelChips, srcChip, qChip, sevChip].filter(Boolean).join('');
3224 const discardBtn = canDiscard
3225 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3226 : '';
3227 return (
3228 '<div class="list-item" data-id="' +
3229 escapeHtml(p.proposal_id) +
3230 '"><span class="row-title">' +
3231 escapeHtml(p.path) +
3232 '</span><div class="status">' +
3233 escapeHtml(p.status) +
3234 (p.updated_at ? ' · ' + (calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) : '') +
3235 (p.evaluation_status ? ' · eval:' + escapeHtml(String(p.evaluation_status)) : '') +
3236 (extraChips ? ' · ' + extraChips : '') +
3237 '</div>' + discardBtn + '</div>'
3238 );
3239 })
3240 .join('');
3241 container.querySelectorAll('.list-item').forEach((item) => {
3242 item.onclick = () => openProposal(item.dataset.id);
3243 const db = item.querySelector('.list-item-delete');
3244 if (db) {
3245 db.onclick = (e) => {
3246 e.stopPropagation();
3247 discardProposalInline(item.dataset.id, item);
3248 };
3249 }
3250 });
3251 })
3252 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3253 });
3254 }
3255
3256 async function loadActivity() {
3257 const container = el('proposals-activity');
3258 if (!container) return;
3259 container.innerHTML = loadingHtml;
3260 try {
3261 const fq = proposalFilterQuerySuffix();
3262 const out = await api('/api/v1/proposals?limit=100' + fq);
3263 let list = out.proposals || [];
3264 list = applySortedProposalsClient(list);
3265 if (list.length === 0) {
3266 container.innerHTML =
3267 '<div class="empty-state empty-state-activity">' +
3268 '<p>No proposal activity yet.</p>' +
3269 '<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>' +
3270 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Suggested tab</button></p>' +
3271 '</div>';
3272 const go = container.querySelector('#empty-activity-goto-suggested');
3273 if (go) go.onclick = () => switchHubMainTab('suggested');
3274 return;
3275 }
3276 const canDiscard = hubUserCanWriteNotes();
3277 container.innerHTML = list
3278 .map((p) => {
3279 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3280 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3281 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3282 // approved records stay as-is unless the user opens them.
3283 const showDiscard = canDiscard && p.status !== 'approved';
3284 const discardBtn = showDiscard
3285 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3286 : '';
3287 return (
3288 '<div class="list-item activity-item ' +
3289 statusClass +
3290 '" data-id="' +
3291 escapeHtml(p.proposal_id) +
3292 '"><span class="row-title">' +
3293 escapeHtml(p.path) +
3294 '</span><div class="status">' +
3295 escapeHtml(p.status) +
3296 ' · ' +
3297 escapeHtml(date) +
3298 '</div>' + discardBtn + '</div>'
3299 );
3300 })
3301 .join('');
3302 container.querySelectorAll('.list-item').forEach((item) => {
3303 item.onclick = () => openProposal(item.dataset.id);
3304 const db = item.querySelector('.list-item-delete');
3305 if (db) {
3306 db.onclick = (e) => {
3307 e.stopPropagation();
3308 discardProposalInline(item.dataset.id, item);
3309 };
3310 }
3311 });
3312 } catch (e) {
3313 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3314 }
3315 }
3316
3317 async function runVaultSearch() {
3318 const query = searchQuery.value.trim();
3319 if (!query) return;
3320 hubBrowseListEmptyUnfiltered = false;
3321 updateEmptyVaultStripVisibility();
3322 const activeMainTab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3323 const useKeyword = searchMode && searchMode.value === 'keyword';
3324 if (activeMainTab && activeMainTab !== 'notes') {
3325 showToast(useKeyword ? 'Keyword results are shown under the Notes tab.' : 'Semantic results are shown under the Notes tab.');
3326 }
3327 switchNotesView('list');
3328 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3329 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3330 document.querySelector('[data-tab="notes"]').classList.add('active');
3331 el('tab-notes').classList.remove('hidden');
3332 notesList.innerHTML = loadingHtml;
3333 notesTotal.textContent = '';
3334 const scopeSummary = formatSearchScopeSummary();
3335 const scopeSuffix = scopeSummary
3336 ? ' · scope: ' + scopeSummary
3337 : ' · scope: entire vault (use dropdowns to narrow)';
3338 try {
3339 const body = { query, limit: 20 };
3340 if (useKeyword) body.mode = 'keyword';
3341 if (filterProject.value) body.project = filterProject.value;
3342 if (filterTag.value) body.tag = filterTag.value;
3343 if (filterFolder.value) body.folder = filterFolder.value;
3344 if (filterSince && filterSince.value) body.since = filterSince.value;
3345 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
3346 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
3347 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
3348 const results = out.results || [];
3349 if (results.length === 0) {
3350 notesList.innerHTML = useKeyword
3351 ? '<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>'
3352 : '<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>';
3353 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
3354 return;
3355 }
3356 notesList.innerHTML = results
3357 .map((r) => {
3358 const chips = [];
3359 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
3360 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3361 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
3362 const pathStr = String(r.path || '').replace(/\\/g, '/');
3363 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
3364 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3365 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3366 return (
3367 '<div class="' +
3368 rowClass +
3369 '" data-path="' +
3370 escapeHtml(r.path) +
3371 '"><span class="row-title">' +
3372 escapeHtml(r.path) +
3373 badge +
3374 '</span><div class="row-chips">' +
3375 chips.join('') +
3376 '</div>' +
3377 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
3378 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
3379 '</div>'
3380 );
3381 })
3382 .join('');
3383 notesTotal.textContent =
3384 results.length +
3385 (useKeyword ? ' keyword' : ' semantic') +
3386 ' result' +
3387 (results.length === 1 ? '' : 's') +
3388 scopeSuffix;
3389 bindNoteClicks(notesList);
3390 listSelectedIndex = 0;
3391 updateListSelection();
3392 } catch (e) {
3393 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3394 notesTotal.textContent = '';
3395 }
3396 }
3397
3398 btnSearch.onclick = () => {
3399 void runVaultSearch();
3400 };
3401
3402 searchQuery.addEventListener('keydown', (e) => {
3403 if (e.key === 'Enter') {
3404 e.preventDefault();
3405 void runVaultSearch();
3406 }
3407 });
3408 searchQuery.addEventListener('input', () => {
3409 updateEmptyVaultStripVisibility();
3410 });
3411
3412 function switchNotesView(view) {
3413 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
3414 el('notes-view-list').classList.toggle('hidden', view !== 'list');
3415 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
3416 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
3417 if (view === 'calendar') renderCalendar();
3418 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
3419 }
3420
3421 document.querySelectorAll('.view-tab').forEach((t) => {
3422 t.onclick = () => switchNotesView(t.dataset.view);
3423 });
3424
3425 function ymd(d) {
3426 const y = d.getFullYear();
3427 const m = String(d.getMonth() + 1).padStart(2, '0');
3428 const day = String(d.getDate()).padStart(2, '0');
3429 return y + '-' + m + '-' + day;
3430 }
3431
3432 async function renderCalendar() {
3433 const grid = el('calendar-grid');
3434 const title = el('cal-title');
3435 const dayList = el('calendar-day-list');
3436 const dayNotes = el('calendar-day-notes');
3437 dayList.classList.add('hidden');
3438 grid.classList.remove('hidden');
3439 el('calendar-nav').classList.remove('hidden');
3440
3441 const y = calendarMonth.getFullYear();
3442 const m = calendarMonth.getMonth();
3443 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
3444
3445 grid.innerHTML = loadingHtml;
3446 const first = new Date(y, m, 1);
3447 const last = new Date(y, m + 1, 0);
3448 const since = ymd(first);
3449 const until = ymd(last);
3450
3451 let notesInMonth = [];
3452 try {
3453 const q = new URLSearchParams({ since, until, limit: '100' });
3454 const out = await api('/api/v1/notes?' + q.toString());
3455 notesInMonth = (out.notes || [])
3456 .map(normalizeHubListItem)
3457 .filter((n) => {
3458 const ds = noteSortOrCalendarDay(n);
3459 return ds >= since && ds <= until;
3460 });
3461 } catch (_) {
3462 notesInMonth = [];
3463 }
3464
3465 const byDay = {};
3466 notesInMonth.forEach((n) => {
3467 const ds = noteSortOrCalendarDay(n);
3468 if (ds >= since && ds <= until) {
3469 byDay[ds] = (byDay[ds] || 0) + 1;
3470 }
3471 });
3472
3473 const startPad = first.getDay();
3474 const daysInMonth = last.getDate();
3475 const cells = [];
3476 const prevLast = new Date(y, m, 0).getDate();
3477 for (let i = 0; i < startPad; i++) {
3478 const d = prevLast - startPad + i + 1;
3479 cells.push({ out: true, day: d, key: null });
3480 }
3481 for (let d = 1; d <= daysInMonth; d++) {
3482 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
3483 }
3484 let nextMonthDay = 1;
3485 while (cells.length % 7 !== 0 || cells.length < 42) {
3486 cells.push({ out: true, day: nextMonthDay++, key: null });
3487 }
3488
3489 const today = ymd(new Date());
3490 grid.innerHTML = cells
3491 .map((c) => {
3492 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
3493 const cnt = byDay[c.key] || 0;
3494 const isToday = c.key === today;
3495 return (
3496 '<div class="cal-cell' +
3497 (isToday ? ' today' : '') +
3498 '" data-day="' +
3499 escapeHtml(c.key) +
3500 '"><span class="cal-day-num">' +
3501 c.day +
3502 '</span>' +
3503 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
3504 '</div>'
3505 );
3506 })
3507 .join('');
3508
3509 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
3510 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
3511 });
3512 }
3513
3514 el('cal-prev').onclick = () => {
3515 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
3516 renderCalendar();
3517 };
3518 el('cal-next').onclick = () => {
3519 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
3520 renderCalendar();
3521 };
3522 el('cal-back').onclick = () => {
3523 el('calendar-day-list').classList.add('hidden');
3524 el('calendar-grid').classList.remove('hidden');
3525 el('calendar-nav').classList.remove('hidden');
3526 };
3527
3528 function showCalendarDay(dayKey, notesInMonth) {
3529 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
3530 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
3531 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
3532 bindNoteClicks(el('calendar-day-notes'));
3533 el('calendar-grid').classList.add('hidden');
3534 el('calendar-nav').classList.add('hidden');
3535 el('calendar-day-list').classList.remove('hidden');
3536 }
3537
3538 async function fetchNotesForDashboard() {
3539 const all = [];
3540 let offset = 0;
3541 const limit = 100;
3542 let total = Infinity;
3543 while (offset < 500 && all.length < total) {
3544 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
3545 total = out.total ?? 0;
3546 const batch = (out.notes || []).map(normalizeHubListItem);
3547 all.push(...batch);
3548 if (batch.length < limit) break;
3549 offset += limit;
3550 }
3551 return { notes: all, total };
3552 }
3553
3554 async function renderDashboard() {
3555 chartInstances.forEach((c) => c.destroy());
3556 chartInstances = [];
3557 const cards = el('dashboard-cards');
3558 const foot = el('dashboard-footnote');
3559 cards.innerHTML = loadingHtml;
3560 foot.textContent = '';
3561
3562 let notes, total;
3563 try {
3564 const r = await fetchNotesForDashboard();
3565 notes = r.notes;
3566 total = r.total;
3567 } catch (e) {
3568 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3569 return;
3570 }
3571
3572 const weekAgo = new Date();
3573 weekAgo.setDate(weekAgo.getDate() - 7);
3574 const weekStr = ymd(weekAgo);
3575 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
3576
3577 const byProject = {};
3578 const byTag = {};
3579 const byWeek = {};
3580 notes.forEach((n) => {
3581 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
3582 (n.tags || []).forEach((t) => {
3583 byTag[t] = (byTag[t] || 0) + 1;
3584 });
3585 const ds = noteSortOrCalendarDay(n);
3586 if (ds) {
3587 const w = ds.slice(0, 7);
3588 byWeek[w] = (byWeek[w] || 0) + 1;
3589 }
3590 });
3591
3592 const topProjects = Object.entries(byProject)
3593 .sort((a, b) => b[1] - a[1])
3594 .slice(0, 8);
3595 const topTags = Object.entries(byTag)
3596 .sort((a, b) => b[1] - a[1])
3597 .slice(0, 8);
3598 const weeks = Object.keys(byWeek).sort();
3599
3600 cards.innerHTML =
3601 '<div class="dash-card"><div class="dash-value">' +
3602 total +
3603 '</div><div class="dash-label">Notes (indexed)</div></div>' +
3604 '<div class="dash-card"><div class="dash-value">' +
3605 thisWeek +
3606 '</div><div class="dash-label">Last 7 days</div></div>' +
3607 '<div class="dash-card"><div class="dash-value">' +
3608 Object.keys(byProject).length +
3609 '</div><div class="dash-label">Projects</div></div>' +
3610 '<div class="dash-card"><div class="dash-value">' +
3611 Object.keys(byTag).length +
3612 '</div><div class="dash-label">Tags</div></div>';
3613
3614 if (notes.length < total) {
3615 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
3616 }
3617
3618 if (typeof Chart === 'undefined') {
3619 foot.textContent += ' Chart.js failed to load.';
3620 return;
3621 }
3622
3623 const commonOpts = {
3624 responsive: true,
3625 maintainAspectRatio: false,
3626 plugins: { legend: { labels: { color: '#a1a1a1' } } },
3627 scales: {
3628 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3629 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3630 },
3631 };
3632
3633 const ctxP = el('chart-projects').getContext('2d');
3634 chartInstances.push(
3635 new Chart(ctxP, {
3636 type: 'bar',
3637 data: {
3638 labels: topProjects.map((x) => x[0]),
3639 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
3640 },
3641 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
3642 })
3643 );
3644
3645 const ctxT = el('chart-tags').getContext('2d');
3646 chartInstances.push(
3647 new Chart(ctxT, {
3648 type: 'doughnut',
3649 data: {
3650 labels: topTags.map((x) => x[0]),
3651 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
3652 },
3653 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
3654 })
3655 );
3656
3657 const ctxL = el('chart-timeline').getContext('2d');
3658 chartInstances.push(
3659 new Chart(ctxL, {
3660 type: 'line',
3661 data: {
3662 labels: weeks,
3663 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 }],
3664 },
3665 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
3666 })
3667 );
3668 }
3669
3670 function resetDuplicateCreateState() {
3671 pendingDuplicateDeleteSource = null;
3672 const ban = el('duplicate-source-banner');
3673 if (ban) ban.classList.add('hidden');
3674 const chk = el('duplicate-delete-after-save');
3675 if (chk) chk.checked = false;
3676 const mt = el('modal-create-title');
3677 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
3678 const fs = el('btn-full-save');
3679 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
3680 }
3681
3682 function openCreateModal() {
3683 resetDuplicateCreateState();
3684 closeCreateProposalModal();
3685 closeFullCreateSimilarModal();
3686 hideDetailPanelChrome();
3687 el('modal-create').classList.remove('hidden');
3688 el('create-msg-quick').textContent = '';
3689 el('create-msg-quick').className = 'create-msg';
3690 el('create-msg-full').textContent = '';
3691 el('create-msg-full').className = 'create-msg';
3692 fullCreateSimilarOverrideOnce = false;
3693 if (token) {
3694 void (async () => {
3695 await refreshFullPathFolderSelect();
3696 if (!lastHubFacets) {
3697 try {
3698 lastHubFacets = await fetchFacetsResolved();
3699 } catch (_) {}
3700 }
3701 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3702 })();
3703 }
3704 }
3705
3706 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
3707 function suggestDuplicateVaultPath(srcPath) {
3708 const t = String(srcPath || '')
3709 .replace(/\\/g, '/')
3710 .trim();
3711 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
3712 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
3713 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
3714 }
3715
3716 function tagsInputFromFrontmatter(tagsVal) {
3717 if (tagsVal == null) return '';
3718 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
3719 return String(tagsVal).trim();
3720 }
3721
3722 /**
3723 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
3724 * Optional checkbox deletes the source path after a successful save (different path only).
3725 */
3726 async function openDuplicateNoteModal() {
3727 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
3728 if (!token) {
3729 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
3730 return;
3731 }
3732 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
3733 closeCreateProposalModal();
3734 closeFullCreateSimilarModal();
3735 el('modal-create').classList.remove('hidden');
3736 el('create-msg-quick').textContent = '';
3737 el('create-msg-quick').className = 'create-msg';
3738 el('create-msg-full').textContent = '';
3739 el('create-msg-full').className = 'create-msg';
3740 fullCreateSimilarOverrideOnce = false;
3741 const mt = el('modal-create-title');
3742 if (mt) mt.textContent = 'Duplicate note';
3743 const fs = el('btn-full-save');
3744 if (fs) fs.textContent = 'Save duplicate';
3745 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
3746 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
3747 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
3748 if (tabFull) tabFull.classList.add('active');
3749 if (tabQuick) tabQuick.classList.remove('active');
3750 el('create-quick').classList.add('hidden');
3751 el('create-full').classList.remove('hidden');
3752 if (token) {
3753 try {
3754 await refreshFullPathFolderSelect();
3755 if (!lastHubFacets) {
3756 try {
3757 lastHubFacets = await fetchFacetsResolved();
3758 } catch (_) {}
3759 }
3760 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3761 } catch (_) {}
3762 }
3763 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
3764 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
3765 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
3766 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
3767 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
3768 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
3769 if (el('full-entity')) {
3770 const ent = fm.entity;
3771 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
3772 }
3773 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
3774 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
3775 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
3776 if (el('full-path')) {
3777 el('full-path').value = sug;
3778 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
3779 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
3780 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
3781 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
3782 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
3783 }
3784 const dsp = el('duplicate-source-path');
3785 if (dsp) dsp.textContent = currentOpenNote.path;
3786 const ban = el('duplicate-source-banner');
3787 if (ban) ban.classList.remove('hidden');
3788 const chk = el('duplicate-delete-after-save');
3789 if (chk) chk.checked = false;
3790 }
3791
3792 function closeCreateModal() {
3793 closeFullCreateSimilarModal();
3794 resetDuplicateCreateState();
3795 el('modal-create').classList.add('hidden');
3796 }
3797 function closeCreateProposalModal() {
3798 const m = el('modal-create-proposal');
3799 if (m) m.classList.add('hidden');
3800 const pathInput = el('proposal-create-path');
3801 if (pathInput) pathInput.readOnly = false;
3802 }
3803 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
3804 function openCreateProposalModal(opts) {
3805 if (!token) {
3806 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
3807 return;
3808 }
3809 if (!hubUserCanWriteNotes()) {
3810 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
3811 return;
3812 }
3813 closeCreateModal();
3814 closeImportModal();
3815 hideDetailPanelChrome();
3816 const modal = el('modal-create-proposal');
3817 const pathInput = el('proposal-create-path');
3818 const hint = el('modal-create-proposal-hint');
3819 const bodyEl = el('proposal-create-body');
3820 const intentEl = el('proposal-create-intent');
3821 const msgEl = el('proposal-create-msg');
3822 if (!modal || !pathInput || !bodyEl || !intentEl) return;
3823 if (opts && opts.fromNote) {
3824 pathInput.readOnly = true;
3825 pathInput.value = opts.path || '';
3826 if (hint)
3827 hint.textContent =
3828 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
3829 } else {
3830 pathInput.readOnly = false;
3831 pathInput.value = (opts && opts.path) || '';
3832 if (hint)
3833 hint.textContent =
3834 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in the Suggested tab.';
3835 }
3836 bodyEl.value = (opts && opts.body) || '';
3837 intentEl.value = (opts && opts.intent) || '';
3838 if (msgEl) {
3839 msgEl.textContent = '';
3840 msgEl.className = 'create-msg';
3841 }
3842 modal.classList.remove('hidden');
3843 }
3844 btnNewNote.onclick = openCreateModal;
3845 el('modal-create-backdrop').onclick = closeCreateModal;
3846 el('modal-create-close').onclick = closeCreateModal;
3847
3848 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
3849 const modalCreateProposalClose = el('modal-create-proposal-close');
3850 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
3851 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
3852
3853 const btnNewProposal = el('btn-new-proposal');
3854 if (btnNewProposal) {
3855 btnNewProposal.onclick = () => openCreateProposalModal({});
3856 }
3857
3858 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
3859 if (btnProposalCreateSubmit) {
3860 btnProposalCreateSubmit.onclick = async () => {
3861 const pathInput = el('proposal-create-path');
3862 const bodyInput = el('proposal-create-body');
3863 const intentInput = el('proposal-create-intent');
3864 const msgEl = el('proposal-create-msg');
3865 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
3866 if (!rawPath) {
3867 if (msgEl) {
3868 msgEl.textContent = 'Path is required.';
3869 msgEl.className = 'create-msg err';
3870 }
3871 return;
3872 }
3873 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
3874 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
3875 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
3876 try {
3877 await api('/api/v1/proposals', {
3878 method: 'POST',
3879 body: JSON.stringify({
3880 path: rawPath,
3881 body,
3882 ...(intent ? { intent } : {}),
3883 source: 'hub_ui',
3884 }),
3885 });
3886 closeCreateProposalModal();
3887 if (typeof showToast === 'function') showToast('Proposal submitted');
3888 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3889 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3890 const suggestedTab = document.querySelector('[data-tab="suggested"]');
3891 const suggestedPanel = el('tab-suggested');
3892 if (suggestedTab) suggestedTab.classList.add('active');
3893 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
3894 syncHubListSortUI('suggested');
3895 setProposalFiltersBarVisible(true);
3896 refreshNewProposalTabVisibility();
3897 loadProposals();
3898 } catch (e) {
3899 if (msgEl) {
3900 msgEl.textContent = e.message || 'Proposal failed';
3901 msgEl.className = 'create-msg err';
3902 }
3903 }
3904 });
3905 };
3906 }
3907
3908 function syncImportSheetsBlock() {
3909 const sel = el('import-source-type');
3910 const block = el('import-sheets-block');
3911 if (block && sel) block.hidden = sel.value !== 'google-sheets';
3912 }
3913
3914 function openImportModal(preselectSourceType) {
3915 if (!token) {
3916 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
3917 return;
3918 }
3919 closeCreateModal();
3920 closeCreateProposalModal();
3921 hideDetailPanelChrome();
3922 el('modal-import').classList.remove('hidden');
3923 el('import-msg').textContent = '';
3924 if (importFileEl) importFileEl.value = '';
3925 if (importFileFolderEl) importFileFolderEl.value = '';
3926 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
3927 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
3928 setImportBatchAria('');
3929 clearImportDropPending();
3930 const urlIn = el('import-url');
3931 if (urlIn) urlIn.value = '';
3932 const sid = el('import-spreadsheet-id');
3933 const srange = el('import-sheets-range');
3934 if (sid) sid.value = '';
3935 if (srange) srange.value = '';
3936 const importSel = el('import-source-type');
3937 if (importSel && preselectSourceType) {
3938 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
3939 if (hasOption) importSel.value = preselectSourceType;
3940 }
3941 syncImportSheetsBlock();
3942 const outDirEl = el('import-output-dir');
3943 if (outDirEl) outDirEl.value = '';
3944 void (async () => {
3945 await refreshImportVaultFolderSelect();
3946 if (!lastHubFacets) {
3947 try {
3948 lastHubFacets = await fetchFacetsResolved();
3949 } catch (_) {}
3950 }
3951 hydrateImportCreateProjectSlugSelect(lastHubFacets);
3952 const out = el('import-output-dir');
3953 if (out) out.value = defaultImportOutputDir();
3954 syncImportFolderSelectToOutputDir();
3955 syncImportPickersFromOutputDir();
3956 updateImportPathLayoutVisibility();
3957 })();
3958 }
3959 function closeImportModal() {
3960 el('modal-import').classList.add('hidden');
3961 clearImportDropPending();
3962 }
3963 if (btnImport) btnImport.onclick = openImportModal;
3964 el('modal-import-backdrop').onclick = closeImportModal;
3965 el('modal-import-close').onclick = closeImportModal;
3966 const importSourceTypeEl = el('import-source-type');
3967 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
3968
3969 function closeProjectsHelpModal() {
3970 const m = el('modal-projects-help');
3971 if (m) m.classList.add('hidden');
3972 }
3973 function openProjectsHelpModal() {
3974 closeCreateModal();
3975 closeCreateProposalModal();
3976 hideDetailPanelChrome();
3977 const m = el('modal-projects-help');
3978 if (m) m.classList.remove('hidden');
3979 }
3980 const btnProjectsHelp = el('btn-projects-help');
3981 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
3982 const btnFullProjectHelp = el('btn-full-project-help');
3983 if (btnFullProjectHelp) {
3984 btnFullProjectHelp.onclick = () => {
3985 const m = el('modal-projects-help');
3986 if (m) m.classList.remove('hidden');
3987 };
3988 }
3989 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
3990 const modalProjectsHelpClose = el('modal-projects-help-close');
3991 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
3992 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
3993
3994 if (btnImportChooseFolder && importFileFolderEl) {
3995 btnImportChooseFolder.onclick = () => {
3996 importFileFolderEl.click();
3997 };
3998 }
3999 if (importFileFolderEl) {
4000 importFileFolderEl.addEventListener('change', () => {
4001 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4002 clearImportDropPending();
4003 if (importFileEl) importFileEl.value = '';
4004 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4005 }
4006 });
4007 }
4008 if (importFileEl) {
4009 importFileEl.addEventListener('change', () => {
4010 clearImportDropPending();
4011 if (importFileFolderEl) importFileFolderEl.value = '';
4012 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4013 });
4014 }
4015 if (importDropZoneEl) {
4016 let dragOverCount = 0;
4017 const setOver = (on) => {
4018 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4019 else importDropZoneEl.classList.remove('import-drop-zone--over');
4020 };
4021 importDropZoneEl.addEventListener('dragenter', (e) => {
4022 e.preventDefault();
4023 dragOverCount += 1;
4024 setOver(true);
4025 });
4026 importDropZoneEl.addEventListener('dragleave', (e) => {
4027 e.preventDefault();
4028 dragOverCount = Math.max(0, dragOverCount - 1);
4029 if (dragOverCount === 0) setOver(false);
4030 });
4031 importDropZoneEl.addEventListener('dragover', (e) => {
4032 e.preventDefault();
4033 e.stopPropagation();
4034 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4035 });
4036 importDropZoneEl.addEventListener('drop', (e) => {
4037 e.preventDefault();
4038 e.stopPropagation();
4039 dragOverCount = 0;
4040 setOver(false);
4041 const msgEl = el('import-msg');
4042 const p = (async () => {
4043 if (!e.dataTransfer) {
4044 if (msgEl) {
4045 msgEl.textContent = 'Drop did not include any files.';
4046 msgEl.className = 'create-msg err';
4047 }
4048 return;
4049 }
4050 let files;
4051 try {
4052 files = await collectFilesFromDataTransfer(e.dataTransfer);
4053 } catch (dropErr) {
4054 if (msgEl) {
4055 msgEl.textContent =
4056 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4057 msgEl.className = 'create-msg err';
4058 }
4059 return;
4060 }
4061 if (!files || files.length === 0) {
4062 if (msgEl) {
4063 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4064 msgEl.className = 'create-msg err';
4065 }
4066 return;
4067 }
4068 importPendingDropFiles = files;
4069 if (importFileEl) importFileEl.value = '';
4070 if (importFileFolderEl) importFileFolderEl.value = '';
4071 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4072 updateImportDropStatusUi();
4073 if (msgEl) {
4074 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4075 msgEl.className = 'create-msg';
4076 }
4077 })();
4078 p.catch((err) => {
4079 if (el('import-msg')) {
4080 const msg = el('import-msg');
4081 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4082 msg.className = 'create-msg err';
4083 }
4084 });
4085 });
4086 }
4087 if (importBatchCancelBtn) {
4088 importBatchCancelBtn.onclick = () => {
4089 if (importBatchAbort) importBatchAbort.abort();
4090 };
4091 }
4092
4093 /**
4094 * @param {string} postPath
4095 * @param {FormData} formData
4096 * @param {Record<string, string>} importHeaders
4097 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4098 */
4099 async function hubPostImportOnce(postPath, formData, importHeaders) {
4100 let res;
4101 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4102 try {
4103 res = await fetch(postPath, {
4104 method: 'POST',
4105 cache: 'no-store',
4106 headers: importHeaders,
4107 body: formData,
4108 });
4109 break;
4110 } catch (importErr) {
4111 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4112 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4113 await new Promise((r) => setTimeout(r, 3000));
4114 continue;
4115 }
4116 return { ok: false, errText: em, status: 0 };
4117 }
4118 }
4119 const text = await res.text();
4120 let data = {};
4121 try {
4122 data = text ? JSON.parse(text) : {};
4123 } catch (_) {
4124 data = {};
4125 }
4126 if (!res.ok) {
4127 let apiErr = '';
4128 if (data && typeof data === 'object') {
4129 const parts = [data.error, data.message, data.detail].filter(
4130 (x) => x != null && String(x).trim().length > 0,
4131 );
4132 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4133 }
4134 if (!apiErr && text) {
4135 const t = text.trim();
4136 if (t.startsWith('<')) {
4137 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4138 } else {
4139 apiErr = t.slice(0, 280);
4140 }
4141 }
4142 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4143 }
4144 return { ok: true, data };
4145 }
4146
4147 el('btn-import-submit').onclick = async () => {
4148 const importSubmitBtn = el('btn-import-submit');
4149 const sourceType = el('import-source-type').value;
4150 const fileInput = el('import-file');
4151 const urlInput = el('import-url');
4152 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4153 const msgEl = el('import-msg');
4154 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4155 const kz = globalThis.knowtationHubImportZip;
4156
4157 if (!token) {
4158 msgEl.textContent = 'Sign in to import.';
4159 msgEl.className = 'create-msg err';
4160 return;
4161 }
4162 const useUrlImport = urlTrim.length > 0;
4163 if (sourceType === 'url' && !useUrlImport) {
4164 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4165 msgEl.className = 'create-msg err';
4166 return;
4167 }
4168 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4169 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4170 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4171 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4172 const fileArr = usedDrop
4173 ? importPendingDropFiles
4174 : usedFolder
4175 ? Array.from(importFileFolderEl.files)
4176 : fileInput && fileInput.files
4177 ? Array.from(fileInput.files)
4178 : [];
4179 if (sourceType === 'google-sheets' && !useUrlImport) {
4180 if (!sheetId) {
4181 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4182 msgEl.className = 'create-msg err';
4183 return;
4184 }
4185 if (fileArr.length > 0) {
4186 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4187 msgEl.className = 'create-msg err';
4188 return;
4189 }
4190 }
4191 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4192 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4193 msgEl.className = 'create-msg err';
4194 return;
4195 }
4196 if (sourceType === 'notion' && fileArr.length > 1) {
4197 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4198 msgEl.className = 'create-msg err';
4199 return;
4200 }
4201
4202 const dest = getImportProjectAndOutputDir();
4203 if (dest.err) {
4204 msgEl.textContent = dest.err;
4205 msgEl.className = 'create-msg err';
4206 return;
4207 }
4208 const project = dest.project || '';
4209 const outputDir = dest.outputDir;
4210 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4211 const urlModeEl = el('import-url-mode');
4212 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4213 const importPostPath = apiBase + '/api/v1/import';
4214 const urlPostPath = apiBase + '/api/v1/import-url';
4215 const mode =
4216 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4217 ? kz.getHubImportFileMode(sourceType, fileArr)
4218 : 'direct';
4219
4220 if (!useUrlImport && !kz && fileArr.length > 1) {
4221 msgEl.textContent =
4222 '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.';
4223 msgEl.className = 'create-msg err';
4224 return;
4225 }
4226
4227 if (!useUrlImport && mode === 'client_zip' && !kz) {
4228 msgEl.textContent =
4229 '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.';
4230 msgEl.className = 'create-msg err';
4231 return;
4232 }
4233 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4234 msgEl.textContent =
4235 'Too many files for one batch (max ' +
4236 HUB_IMPORT_MAX_SEQUENTIAL +
4237 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4238 msgEl.className = 'create-msg err';
4239 return;
4240 }
4241
4242 if (useUrlImport) {
4243 const jsonBody = { url: urlTrim, mode: urlMode };
4244 if (project) jsonBody.project = project;
4245 if (outputDir) jsonBody.output_dir = outputDir;
4246 if (tags) jsonBody.tags = tags;
4247 msgEl.textContent = 'Importing…';
4248 msgEl.className = 'create-msg';
4249 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4250 try {
4251 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4252 const importVaultId = getCurrentVaultId();
4253 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4254 let res;
4255 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4256 try {
4257 res = await fetch(urlPostPath, {
4258 method: 'POST',
4259 cache: 'no-store',
4260 headers: importHeaders,
4261 body: JSON.stringify(jsonBody),
4262 });
4263 break;
4264 } catch (importErr) {
4265 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4266 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4267 await new Promise((r) => setTimeout(r, 3000));
4268 continue;
4269 }
4270 throw importErr;
4271 }
4272 }
4273 const text = await res.text();
4274 let data = {};
4275 try {
4276 data = text ? JSON.parse(text) : {};
4277 } catch (_) {
4278 data = {};
4279 }
4280 if (!res.ok) {
4281 let apiErr = '';
4282 if (data && typeof data === 'object') {
4283 const parts = [data.error, data.message, data.detail].filter(
4284 (x) => x != null && String(x).trim().length > 0,
4285 );
4286 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4287 }
4288 if (!apiErr && text) {
4289 const t = text.trim();
4290 if (t.startsWith('<')) {
4291 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4292 } else {
4293 apiErr = t.slice(0, 280);
4294 }
4295 }
4296 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4297 msgEl.className = 'create-msg err';
4298 return;
4299 }
4300 const count = data.count ?? data.imported?.length ?? 0;
4301 if (count === 0) {
4302 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
4303 msgEl.className = 'create-msg warn';
4304 } else {
4305 msgEl.textContent = 'Imported ' + count + ' note(s).';
4306 msgEl.className = 'create-msg ok';
4307 }
4308 if (count > 0) hubMarkSemanticIndexStale();
4309 if (typeof loadNotes === 'function') loadNotes();
4310 if (typeof loadFacets === 'function') loadFacets();
4311 if (typeof showToast === 'function') showToast('Import complete');
4312 setTimeout(() => closeImportModal(), 1500);
4313 } catch (e) {
4314 const raw = e && e.message ? String(e.message) : 'Import failed';
4315 const isNetwork =
4316 raw === 'Failed to fetch' ||
4317 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4318 msgEl.textContent = isNetwork
4319 ? raw +
4320 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
4321 : raw;
4322 msgEl.className = 'create-msg err';
4323 }
4324 });
4325 return;
4326 }
4327
4328 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
4329 const importVaultId = getCurrentVaultId();
4330 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
4331
4332 if (mode === 'sequential') {
4333 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
4334 importBatchAbort = new AbortController();
4335 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
4336 msgEl.className = 'create-msg';
4337 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
4338 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4339 const failures = [];
4340 let totalImported = 0;
4341 let okN = 0;
4342 for (let i = 0; i < fileArr.length; i++) {
4343 if (importBatchAbort && importBatchAbort.signal.aborted) {
4344 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
4345 break;
4346 }
4347 const f = fileArr[i];
4348 try {
4349 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
4350 } catch (limErr) {
4351 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
4352 continue;
4353 }
4354 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
4355 const fd = new FormData();
4356 fd.append('source_type', sourceType);
4357 fd.append('file', f);
4358 if (project) fd.append('project', project);
4359 if (outputDir) fd.append('output_dir', outputDir);
4360 if (tags) fd.append('tags', tags);
4361 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4362 if (r.ok && r.data) {
4363 const c = r.data.count ?? r.data.imported?.length ?? 0;
4364 totalImported += typeof c === 'number' ? c : 0;
4365 okN++;
4366 } else {
4367 failures.push({ name: f.name, err: r.errText || 'error' });
4368 }
4369 }
4370 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4371 importBatchAbort = null;
4372 const fl = failures.length
4373 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
4374 : '.';
4375 msgEl.textContent =
4376 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
4377 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
4378 setImportBatchAria(msgEl.textContent);
4379 if (totalImported > 0) hubMarkSemanticIndexStale();
4380 if (typeof loadNotes === 'function') loadNotes();
4381 if (typeof loadFacets === 'function') loadFacets();
4382 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
4383 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
4384 });
4385 return;
4386 }
4387
4388 msgEl.textContent = 'Importing…';
4389 msgEl.className = 'create-msg';
4390 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4391 try {
4392 if (sourceType === 'google-sheets') {
4393 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
4394 ? el('import-spreadsheet-id').value.trim()
4395 : '';
4396 if (!sid) {
4397 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
4398 msgEl.className = 'create-msg err';
4399 return;
4400 }
4401 const rEl = el('import-sheets-range');
4402 const range = rEl && rEl.value ? rEl.value.trim() : '';
4403 const fd = new FormData();
4404 fd.append('source_type', 'google-sheets');
4405 fd.append('spreadsheet_id', sid);
4406 if (range) fd.append('sheets_range', range);
4407 if (project) fd.append('project', project);
4408 if (outputDir) fd.append('output_dir', outputDir);
4409 if (tags) fd.append('tags', tags);
4410 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4411 if (!r.ok) {
4412 msgEl.textContent = r.errText || 'Import failed';
4413 msgEl.className = 'create-msg err';
4414 return;
4415 }
4416 const data = r.data || {};
4417 const count = data.count ?? data.imported?.length ?? 0;
4418 if (count === 0) {
4419 msgEl.textContent =
4420 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
4421 msgEl.className = 'create-msg warn';
4422 } else {
4423 msgEl.textContent = 'Imported ' + count + ' note(s).';
4424 msgEl.className = 'create-msg ok';
4425 }
4426 if (count > 0) hubMarkSemanticIndexStale();
4427 if (typeof loadNotes === 'function') loadNotes();
4428 if (typeof loadFacets === 'function') loadFacets();
4429 if (typeof showToast === 'function') showToast('Import complete');
4430 setTimeout(() => closeImportModal(), 1500);
4431 return;
4432 }
4433 const dupWarn = [];
4434 const warnFn = (s) => {
4435 dupWarn.push(s);
4436 };
4437 /** @type {FormData} */
4438 let formData;
4439 if (mode === 'client_zip' && kz) {
4440 const blob = await kz.buildImportZipBlob(fileArr, {
4441 signal: null,
4442 warn: warnFn,
4443 });
4444 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
4445 formData = new FormData();
4446 formData.append('source_type', sourceType);
4447 formData.append('file', fileOut);
4448 if (project) formData.append('project', project);
4449 if (outputDir) formData.append('output_dir', outputDir);
4450 if (tags) formData.append('tags', tags);
4451 if (dupWarn.length) {
4452 msgEl.className = 'create-msg';
4453 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
4454 }
4455 } else {
4456 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
4457 try {
4458 kz.assertSingleFileWithinLimit(fileArr[0]);
4459 } catch (e1) {
4460 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
4461 msgEl.className = 'create-msg err';
4462 return;
4463 }
4464 }
4465 formData = new FormData();
4466 formData.append('source_type', sourceType);
4467 formData.append('file', fileArr[0]);
4468 if (project) formData.append('project', project);
4469 if (outputDir) formData.append('output_dir', outputDir);
4470 if (tags) formData.append('tags', tags);
4471 }
4472 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
4473 if (!r.ok) {
4474 msgEl.textContent = r.errText || 'Import failed';
4475 msgEl.className = 'create-msg err';
4476 return;
4477 }
4478 const data = r.data || {};
4479 const count = data.count ?? data.imported?.length ?? 0;
4480 let extra = '';
4481 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
4482 if (count === 0) {
4483 const zeroMsg =
4484 sourceType === 'markdown'
4485 ? '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).'
4486 : sourceType === 'pdf'
4487 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
4488 : sourceType === 'docx'
4489 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
4490 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
4491 msgEl.textContent = zeroMsg + extra;
4492 msgEl.className = 'create-msg warn';
4493 } else {
4494 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
4495 msgEl.className = 'create-msg ok';
4496 }
4497 if (count > 0) hubMarkSemanticIndexStale();
4498 if (typeof loadNotes === 'function') loadNotes();
4499 if (typeof loadFacets === 'function') loadFacets();
4500 if (typeof showToast === 'function') showToast('Import complete');
4501 setTimeout(() => closeImportModal(), 1500);
4502 } catch (e) {
4503 const raw = e && e.message ? String(e.message) : 'Import failed';
4504 if (e && e.name === 'AbortError') {
4505 msgEl.textContent = 'Cancelled.';
4506 } else {
4507 const isNetwork =
4508 raw === 'Failed to fetch' ||
4509 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4510 msgEl.textContent = isNetwork
4511 ? raw +
4512 ' — 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.'
4513 : raw;
4514 }
4515 msgEl.className = 'create-msg err';
4516 }
4517 });
4518 };
4519
4520 function openHowToUse(tabId, scrollToId) {
4521 const id = tabId || 'setup';
4522 el('modal-how-to-use').classList.remove('hidden');
4523 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
4524 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
4525 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
4526 if (scrollToId) {
4527 requestAnimationFrame(() => {
4528 const target = document.getElementById(scrollToId);
4529 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4530 });
4531 }
4532 }
4533 function closeHowToUse() {
4534 el('modal-how-to-use').classList.add('hidden');
4535 }
4536 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
4537 const btnLoginHowToUse = el('btn-login-how-to-use');
4538 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
4539 const btnSettingsHelp = el('btn-settings-help');
4540 if (btnSettingsHelp) {
4541 btnSettingsHelp.onclick = () => {
4542 closeSettings();
4543 openHowToUse('knowledge-agents');
4544 };
4545 }
4546 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
4547 el('modal-how-to-use-close').onclick = closeHowToUse;
4548
4549 document.querySelectorAll('.how-to-tab').forEach((tab) => {
4550 tab.addEventListener('click', () => {
4551 const id = tab.dataset.howToTab;
4552 document.querySelectorAll('.how-to-tab').forEach((t) => {
4553 t.classList.toggle('active', t.dataset.howToTab === id);
4554 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
4555 });
4556 document.querySelectorAll('.how-to-panel').forEach((p) => {
4557 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
4558 });
4559 });
4560 });
4561
4562 const modalHowTo = el('modal-how-to-use');
4563 if (modalHowTo) {
4564 modalHowTo.addEventListener('click', (e) => {
4565 const t = e.target;
4566 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
4567 e.preventDefault();
4568 openHowToUse('consolidation');
4569 }
4570 });
4571 }
4572
4573 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
4574 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
4575 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
4576 btnHowToOpenOnboarding.addEventListener('click', () => {
4577 closeHowToUse();
4578 void openOnboardingWizard({ restart: false });
4579 });
4580 }
4581 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
4582 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
4583 btnEmptyStripWizard.dataset.knowtationBound = '1';
4584 btnEmptyStripWizard.addEventListener('click', () => {
4585 void openOnboardingWizard({ restart: true });
4586 });
4587 }
4588 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
4589 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
4590 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
4591 btnEmptyStripGettingStarted.addEventListener('click', () => {
4592 openHowToUse('getting-started');
4593 });
4594 }
4595
4596 function openTokenSavingsHowToFromSettings() {
4597 closeSettings();
4598 openHowToUse('token-savings');
4599 }
4600 const btnConsolToken = el('btn-consol-how-token-savings');
4601 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4602 const btnIntegToken = el('btn-integrations-how-token-savings');
4603 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4604 const btnAgentsToken = el('btn-agents-how-token-savings');
4605 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4606
4607 function openSettings() {
4608 refreshApiBaseFootgunBanner();
4609 closeCreateModal();
4610 el('modal-settings').classList.remove('hidden');
4611 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
4612 document.querySelectorAll('.settings-panel').forEach((p) => {
4613 p.classList.toggle('active', p.id === 'settings-panel-backup');
4614 });
4615 syncAccentUI();
4616 syncThemeUI();
4617 syncColorPaletteUI();
4618 refreshIntegApiStatus();
4619 el('settings-sync-msg').textContent = '';
4620 el('settings-sync-msg').className = 'settings-msg';
4621 el('settings-save-msg').textContent = '';
4622 el('settings-save-msg').className = 'settings-msg';
4623 const policyMsg = el('settings-proposal-policy-msg');
4624 if (policyMsg) {
4625 policyMsg.textContent = '';
4626 policyMsg.className = 'settings-msg';
4627 }
4628 el('settings-mode-display').textContent = 'Loading…';
4629 el('settings-vault-display').textContent = 'Loading…';
4630 el('settings-git-status').textContent = 'Loading…';
4631 const ghStatus = el('settings-github-status');
4632 if (ghStatus) ghStatus.textContent = 'Loading…';
4633 fetchSettingsForBackupModal()
4634 .then((s) => {
4635 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
4636 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
4637 throw new Error(
4638 '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.',
4639 );
4640 }
4641 applySettingsPayloadToHubChrome(s);
4642 const roleEl = el('settings-role-display');
4643 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
4644 const userIdEl = el('settings-user-id');
4645 if (userIdEl) userIdEl.textContent = s.user_id || '—';
4646 const vaultDisplay = s.vault_path_display || '—';
4647 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
4648 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
4649 el('settings-vault-display').textContent = vaultDisplay;
4650 const configureSection = el('settings-configure-backup-section');
4651 const configureHr = el('settings-hr-configure');
4652 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
4653 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
4654 const vg = s.vault_git || {};
4655 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
4656 const step1 = document.getElementById('setup-step-1');
4657 const step4 = document.getElementById('setup-step-4');
4658 const step1Label = el('setup-step-1-label');
4659 const step1Hint = el('setup-step-1-hint');
4660 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
4661 if (step1Hint) {
4662 step1Hint.textContent = isHosted
4663 ? 'Your notes live in your hosted vault'
4664 : 'Set below under Configure backup';
4665 }
4666 if (step1) {
4667 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
4668 step1.classList.toggle('setup-step-done', done);
4669 const icon = step1.querySelector('.setup-step-icon');
4670 if (icon) icon.textContent = done ? '✓' : '';
4671 }
4672 if (step4) {
4673 const done = !!(vg.enabled && vg.has_remote);
4674 step4.classList.toggle('setup-step-done', done);
4675 const icon = step4.querySelector('.setup-step-icon');
4676 if (icon) icon.textContent = done ? '✓' : '';
4677 }
4678 let gitText = 'Not configured';
4679 if (vg.enabled && vg.has_remote) {
4680 gitText = 'Configured';
4681 if (vg.auto_commit) gitText += ' (auto-commit on)';
4682 if (vg.auto_push) gitText += ', auto-push on';
4683 } else if (vg.enabled) gitText = 'Enabled but no remote set';
4684 el('settings-git-status').textContent = gitText;
4685 const evalReqEl = el('settings-proposal-eval-required');
4686 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
4687 const hintsEl = el('settings-proposal-hints-enabled');
4688 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
4689 const enrichStatusEl = el('settings-proposal-enrich-enabled');
4690 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
4691 const evApEl = el('settings-evaluator-may-approve');
4692 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
4693 const syncBtn = el('btn-settings-sync');
4694 const isAdmin = s.role === 'admin';
4695 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
4696 const saveSetupBtn = el('btn-settings-save');
4697 if (saveSetupBtn) {
4698 saveSetupBtn.disabled = false;
4699 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
4700 }
4701 const teamTab = el('settings-tab-team');
4702 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
4703 const vaultsTab = el('settings-tab-vaults');
4704 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
4705 const policyAdmin = el('settings-proposal-policy-admin');
4706 const storedPolicy = s.proposal_policy_stored || {};
4707 const policyLocks = s.proposal_policy_env_locked || {};
4708 if (policyAdmin) {
4709 policyAdmin.classList.toggle('hidden', !isAdmin);
4710 const cEval = el('settings-policy-eval');
4711 const cHints = el('settings-policy-hints');
4712 const cEnrich = el('settings-policy-enrich');
4713 if (cEval && cHints && cEnrich) {
4714 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
4715 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
4716 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
4717 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
4718 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
4719 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
4720 const lockHint =
4721 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4722 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
4723 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
4724 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
4725 }
4726 }
4727 const connectBtn = el('btn-connect-github');
4728 const ghStatus = el('settings-github-status');
4729 const hostedGhHint = el('settings-hosted-connect-github-hint');
4730 if (s.github_connect_available) {
4731 if (connectBtn) {
4732 connectBtn.classList.remove('hidden');
4733 connectBtn.onclick = () => {
4734 const base = apiBase.replace(/\/$/, '');
4735 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
4736 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
4737 };
4738 }
4739 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
4740 } else {
4741 if (connectBtn) {
4742 connectBtn.classList.add('hidden');
4743 connectBtn.onclick = null;
4744 }
4745 if (ghStatus) ghStatus.textContent = '—';
4746 }
4747 if (hostedGhHint) {
4748 const vd = s.vault_path_display || '';
4749 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
4750 }
4751 const hostedRepoSection = el('settings-hosted-backup-repo-section');
4752 const hostedRepoInput = el('settings-hosted-repo');
4753 if (hostedRepoSection) {
4754 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
4755 }
4756 if (hostedRepoInput && isHosted && s.github_connect_available) {
4757 if (!hostedRepoInput.value.trim()) {
4758 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
4759 }
4760 if (!hostedRepoInput.dataset.knowtationBound) {
4761 hostedRepoInput.dataset.knowtationBound = '1';
4762 hostedRepoInput.addEventListener('input', () => {
4763 const syncBtn = el('btn-settings-sync');
4764 if (!syncBtn || !lastBackupSettingsPayload) return;
4765 const vd = lastBackupSettingsPayload.vault_path_display || '';
4766 const ih = (vd + '').toLowerCase() === 'canister';
4767 if (ih && lastBackupSettingsPayload.github_connect_available) {
4768 const vg = lastBackupSettingsPayload.vault_git || {};
4769 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
4770 }
4771 });
4772 }
4773 }
4774 const ed = s.embedding_display || {};
4775 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
4776 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
4777 const ollamaRow = el('agents-ollama-row');
4778 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
4779 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
4780 applyChatProviderSettings(s);
4781 const apiRow = el('settings-api-base-row');
4782 const apiDisp = el('settings-api-base-display');
4783 if (apiRow && apiDisp) {
4784 if (isLocalHubHostname()) {
4785 apiRow.classList.remove('hidden');
4786 apiDisp.textContent = apiBase;
4787 } else {
4788 apiRow.classList.add('hidden');
4789 }
4790 }
4791 refreshApiBaseFootgunBanner();
4792 void refreshBulkDeletePresetDropdowns();
4793 })
4794 .catch((e) => {
4795 const syncMsg = el('settings-sync-msg');
4796 if (syncMsg) {
4797 const m = e && e.message ? String(e.message) : 'Could not load settings.';
4798 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
4799 syncMsg.className = 'settings-msg err';
4800 }
4801 if (typeof console !== 'undefined' && console.error) {
4802 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
4803 }
4804 const hostedGhHint = el('settings-hosted-connect-github-hint');
4805 if (hostedGhHint) hostedGhHint.classList.add('hidden');
4806 const roleEl = el('settings-role-display');
4807 if (roleEl) roleEl.textContent = '—';
4808 const userIdEl = el('settings-user-id');
4809 if (userIdEl) userIdEl.textContent = '—';
4810 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
4811 el('settings-vault-display').textContent = '—';
4812 el('settings-git-status').textContent = 'Could not load';
4813 const evalReqErr = el('settings-proposal-eval-required');
4814 if (evalReqErr) evalReqErr.textContent = '—';
4815 const hintsErr = el('settings-proposal-hints-enabled');
4816 if (hintsErr) hintsErr.textContent = '—';
4817 const enrichErr = el('settings-proposal-enrich-enabled');
4818 if (enrichErr) enrichErr.textContent = '—';
4819 const evApErr = el('settings-evaluator-may-approve');
4820 if (evApErr) evApErr.textContent = '—';
4821 const configureSection = el('settings-configure-backup-section');
4822 const configureHr = el('settings-hr-configure');
4823 if (configureSection) configureSection.style.display = '';
4824 if (configureHr) configureHr.style.display = '';
4825 const ghStatus = el('settings-github-status');
4826 if (ghStatus) ghStatus.textContent = '—';
4827 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
4828 const apiRowErr = el('settings-api-base-row');
4829 const apiDispErr = el('settings-api-base-display');
4830 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
4831 apiRowErr.classList.remove('hidden');
4832 apiDispErr.textContent = apiBase;
4833 }
4834 refreshApiBaseFootgunBanner();
4835 });
4836 api('/api/v1/setup')
4837 .then((u) => {
4838 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
4839 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
4840 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
4841 })
4842 .catch(() => {});
4843 }
4844 function closeSettings() {
4845 el('modal-settings').classList.add('hidden');
4846 }
4847 function openSettingsBillingTab() {
4848 openSettings();
4849 document.querySelectorAll('.settings-tab').forEach((t) => {
4850 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
4851 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
4852 });
4853 document.querySelectorAll('.settings-panel').forEach((p) => {
4854 p.classList.toggle('active', p.id === 'settings-panel-billing');
4855 });
4856 loadBillingPanel();
4857 }
4858
4859 function openSettingsIntegrationsTab() {
4860 openSettings();
4861 document.querySelectorAll('.settings-tab').forEach((t) => {
4862 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
4863 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
4864 });
4865 document.querySelectorAll('.settings-panel').forEach((p) => {
4866 p.classList.toggle('active', p.id === 'settings-panel-integrations');
4867 });
4868 refreshIntegApiStatus();
4869 applyMuseBridgePanel(lastBackupSettingsPayload);
4870 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
4871 }
4872
4873 if (btnSettings) btnSettings.onclick = openSettings;
4874
4875 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
4876 if (btnSettingsSetupGuide) {
4877 btnSettingsSetupGuide.addEventListener('click', () => {
4878 closeSettings();
4879 void openOnboardingWizard({ restart: true });
4880 });
4881 }
4882
4883 const btnProposalPolicySave = el('btn-proposal-policy-save');
4884 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
4885 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
4886 btnProposalPolicySave.addEventListener('click', async () => {
4887 const msg = el('settings-proposal-policy-msg');
4888 if (msg) {
4889 msg.textContent = '';
4890 msg.className = 'settings-msg';
4891 }
4892 try {
4893 await api('/api/v1/settings/proposal-policy', {
4894 method: 'POST',
4895 body: JSON.stringify({
4896 proposal_evaluation_required: el('settings-policy-eval').checked,
4897 review_hints_enabled: el('settings-policy-hints').checked,
4898 enrich_enabled: el('settings-policy-enrich').checked,
4899 }),
4900 });
4901 if (msg) {
4902 msg.textContent = 'Saved.';
4903 msg.className = 'settings-msg ok';
4904 }
4905 const fresh = await fetchSettingsForBackupModal();
4906 applySettingsPayloadToHubChrome(fresh);
4907 const evalReqEl = el('settings-proposal-eval-required');
4908 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
4909 const hintsEl2 = el('settings-proposal-hints-enabled');
4910 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
4911 const enrichEl2 = el('settings-proposal-enrich-enabled');
4912 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
4913 const st = fresh.proposal_policy_stored || {};
4914 const lk = fresh.proposal_policy_env_locked || {};
4915 const ce = el('settings-policy-eval');
4916 const ch = el('settings-policy-hints');
4917 const cr = el('settings-policy-enrich');
4918 if (ce && ch && cr) {
4919 ce.checked = Boolean(st.proposal_evaluation_required);
4920 ch.checked = Boolean(st.review_hints_enabled);
4921 cr.checked = Boolean(st.enrich_enabled);
4922 ce.disabled = Boolean(lk.proposal_evaluation_required);
4923 ch.disabled = Boolean(lk.review_hints_enabled);
4924 cr.disabled = Boolean(lk.enrich_enabled);
4925 const lockHint =
4926 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4927 ce.title = lk.proposal_evaluation_required ? lockHint : '';
4928 ch.title = lk.review_hints_enabled ? lockHint : '';
4929 cr.title = lk.enrich_enabled ? lockHint : '';
4930 }
4931 } catch (e) {
4932 if (msg) {
4933 msg.textContent = e && e.message ? String(e.message) : String(e);
4934 msg.className = 'settings-msg err';
4935 }
4936 }
4937 });
4938 }
4939 el('modal-settings-backdrop').onclick = closeSettings;
4940 el('modal-settings-close').onclick = closeSettings;
4941
4942 el('btn-copy-env-agentception').onclick = () => {
4943 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
4944 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
4945 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
4946 const lines = [];
4947 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
4948 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
4949 }
4950 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
4951 const snippet = lines.join('\n');
4952 const msg = el('agents-copy-msg');
4953 if (navigator.clipboard && navigator.clipboard.writeText) {
4954 navigator.clipboard.writeText(snippet).then(() => {
4955 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
4956 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
4957 }).catch(() => {
4958 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
4959 });
4960 } else {
4961 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
4962 }
4963 };
4964
4965 function refreshIntegApiStatus() {
4966 var dot = el('integ-api-status');
4967 if (!dot) return;
4968 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
4969 dot.classList.toggle('active', hasToken);
4970 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
4971 }
4972
4973 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
4974 let activeIntegGuide = null;
4975
4976 function closeIntegGuideModal() {
4977 const modal = el('modal-integ-guide');
4978 if (modal) modal.classList.add('hidden');
4979 activeIntegGuide = null;
4980 }
4981
4982 function openIntegGuideModal(guide) {
4983 const mod = globalThis.HubIntegrationGuides;
4984 if (!mod || !guide) return;
4985 const modal = el('modal-integ-guide');
4986 const iconEl = el('modal-integ-guide-icon');
4987 const nameEl = el('modal-integ-guide-name');
4988 const leadEl = el('modal-integ-guide-lead');
4989 const contentEl = el('modal-integ-guide-content');
4990 const importBtn = el('btn-integ-guide-import');
4991 const teamBtn = el('btn-integ-guide-team');
4992 const msgEl = el('modal-integ-guide-msg');
4993 if (!modal || !contentEl) return;
4994 activeIntegGuide = guide;
4995 if (iconEl) iconEl.textContent = guide.icon || '';
4996 if (nameEl) nameEl.textContent = guide.name || 'Integration';
4997 if (leadEl) {
4998 leadEl.textContent =
4999 guide.kind === 'capture'
5000 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
5001 : guide.desc || 'Import files or exports into your vault.';
5002 }
5003 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5004 if (msgEl) msgEl.textContent = '';
5005 if (importBtn) {
5006 const importSel = el('import-source-type');
5007 const canPreselect =
5008 guide.hubImport &&
5009 guide.sourceType &&
5010 importSel &&
5011 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5012 const showImport =
5013 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5014 importBtn.classList.toggle('hidden', !showImport);
5015 importBtn.textContent =
5016 guide.id === 'hermes'
5017 ? 'Open Import (Markdown)'
5018 : guide.id === 'imports'
5019 ? 'Open Import'
5020 : 'Open Import';
5021 }
5022 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5023 modal.classList.remove('hidden');
5024 }
5025
5026 let integGuideControlsBound = false;
5027
5028 function bindIntegrationGuideModalControlsOnce() {
5029 if (integGuideControlsBound) return;
5030 integGuideControlsBound = true;
5031 const backdrop = el('modal-integ-guide-backdrop');
5032 const closeBtn = el('modal-integ-guide-close');
5033 const importBtn = el('btn-integ-guide-import');
5034 const teamBtn = el('btn-integ-guide-team');
5035 const contentEl = el('modal-integ-guide-content');
5036 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5037 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5038 if (contentEl) {
5039 contentEl.addEventListener('click', (ev) => {
5040 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5041 if (!btn) return;
5042 const text = btn.getAttribute('data-copy') || '';
5043 const msgEl = el('modal-integ-guide-msg');
5044 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5045 navigator.clipboard.writeText(text).then(() => {
5046 if (msgEl) {
5047 msgEl.textContent = 'Copied.';
5048 msgEl.className = 'settings-msg ok';
5049 }
5050 setTimeout(() => {
5051 if (msgEl) msgEl.textContent = '';
5052 }, 2000);
5053 }).catch(() => {
5054 if (msgEl) {
5055 msgEl.textContent = 'Copy failed';
5056 msgEl.className = 'settings-msg err';
5057 }
5058 });
5059 } else if (msgEl) {
5060 msgEl.textContent = 'Clipboard not available';
5061 msgEl.className = 'settings-msg err';
5062 }
5063 });
5064 }
5065 if (importBtn) {
5066 importBtn.onclick = () => {
5067 const guide = activeIntegGuide;
5068 closeIntegGuideModal();
5069 closeSettings();
5070 const preselect =
5071 guide && guide.id === 'hermes'
5072 ? 'markdown'
5073 : guide && guide.sourceType
5074 ? guide.sourceType
5075 : undefined;
5076 openImportModal(preselect);
5077 };
5078 }
5079 if (teamBtn) {
5080 teamBtn.onclick = () => {
5081 closeIntegGuideModal();
5082 openSettings();
5083 document.querySelectorAll('.settings-tab').forEach((t) => {
5084 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5085 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5086 });
5087 document.querySelectorAll('.settings-panel').forEach((p) => {
5088 p.classList.toggle('active', p.id === 'settings-panel-team');
5089 });
5090 };
5091 }
5092 document.addEventListener('click', (ev) => {
5093 const tile =
5094 ev.target instanceof Element
5095 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5096 : null;
5097 if (!tile) return;
5098 const mod = globalThis.HubIntegrationGuides;
5099 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5100 if (typeof showToast === 'function') {
5101 showToast('Integration details still loading — try again in a moment.', true);
5102 }
5103 scheduleIntegrationGuidesInit(0);
5104 return;
5105 }
5106 const id = tile.getAttribute('data-integ-id');
5107 const guide = id ? mod.getIntegrationGuide(id) : null;
5108 if (guide) {
5109 ev.preventDefault();
5110 openIntegGuideModal(guide);
5111 }
5112 });
5113 }
5114
5115 function scheduleIntegrationGuidesInit(attempt) {
5116 bindIntegrationGuideModalControlsOnce();
5117 if (globalThis.HubIntegrationGuides) return;
5118 if (attempt >= 80) return;
5119 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5120 }
5121
5122 scheduleIntegrationGuidesInit(0);
5123
5124 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5125 if (btnCopyMcpPrime) {
5126 btnCopyMcpPrime.onclick = () => {
5127 const base = String(apiBase || '').replace(/\/$/, '');
5128 const vaultId = getCurrentVaultId() || 'default';
5129 const msg = el('integrations-hub-api-copy-msg');
5130 const payload = {
5131 schema: 'knowtation.hub_copy_prime/v1',
5132 mcp_read_resource_uri: 'knowtation://hosted/prime',
5133 instructions:
5134 '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 ' +
5135 INTEGRATION_DOC_URL,
5136 KNOWTATION_HUB_URL: base,
5137 KNOWTATION_HUB_VAULT_ID: vaultId,
5138 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5139 };
5140 const snippet = JSON.stringify(payload, null, 2);
5141 if (navigator.clipboard && navigator.clipboard.writeText) {
5142 navigator.clipboard.writeText(snippet).then(() => {
5143 if (msg) {
5144 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5145 msg.className = 'settings-msg';
5146 }
5147 setTimeout(() => {
5148 if (msg) msg.textContent = '';
5149 }, 2800);
5150 }).catch(() => {
5151 if (msg) {
5152 msg.textContent = 'Copy failed';
5153 msg.className = 'settings-msg err';
5154 }
5155 });
5156 } else if (msg) {
5157 msg.textContent = 'Clipboard not available';
5158 msg.className = 'settings-msg err';
5159 }
5160 };
5161 }
5162
5163 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5164 if (btnCopyHubApiEnv) {
5165 btnCopyHubApiEnv.onclick = () => {
5166 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5167 const vaultId = getCurrentVaultId() || 'default';
5168 const base = String(apiBase || '').replace(/\/$/, '');
5169 const msg = el('integrations-hub-api-copy-msg');
5170 if (!hubTok) {
5171 if (msg) {
5172 msg.textContent = 'Sign in first, then copy again.';
5173 msg.className = 'settings-msg err';
5174 }
5175 return;
5176 }
5177 const copyLines = [
5178 'KNOWTATION_HUB_URL=' + base,
5179 'KNOWTATION_HUB_TOKEN=' + hubTok,
5180 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5181 ];
5182 if (mcpPublicUrl !== '') {
5183 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5184 }
5185 copyLines.push('');
5186 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5187 copyLines.push(
5188 '# Example curl (append these headers to any Hub REST call): ' +
5189 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5190 '-H "Content-Type: application/json" ' +
5191 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5192 );
5193 const snippet = copyLines.join('\n');
5194 if (navigator.clipboard && navigator.clipboard.writeText) {
5195 navigator.clipboard.writeText(snippet).then(() => {
5196 if (msg) {
5197 msg.textContent = 'Copied session access token (expires — not for always-on agents).';
5198 msg.className = 'settings-msg';
5199 }
5200 refreshIntegApiStatus();
5201 setTimeout(() => {
5202 if (msg) msg.textContent = '';
5203 }, 3500);
5204 }).catch(() => {
5205 if (msg) {
5206 msg.textContent = 'Copy failed';
5207 msg.className = 'settings-msg err';
5208 }
5209 });
5210 } else if (msg) {
5211 msg.textContent = 'Clipboard not available';
5212 msg.className = 'settings-msg err';
5213 }
5214 };
5215 }
5216
5217 /** Settings → Integrations → Connect cloud agent (RFC 8628 device approval). */
5218 /** Device auth mounts on the persistent MCP host — not Netlify api.knowtation.store. */
5219 function deviceAuthBase() {
5220 if (mcpPublicUrl) {
5221 try {
5222 const u = new URL(mcpPublicUrl);
5223 return u.origin;
5224 } catch (_) { /* fall through */ }
5225 }
5226 return String(apiBase || '').replace(/\/$/, '');
5227 }
5228
5229 function setDeviceConnectMsg(text, isErr) {
5230 const msg = el('device-connect-msg');
5231 if (!msg) return;
5232 msg.textContent = text || '';
5233 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5234 }
5235
5236 async function refreshDevicePendingList() {
5237 const list = el('device-pending-list');
5238 if (!list) return;
5239 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5240 if (!hubTok) {
5241 list.innerHTML = '<li>Sign in to see pending agent codes.</li>';
5242 return;
5243 }
5244 try {
5245 const res = await fetch(deviceAuthBase() + '/api/v1/auth/device/pending', {
5246 headers: { Authorization: 'Bearer ' + hubTok },
5247 credentials: 'omit',
5248 });
5249 if (!res.ok) {
5250 list.innerHTML = '<li>Pending list unavailable on this host (device auth mounts on the persistent MCP gateway).</li>';
5251 return;
5252 }
5253 const data = await res.json();
5254 const pending = Array.isArray(data.pending) ? data.pending : [];
5255 if (pending.length === 0) {
5256 list.innerHTML = '<li>No pending cloud-agent codes.</li>';
5257 return;
5258 }
5259 list.innerHTML = pending
5260 .map(function (p) {
5261 const code = String(p.userCode || '').replace(/[<>&]/g, '');
5262 const name = String(p.clientName || p.clientId || 'agent').replace(/[<>&]/g, '');
5263 return '<li><strong>' + code + '</strong> — ' + name + '</li>';
5264 })
5265 .join('');
5266 } catch (_) {
5267 list.innerHTML = '<li>Could not load pending codes.</li>';
5268 }
5269 }
5270
5271 async function postDeviceApproveOrDeny(path) {
5272 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5273 const input = el('device-user-code-input');
5274 const userCode = input ? String(input.value || '').trim() : '';
5275 if (!hubTok) {
5276 setDeviceConnectMsg('Sign in first.', true);
5277 return;
5278 }
5279 if (!userCode) {
5280 setDeviceConnectMsg('Enter the user code shown by your agent.', true);
5281 return;
5282 }
5283 try {
5284 const res = await fetch(deviceAuthBase() + path, {
5285 method: 'POST',
5286 headers: {
5287 Authorization: 'Bearer ' + hubTok,
5288 'Content-Type': 'application/json',
5289 },
5290 credentials: 'omit',
5291 body: JSON.stringify({
5292 user_code: userCode,
5293 vault_id: getCurrentVaultId() || 'default',
5294 }),
5295 });
5296 const data = await res.json().catch(function () { return {}; });
5297 if (!res.ok) {
5298 setDeviceConnectMsg(data.error || ('Request failed (' + res.status + ')'), true);
5299 return;
5300 }
5301 setDeviceConnectMsg(path.indexOf('deny') >= 0 ? 'Denied.' : 'Approved — agent can finish polling.', false);
5302 if (input) input.value = '';
5303 refreshDevicePendingList();
5304 } catch (_) {
5305 setDeviceConnectMsg('Network error talking to device auth endpoint.', true);
5306 }
5307 }
5308
5309 const btnDeviceApprove = el('btn-device-approve');
5310 if (btnDeviceApprove) {
5311 btnDeviceApprove.onclick = function () {
5312 postDeviceApproveOrDeny('/api/v1/auth/device/approve');
5313 };
5314 }
5315 const btnDeviceDeny = el('btn-device-deny');
5316 if (btnDeviceDeny) {
5317 btnDeviceDeny.onclick = function () {
5318 postDeviceApproveOrDeny('/api/v1/auth/device/deny');
5319 };
5320 }
5321 const btnDeviceRefreshPending = el('btn-device-refresh-pending');
5322 if (btnDeviceRefreshPending) {
5323 btnDeviceRefreshPending.onclick = function () {
5324 refreshDevicePendingList();
5325 };
5326 }
5327 const btnCopyCloudSetupPack = el('btn-copy-cloud-setup-pack');
5328 if (btnCopyCloudSetupPack) {
5329 btnCopyCloudSetupPack.onclick = function () {
5330 const pack =
5331 '# Knowtation cloud agent setup (NO SECRETS)\n' +
5332 '# MCP URL: https://mcp.knowtation.store/mcp\n' +
5333 '# Prefer: Hub Settings → Integrations → Connect cloud agent (device code)\n' +
5334 '# Interim (Hostinger Hermes): desktop mcp-remote OAuth → copy ~/.mcp-auth/mcp-remote-* to agent HOME\n' +
5335 '# → Hermes stdio: npx -y mcp-remote https://mcp.knowtation.store/mcp\n' +
5336 '# DO NOT: paste Hub session JWT into always-on .env\n' +
5337 '# DO NOT: use api.knowtation.store/mcp or Netlify /mcp\n' +
5338 '# Full guide: docs/AGENT-INTEGRATION.md (Always-on cloud agents)\n';
5339 if (navigator.clipboard && navigator.clipboard.writeText) {
5340 navigator.clipboard.writeText(pack).then(function () {
5341 setDeviceConnectMsg('Copied non-secret setup pack.', false);
5342 }).catch(function () {
5343 setDeviceConnectMsg('Copy failed', true);
5344 });
5345 } else {
5346 setDeviceConnectMsg('Clipboard not available', true);
5347 }
5348 };
5349 }
5350 try {
5351 var _ucParams = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
5352 var _uc = _ucParams ? _ucParams.get('user_code') : null;
5353 if (!_uc && typeof location !== 'undefined' && location.hash && location.hash.indexOf('user_code=') >= 0) {
5354 var _hq = location.hash.split('?')[1] || '';
5355 _uc = new URLSearchParams(_hq).get('user_code');
5356 }
5357 if (_uc && el('device-user-code-input')) {
5358 el('device-user-code-input').value = String(_uc).toUpperCase();
5359 }
5360 } catch (_) { /* ignore */ }
5361
5362 /** Settings → Integrations → Agent credentials (REST / Paperclip / cron) — Phase C. */
5363 function setAgentCredMsg(text, isErr) {
5364 const msg = el('agent-cred-msg');
5365 if (!msg) return;
5366 msg.textContent = text || '';
5367 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5368 }
5369
5370 function agentCredAuthHeaders() {
5371 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5372 return {
5373 Authorization: 'Bearer ' + hubTok,
5374 'Content-Type': 'application/json',
5375 Accept: 'application/json',
5376 };
5377 }
5378
5379 function formatAgentCredTs(ms) {
5380 if (ms == null || !Number.isFinite(Number(ms))) return '—';
5381 try {
5382 return new Date(Number(ms)).toISOString().slice(0, 19) + 'Z';
5383 } catch (_) {
5384 return '—';
5385 }
5386 }
5387
5388 /** Populate vault multi-select (freeze §8); default current vault selected. */
5389 function refreshAgentCredVaultSelect() {
5390 const sel = el('agent-cred-vault-select');
5391 if (!sel) return;
5392 const current = String(getCurrentVaultId() || 'default');
5393 const s = lastBackupSettingsPayload;
5394 let allowed = [];
5395 if (s && Array.isArray(s.allowed_vault_ids) && s.allowed_vault_ids.length) {
5396 allowed = s.allowed_vault_ids.map(String).filter(Boolean);
5397 } else if (s && Array.isArray(s.vault_list)) {
5398 allowed = s.vault_list
5399 .map(function (v) {
5400 return v && v.id != null ? String(v.id) : '';
5401 })
5402 .filter(Boolean);
5403 }
5404 if (allowed.length === 0) allowed = [current];
5405 if (allowed.indexOf(current) < 0) allowed = [current].concat(allowed);
5406 const prev = Array.prototype.slice
5407 .call(sel.selectedOptions || [])
5408 .map(function (o) {
5409 return o.value;
5410 });
5411 sel.innerHTML = '';
5412 allowed.forEach(function (vid) {
5413 const opt = document.createElement('option');
5414 opt.value = vid;
5415 opt.textContent = vid;
5416 opt.selected = prev.length ? prev.indexOf(vid) >= 0 : vid === current;
5417 sel.appendChild(opt);
5418 });
5419 if (!sel.selectedOptions || sel.selectedOptions.length === 0) {
5420 const fallback =
5421 Array.prototype.find.call(sel.options, function (o) {
5422 return o.value === current;
5423 }) || sel.options[0];
5424 if (fallback) fallback.selected = true;
5425 }
5426 }
5427
5428 function selectedAgentCredVaultIds() {
5429 const sel = el('agent-cred-vault-select');
5430 if (!sel) return [getCurrentVaultId() || 'default'];
5431 const picked = Array.prototype.slice.call(sel.selectedOptions || []).map(function (o) { return String(o.value || '').trim(); }).filter(Boolean);
5432 if (picked.length) return picked.slice(0, 32);
5433 return [getCurrentVaultId() || 'default'];
5434 }
5435
5436 function syncAgentCredWriteWarn() {
5437 const warn = el('agent-cred-write-warn');
5438 const box = el('agent-cred-scope-write');
5439 if (!warn || !box) return;
5440 warn.style.display = box.checked ? 'block' : 'none';
5441 }
5442
5443 async function refreshAgentCredList() {
5444 const list = el('agent-cred-list');
5445 if (!list) return;
5446 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5447 if (!hubTok) {
5448 list.innerHTML = '<li>Sign in to manage agent credentials.</li>';
5449 return;
5450 }
5451 try {
5452 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5453 headers: agentCredAuthHeaders(),
5454 credentials: 'omit',
5455 });
5456 if (!res.ok) {
5457 list.innerHTML = '<li>Agent credentials unavailable on this host (' + res.status + ').</li>';
5458 return;
5459 }
5460 const data = await res.json();
5461 const creds = Array.isArray(data.credentials) ? data.credentials : [];
5462 if (creds.length === 0) {
5463 list.innerHTML = '<li>No agent credentials yet.</li>';
5464 return;
5465 }
5466 list.innerHTML = creds
5467 .map(function (c) {
5468 const id = String(c.id || '').replace(/[<>&"]/g, '');
5469 const name = String(c.name || '').replace(/[<>&"]/g, '');
5470 const scopes = (Array.isArray(c.scopes) ? c.scopes : []).join(' ').replace(/[<>&"]/g, '');
5471 const vaults = (Array.isArray(c.vault_ids) ? c.vault_ids : []).join(', ').replace(/[<>&"]/g, '') || '—';
5472 const created = formatAgentCredTs(c.created_at);
5473 const expires = formatAgentCredTs(c.expires_at);
5474 const lastUsed = formatAgentCredTs(c.last_used_at);
5475 const revoked = c.revoked ? ' (revoked)' : '';
5476 return (
5477 '<li><strong>' +
5478 name +
5479 '</strong> — vaults: ' +
5480 vaults +
5481 '; scopes: ' +
5482 scopes +
5483 '; created: ' +
5484 created +
5485 '; expires: ' +
5486 expires +
5487 '; last used: ' +
5488 lastUsed +
5489 revoked +
5490 ' <button type="button" class="btn-secondary btn-agent-cred-revoke" data-id="' +
5491 id +
5492 '">Revoke</button> <button type="button" class="btn-secondary btn-agent-cred-rotate" data-id="' +
5493 id +
5494 '">Rotate</button></li>'
5495 );
5496 })
5497 .join('');
5498 list.querySelectorAll('.btn-agent-cred-revoke').forEach(function (btn) {
5499 btn.onclick = async function () {
5500 const id = btn.getAttribute('data-id');
5501 const res = await fetch(
5502 String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials/' + encodeURIComponent(id),
5503 { method: 'DELETE', headers: agentCredAuthHeaders(), credentials: 'omit' }
5504 );
5505 setAgentCredMsg(res.ok ? 'Revoked.' : 'Revoke failed', !res.ok);
5506 refreshAgentCredList();
5507 };
5508 });
5509 list.querySelectorAll('.btn-agent-cred-rotate').forEach(function (btn) {
5510 btn.onclick = async function () {
5511 const id = btn.getAttribute('data-id');
5512 const res = await fetch(
5513 String(apiBase || '').replace(/\/$/, '') +
5514 '/api/v1/auth/agent/credentials/' +
5515 encodeURIComponent(id) +
5516 '/rotate',
5517 { method: 'POST', headers: agentCredAuthHeaders(), credentials: 'omit' }
5518 );
5519 const data = await res.json().catch(function () { return {}; });
5520 if (!res.ok) {
5521 setAgentCredMsg(data.error || 'Rotate failed', true);
5522 return;
5523 }
5524 const once = el('agent-cred-once');
5525 const pack =
5526 'KNOWTATION_HUB_URL=' +
5527 String(apiBase || '').replace(/\/$/, '') +
5528 '\nKNOWTATION_HUB_VAULT_ID=' +
5529 (getCurrentVaultId() || 'default') +
5530 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
5531 String(data.credential || '') +
5532 '\n';
5533 if (once) {
5534 once.style.display = 'block';
5535 once.textContent = pack + '\n# Shown once — copy now.';
5536 }
5537 if (navigator.clipboard && navigator.clipboard.writeText) {
5538 navigator.clipboard.writeText(pack).catch(function () {});
5539 }
5540 setAgentCredMsg('Rotated — new secret copied (shown once).', false);
5541 refreshAgentCredList();
5542 };
5543 });
5544 } catch (_) {
5545 list.innerHTML = '<li>Could not load agent credentials.</li>';
5546 }
5547 }
5548
5549 const btnAgentCredMint = el('btn-agent-cred-mint');
5550 if (btnAgentCredMint) {
5551 btnAgentCredMint.onclick = async function () {
5552 const nameEl = el('agent-cred-name-input');
5553 const name = nameEl ? String(nameEl.value || '').trim() : '';
5554 if (!name) {
5555 setAgentCredMsg('Enter a name.', true);
5556 return;
5557 }
5558 const scopes = [];
5559 if (el('agent-cred-scope-propose') && el('agent-cred-scope-propose').checked) scopes.push('propose');
5560 if (el('agent-cred-scope-read') && el('agent-cred-scope-read').checked) scopes.push('vault:read');
5561 if (el('agent-cred-scope-write') && el('agent-cred-scope-write').checked) scopes.push('vault:write');
5562 try {
5563 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5564 method: 'POST',
5565 headers: agentCredAuthHeaders(),
5566 credentials: 'omit',
5567 body: JSON.stringify({
5568 name: name,
5569 vault_ids: selectedAgentCredVaultIds(),
5570 scopes: scopes.length ? scopes : ['propose', 'vault:read'],
5571 }),
5572 });
5573 const data = await res.json().catch(function () { return {}; });
5574 if (!res.ok) {
5575 setAgentCredMsg(data.error || data.code || 'Mint failed', true);
5576 return;
5577 }
5578 const packVault =
5579 (Array.isArray(data.vault_ids) && data.vault_ids[0]) ||
5580 selectedAgentCredVaultIds()[0] ||
5581 getCurrentVaultId() ||
5582 'default';
5583 const pack =
5584 'KNOWTATION_HUB_URL=' +
5585 String(apiBase || '').replace(/\/$/, '') +
5586 '\nKNOWTATION_HUB_VAULT_ID=' +
5587 packVault +
5588 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
5589 String(data.credential || '') +
5590 '\n';
5591 const once = el('agent-cred-once');
5592 if (once) {
5593 once.style.display = 'block';
5594 once.textContent = pack + '\n# Shown once — copy now. Store in Paperclip secrets.';
5595 }
5596 if (navigator.clipboard && navigator.clipboard.writeText) {
5597 await navigator.clipboard.writeText(pack);
5598 }
5599 setAgentCredMsg('Minted — env block copied (secret shown once).', false);
5600 refreshAgentCredList();
5601 } catch (_) {
5602 setAgentCredMsg('Network error minting credential.', true);
5603 }
5604 };
5605 }
5606 const btnAgentCredRefresh = el('btn-agent-cred-refresh');
5607 if (btnAgentCredRefresh) {
5608 btnAgentCredRefresh.onclick = function () {
5609 refreshAgentCredVaultSelect();
5610 refreshAgentCredList();
5611 };
5612 }
5613 const agentCredWriteBox = el('agent-cred-scope-write');
5614 if (agentCredWriteBox) {
5615 agentCredWriteBox.onchange = syncAgentCredWriteWarn;
5616 syncAgentCredWriteWarn();
5617 }
5618 try {
5619 refreshAgentCredVaultSelect();
5620 refreshAgentCredList();
5621 } catch (_) { /* ignore */ }
5622 refreshDevicePendingList();
5623
5624 const btnSettingsMuseSave = el('btn-settings-muse-save');
5625 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
5626 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
5627 btnSettingsMuseSave.addEventListener('click', async () => {
5628 const msg = el('settings-muse-msg');
5629 if (msg) {
5630 msg.textContent = '';
5631 msg.className = 'settings-msg';
5632 }
5633 const input = el('settings-muse-url');
5634 const url = input ? String(input.value || '').trim() : '';
5635 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
5636 try {
5637 await api('/api/v1/settings/muse', {
5638 method: 'POST',
5639 body: JSON.stringify({ url }),
5640 });
5641 if (msg) {
5642 msg.textContent = 'Saved.';
5643 msg.className = 'settings-msg ok';
5644 }
5645 const s = await api('/api/v1/settings');
5646 applySettingsPayloadToHubChrome(s);
5647 } catch (e) {
5648 if (msg) {
5649 msg.textContent =
5650 e && e.code === 'ENV_CONFLICT'
5651 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
5652 : (e && e.message) || 'Save failed';
5653 msg.className = 'settings-msg err';
5654 }
5655 }
5656 });
5657 });
5658 }
5659
5660 document.querySelectorAll('.settings-tab').forEach((tab) => {
5661 tab.addEventListener('click', () => {
5662 const id = tab.dataset.settingsTab;
5663 document.querySelectorAll('.settings-tab').forEach((t) => {
5664 t.classList.toggle('active', t.dataset.settingsTab === id);
5665 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
5666 });
5667 document.querySelectorAll('.settings-panel').forEach((p) => {
5668 p.classList.toggle('active', p.id === 'settings-panel-' + id);
5669 });
5670 if (id === 'team') {
5671 loadTeamRolesList();
5672 loadInvitesList();
5673 }
5674 if (id === 'integrations') {
5675 refreshDevicePendingList();
5676 }
5677 if (id === 'vaults') loadVaultsPanel();
5678 if (id === 'billing') loadBillingPanel();
5679 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
5680 if (id === 'consolidation') loadConsolidationSettings();
5681 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
5682 });
5683 });
5684
5685 function formatTokenCount(n) {
5686 if (n == null || !Number.isFinite(Number(n))) return '—';
5687 return Number(n).toLocaleString();
5688 }
5689
5690 function formatTokenCountShort(n) {
5691 if (n == null || !Number.isFinite(Number(n))) return '—';
5692 const v = Number(n);
5693 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
5694 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
5695 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
5696 return String(v);
5697 }
5698
5699 /**
5700 * Update the token usage progress bar.
5701 * @param {number} used - tokens used this period
5702 * @param {number|null} included - tokens included (null = unlimited)
5703 */
5704 function updateUsageBar(fillId, used, included) {
5705 const fill = el(fillId);
5706 if (!fill) return;
5707 if (included == null) {
5708 fill.style.width = '15%';
5709 fill.className = 'billing-usage-bar-fill';
5710 return;
5711 }
5712 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
5713 fill.style.width = pct + '%';
5714 fill.className =
5715 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
5716 }
5717
5718 const TIER_LABELS = {
5719 free: 'Free',
5720 plus: 'Plus',
5721 growth: 'Growth',
5722 pro: 'Pro',
5723 beta: 'Beta',
5724 starter: 'Plus',
5725 team: 'Team',
5726 };
5727
5728 const TIER_CSS_CLASSES = {
5729 free: 'tier-free',
5730 plus: 'tier-plus',
5731 growth: 'tier-growth',
5732 pro: 'tier-pro',
5733 beta: 'tier-beta',
5734 starter: 'tier-plus',
5735 team: 'tier-pro',
5736 };
5737
5738 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
5739
5740 const TIER_PLAN_DATA = [
5741 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
5742 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
5743 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
5744 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
5745 ];
5746
5747 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
5748 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
5749
5750 /**
5751 * Render the plan comparison grid into #billing-plan-grid.
5752 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
5753 */
5754 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
5755 const grid = el('billing-plan-grid');
5756 if (!grid) return;
5757
5758 const normalized =
5759 currentTier === 'starter' ? 'plus'
5760 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
5761 : currentTier;
5762 const currentRank = TIER_ORDER.indexOf(normalized);
5763
5764 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
5765 const rank = TIER_ORDER.indexOf(tier);
5766 const isCurrent = rank === currentRank;
5767 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
5768
5769 let ctaHtml = '';
5770 if (isCurrent) {
5771 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
5772 } else if (isUpgrade) {
5773 const label = hasSub
5774 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
5775 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
5776 ctaHtml =
5777 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
5778 tier + '">' + label + '</button>';
5779 }
5780
5781 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
5782 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
5783
5784 return (
5785 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
5786 '<div class="billing-plan-card-header">' +
5787 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
5788 '<span class="billing-plan-card-price">' + price + '</span>' +
5789 '</div>' +
5790 '<ul class="billing-plan-card-features">' +
5791 '<li>' + searches + '</li>' +
5792 '<li>' + indexJobs + '</li>' +
5793 '<li>' + notes + '</li>' +
5794 consolLine +
5795 packLine +
5796 '</ul>' +
5797 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
5798 '</div>'
5799 );
5800 });
5801
5802 grid.innerHTML = cards.join('');
5803
5804 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
5805 btn.addEventListener('click', async () => {
5806 const tier = btn.dataset.tier;
5807 setButtonBusy(btn, true, 'Redirecting\u2026');
5808 try {
5809 await redirectToCheckout({ tier });
5810 } catch (e) {
5811 setButtonBusy(btn, false);
5812 const msg = el('billing-panel-msg');
5813 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
5814 }
5815 });
5816 });
5817 }
5818
5819 /**
5820 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
5821 * @param {{ price_id?: string, tier?: string }} opts
5822 */
5823 async function redirectToCheckout(opts) {
5824 const resp = await api('/api/v1/billing/checkout', {
5825 method: 'POST',
5826 headers: { 'Content-Type': 'application/json' },
5827 body: JSON.stringify({
5828 ...opts,
5829 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
5830 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
5831 }),
5832 });
5833 if (resp && resp.url) {
5834 window.location.href = resp.url;
5835 }
5836 }
5837
5838 /**
5839 * Redirect to Stripe Customer Portal.
5840 */
5841 async function redirectToPortal() {
5842 const resp = await api('/api/v1/billing/portal', {
5843 method: 'POST',
5844 headers: { 'Content-Type': 'application/json' },
5845 body: JSON.stringify({
5846 return_url: window.location.origin + window.location.pathname + '?open=billing',
5847 }),
5848 });
5849 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
5850 if (!url) {
5851 throw new Error(
5852 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
5853 );
5854 }
5855 window.location.assign(url);
5856 }
5857
5858 async function loadBillingPanel() {
5859 const msg = el('billing-panel-msg');
5860 const tierEl = el('billing-tier');
5861 const searchesUsedEl = el('billing-searches-used');
5862 const searchesIncEl = el('billing-searches-included');
5863 const indexJobsUsedEl = el('billing-index-jobs-used');
5864 const indexJobsIncEl = el('billing-index-jobs-included');
5865 const packEl = el('billing-pack-balance');
5866 const packRow = el('billing-pack-balance-row');
5867 const periodEl = el('billing-period');
5868 const renewalEl = el('billing-renewal');
5869 const credEl = el('billing-credits-used');
5870 const credRow = el('billing-credits-row');
5871 const polEl = el('billing-indexing-policy');
5872 const noteCap = el('billing-note-cap');
5873 const refreshBtn = el('btn-billing-refresh');
5874 const upgradeBtn = el('btn-billing-upgrade');
5875 const manageBtn = el('btn-billing-manage');
5876 const packSection = el('billing-pack-section');
5877 if (!tierEl || !searchesUsedEl) return;
5878 if (msg) msg.textContent = '';
5879 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
5880
5881 const setDash = () => {
5882 tierEl.textContent = '—';
5883 tierEl.className = 'billing-plan-badge tier-beta';
5884 if (searchesUsedEl) searchesUsedEl.textContent = '—';
5885 if (searchesIncEl) searchesIncEl.textContent = '—';
5886 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
5887 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
5888 if (packEl) packEl.textContent = '0';
5889 if (packRow) packRow.style.display = 'none';
5890 if (periodEl) periodEl.textContent = '—';
5891 if (renewalEl) renewalEl.textContent = '';
5892 if (credEl) credEl.textContent = '—';
5893 if (credRow) credRow.style.display = 'none';
5894 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
5895 if (noteCap) noteCap.textContent = '—';
5896 if (packSection) packSection.style.display = 'none';
5897 if (upgradeBtn) upgradeBtn.style.display = 'none';
5898 if (manageBtn) manageBtn.style.display = 'none';
5899 updateUsageBar('billing-searches-bar-fill', 0, 0);
5900 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
5901 updateUsageBar('billing-consol-bar-fill', 0, 0);
5902 const consolUsedReset = el('billing-consol-used');
5903 const consolIncReset = el('billing-consol-included');
5904 if (consolUsedReset) consolUsedReset.textContent = '—';
5905 if (consolIncReset) consolIncReset.textContent = '—';
5906 renderBillingPlanGrid('beta', false, false);
5907 };
5908
5909 if (!token) {
5910 setDash();
5911 if (msg) msg.textContent = 'Sign in to view billing usage.';
5912 if (refreshBtn) setButtonBusy(refreshBtn, false);
5913 return;
5914 }
5915
5916 try {
5917 const d = await api('/api/v1/billing/summary');
5918 const tier = d.tier != null ? String(d.tier) : 'beta';
5919
5920 // Plan badge
5921 tierEl.textContent = TIER_LABELS[tier] || tier;
5922 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
5923
5924 // Renewal date
5925 if (renewalEl) {
5926 const pe = d.period_end;
5927 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
5928 }
5929
5930 // Plan comparison grid
5931 const hasSub = Boolean(d.has_active_subscription);
5932 const isFreeTier = tier === 'free' || tier === 'beta';
5933 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
5934
5935 // Legacy upgrade button stays hidden (grid handles upgrades now)
5936 if (upgradeBtn) upgradeBtn.style.display = 'none';
5937 // Manage button: visible for active subscribers to reach the Stripe portal
5938 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
5939
5940 // Searches usage bar
5941 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
5942 const searchesInc = d.monthly_searches_included ?? null;
5943 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
5944 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
5945 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
5946
5947 // Index jobs usage bar
5948 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
5949 const indexJobsInc = d.monthly_index_jobs_included ?? null;
5950 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
5951 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
5952 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
5953
5954 // Consolidation jobs usage bar
5955 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
5956 const consolInc = d.monthly_consolidation_jobs_included ?? null;
5957 const consolUsedEl = el('billing-consol-used');
5958 const consolIncEl = el('billing-consol-included');
5959 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
5960 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
5961 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
5962
5963 // Pack balance
5964 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
5965 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
5966 if (packEl) {
5967 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
5968 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
5969 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
5970 let packText = formatTokenCountShort(packBal) +
5971 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
5972 if (packConsolPasses > 0) {
5973 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
5974 }
5975 packEl.textContent = packText;
5976 }
5977 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
5978
5979 // Period
5980 if (periodEl) {
5981 const ps = d.period_start;
5982 const pe = d.period_end;
5983 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
5984 }
5985
5986 // Note cap
5987 if (noteCap) {
5988 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
5989 }
5990
5991 // Legacy credits row (only show if non-zero)
5992 const mu = Number(d.monthly_used_cents) || 0;
5993 const mi = Number(d.monthly_included_effective_cents) || 0;
5994 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
5995 if (credEl && (mu > 0 || mi > 0)) {
5996 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
5997 }
5998
5999 // Token policy
6000 if (polEl) {
6001 const pol = d.indexing_tokens_policy;
6002 if (pol && String(pol).trim()) {
6003 polEl.textContent = String(pol).trim();
6004 polEl.style.display = '';
6005 } else {
6006 polEl.style.display = 'none';
6007 }
6008 }
6009
6010 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
6011 if (packSection) {
6012 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
6013 packSection.style.display = showPacks ? '' : 'none';
6014 }
6015
6016 if (msg) {
6017 msg.textContent = '';
6018 msg.className = 'settings-intro small muted';
6019 }
6020 } catch (e) {
6021 setDash();
6022 const m = e && e.message ? String(e.message) : String(e);
6023 if (msg) {
6024 msg.textContent =
6025 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
6026 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
6027 : m;
6028 msg.className = 'settings-intro small err';
6029 }
6030 }
6031 if (refreshBtn) setButtonBusy(refreshBtn, false);
6032 }
6033
6034 const btnBillingRefresh = el('btn-billing-refresh');
6035 if (btnBillingRefresh) {
6036 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
6037 }
6038
6039 const btnBillingUpgrade = el('btn-billing-upgrade');
6040 if (btnBillingUpgrade) {
6041 btnBillingUpgrade.addEventListener('click', async () => {
6042 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
6043 try {
6044 await redirectToCheckout({ tier: 'plus' });
6045 } catch (e) {
6046 setButtonBusy(btnBillingUpgrade, false);
6047 const packMsg = el('billing-panel-msg');
6048 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
6049 }
6050 });
6051 }
6052
6053 const btnBillingManage = el('btn-billing-manage');
6054 if (btnBillingManage) {
6055 btnBillingManage.addEventListener('click', async () => {
6056 const panelMsg = el('billing-panel-msg');
6057 if (panelMsg) {
6058 panelMsg.textContent = '';
6059 panelMsg.className = 'settings-intro small muted';
6060 }
6061 setButtonBusy(btnBillingManage, true, 'Redirecting…');
6062 try {
6063 await redirectToPortal();
6064 } catch (e) {
6065 setButtonBusy(btnBillingManage, false);
6066 const errText = e?.message || 'Could not open billing portal.';
6067 if (panelMsg) {
6068 panelMsg.textContent = errText;
6069 panelMsg.className = 'settings-intro small err';
6070 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6071 }
6072 }
6073 });
6074 }
6075
6076 // Token pack purchase buttons
6077 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
6078 btn.addEventListener('click', async () => {
6079 const pack = btn.dataset.pack;
6080 const packMsgEl = el('billing-pack-msg');
6081 setButtonBusy(btn, true, 'Redirecting…');
6082 if (packMsgEl) packMsgEl.textContent = '';
6083 try {
6084 await redirectToCheckout({ pack_size: pack });
6085 } catch (e) {
6086 setButtonBusy(btn, false);
6087 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
6088 }
6089 });
6090 });
6091
6092 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
6093 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
6094 const arr = Array.isArray(vaults) ? vaults : [];
6095 if (arr.length === 0) {
6096 return isHosted
6097 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
6098 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
6099 }
6100 const items = arr
6101 .map((v) => {
6102 if (!v || v.id == null) return '';
6103 const id = escapeHtml(String(v.id).trim());
6104 const lab =
6105 v.label != null && String(v.label).trim()
6106 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
6107 : '';
6108 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
6109 const pathHtml = pathRaw
6110 ? escapeHtml(pathRaw)
6111 : '<span class="muted">—</span>';
6112 return (
6113 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
6114 id +
6115 '</code>' +
6116 lab +
6117 '</div><div class="vaults-summary-path muted small">' +
6118 pathHtml +
6119 '</div></li>'
6120 );
6121 })
6122 .filter(Boolean)
6123 .join('');
6124 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
6125 }
6126
6127 function collectVaultIdsForAccessForm(vaults, settingsRes) {
6128 const set = new Set(['default']);
6129 const allowed =
6130 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
6131 allowed.forEach((id) => {
6132 if (id != null && String(id).trim()) set.add(String(id).trim());
6133 });
6134 (vaults || []).forEach((v) => {
6135 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
6136 });
6137 return Array.from(set).sort((a, b) => {
6138 if (a === 'default') return -1;
6139 if (b === 'default') return 1;
6140 return a.localeCompare(b);
6141 });
6142 }
6143
6144 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
6145 if (!selectEl) return;
6146 const uids = new Set();
6147 (roleIds || []).forEach((id) => {
6148 if (id != null && String(id).trim()) uids.add(String(id).trim());
6149 });
6150 if (currentUserId != null && String(currentUserId).trim()) {
6151 uids.add(String(currentUserId).trim());
6152 }
6153 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
6154 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
6155 sorted.forEach((uid) => {
6156 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
6157 });
6158 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
6159 selectEl.innerHTML = html;
6160 }
6161
6162 function renderAccessVaultCheckboxes(vaultIds) {
6163 const wrap = el('access-form-vault-checkboxes');
6164 if (!wrap) return;
6165 if (!vaultIds.length) {
6166 wrap.innerHTML =
6167 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
6168 return;
6169 }
6170 wrap.innerHTML = vaultIds
6171 .map((id) => {
6172 const idAttr = escapeHtml(id);
6173 return (
6174 '<label><input type="checkbox" name="hub-access-vault" value="' +
6175 idAttr +
6176 '"> <code>' +
6177 idAttr +
6178 '</code></label>'
6179 );
6180 })
6181 .join('');
6182 }
6183
6184 function parseVaultAccessFromTextarea() {
6185 const accessText = el('vault-access-json');
6186 try {
6187 const access = JSON.parse((accessText && accessText.value) || '{}');
6188 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
6189 } catch (_) {
6190 return {};
6191 }
6192 }
6193
6194 function refreshAccessRulesSummary(access) {
6195 const wrap = el('access-rules-summary');
6196 if (!wrap) return;
6197 if (typeof access !== 'object' || access === null) access = {};
6198 const keys = Object.keys(access);
6199 if (keys.length === 0) {
6200 wrap.innerHTML =
6201 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
6202 return;
6203 }
6204 wrap.innerHTML = keys
6205 .sort((a, b) => a.localeCompare(b))
6206 .map((uid) => {
6207 const arr = access[uid];
6208 const vaults =
6209 Array.isArray(arr) && arr.length
6210 ? arr.map((x) => escapeHtml(String(x))).join(', ')
6211 : '<span class="muted">(invalid)</span>';
6212 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
6213 })
6214 .join('');
6215 }
6216
6217 function accessFormToggleOtherInput() {
6218 const sel = el('access-form-user-select');
6219 const wrap = el('access-form-user-other-wrap');
6220 const other = el('access-form-user-other');
6221 if (!sel || !wrap) return;
6222 const show = sel.value === '__other__';
6223 wrap.classList.toggle('hidden', !show);
6224 if (!show && other) other.value = '';
6225 }
6226
6227 function accessFormSyncCheckboxesFromAccessJson() {
6228 const sel = el('access-form-user-select');
6229 const other = el('access-form-user-other');
6230 if (!sel) return;
6231 let uid = '';
6232 if (sel.value === '__other__') {
6233 uid = ((other && other.value) || '').trim();
6234 } else {
6235 uid = (sel.value || '').trim();
6236 }
6237 const access = parseVaultAccessFromTextarea();
6238 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
6239 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
6240 cb.checked = allowed.indexOf(cb.value) !== -1;
6241 });
6242 }
6243
6244 function getAccessFormResolvedUserId() {
6245 const sel = el('access-form-user-select');
6246 const other = el('access-form-user-other');
6247 if (!sel) return '';
6248 if (sel.value === '__other__') return ((other && other.value) || '').trim();
6249 return (sel.value || '').trim();
6250 }
6251
6252 const accessUserSel = el('access-form-user-select');
6253 if (accessUserSel) {
6254 accessUserSel.addEventListener('change', () => {
6255 accessFormToggleOtherInput();
6256 accessFormSyncCheckboxesFromAccessJson();
6257 });
6258 }
6259 const accessUserOther = el('access-form-user-other');
6260 if (accessUserOther) {
6261 accessUserOther.addEventListener('input', () => {
6262 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
6263 accessFormSyncCheckboxesFromAccessJson();
6264 }
6265 });
6266 }
6267 const scopeUserSelInit = el('scope-form-user-select');
6268 if (scopeUserSelInit) {
6269 scopeUserSelInit.addEventListener('change', () => {
6270 const inp = el('scope-form-user-id');
6271 if (scopeUserSelInit.value === '__other__') {
6272 if (inp) inp.focus();
6273 } else if (scopeUserSelInit.value && inp) {
6274 inp.value = scopeUserSelInit.value;
6275 }
6276 });
6277 }
6278
6279 function populateVaultListExistingSelect(vaults) {
6280 const sel = el('vault-list-form-existing');
6281 if (!sel) return;
6282 let html = '<option value="">New vault</option>';
6283 (vaults || []).forEach((v) => {
6284 if (v && v.id != null && String(v.id).trim()) {
6285 const id = String(v.id).trim();
6286 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
6287 }
6288 });
6289 sel.innerHTML = html;
6290 }
6291
6292 function parseVaultsJsonArrayFromTextarea() {
6293 const ta = el('vaults-json');
6294 try {
6295 const arr = JSON.parse((ta && ta.value) || '[]');
6296 return Array.isArray(arr) ? arr : [];
6297 } catch (_) {
6298 return null;
6299 }
6300 }
6301
6302 function fillVaultListFormFromExisting() {
6303 const sel = el('vault-list-form-existing');
6304 const idInp = el('vault-list-form-id');
6305 const pathInp = el('vault-list-form-path');
6306 const labelInp = el('vault-list-form-label');
6307 if (!sel) return;
6308 if (!sel.value) {
6309 if (idInp) {
6310 idInp.value = '';
6311 idInp.readOnly = false;
6312 }
6313 if (pathInp) pathInp.value = '';
6314 if (labelInp) labelInp.value = '';
6315 return;
6316 }
6317 const vaults = parseVaultsJsonArrayFromTextarea();
6318 if (!vaults) return;
6319 const v = vaults.find((x) => x && String(x.id) === sel.value);
6320 if (v) {
6321 if (idInp) {
6322 idInp.value = String(v.id);
6323 idInp.readOnly = true;
6324 }
6325 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
6326 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
6327 }
6328 }
6329
6330 function toggleVaultsInfoPanel(panelId) {
6331 const panel = el(panelId);
6332 const modal = el('modal-settings');
6333 if (!panel || !modal) return;
6334 const wasHidden = panel.classList.contains('hidden');
6335 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
6336 if (wasHidden) panel.classList.remove('hidden');
6337 }
6338
6339 const modalSettingsForVaultsInfo = el('modal-settings');
6340 if (modalSettingsForVaultsInfo) {
6341 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
6342 const infoBtn = e.target.closest('.btn-settings-info');
6343 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
6344 e.stopPropagation();
6345 const tid = infoBtn.getAttribute('data-settings-info-target');
6346 if (tid) toggleVaultsInfoPanel(tid);
6347 return;
6348 }
6349 if (
6350 !e.target.closest('.settings-info-panel') &&
6351 !e.target.closest('.btn-settings-info')
6352 ) {
6353 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
6354 p.classList.add('hidden');
6355 });
6356 }
6357 });
6358 }
6359
6360 const vaultListExistingSel = el('vault-list-form-existing');
6361 if (vaultListExistingSel) {
6362 vaultListExistingSel.addEventListener('change', () => {
6363 fillVaultListFormFromExisting();
6364 const msg = el('vault-list-form-msg');
6365 if (msg) msg.textContent = '';
6366 });
6367 }
6368
6369 const btnVaultListFormApply = el('btn-vault-list-form-apply');
6370 if (btnVaultListFormApply) {
6371 btnVaultListFormApply.onclick = () => {
6372 const msg = el('vault-list-form-msg');
6373 const ta = el('vaults-json');
6374 const idInp = el('vault-list-form-id');
6375 const pathInp = el('vault-list-form-path');
6376 const labelInp = el('vault-list-form-label');
6377 const vaults = parseVaultsJsonArrayFromTextarea();
6378 if (!vaults) {
6379 if (msg) {
6380 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
6381 msg.className = 'settings-msg err';
6382 }
6383 return;
6384 }
6385 const id = ((idInp && idInp.value) || '').trim();
6386 const path = ((pathInp && pathInp.value) || '').trim();
6387 const label = ((labelInp && labelInp.value) || '').trim();
6388 if (!id || !path) {
6389 if (msg) {
6390 msg.textContent = 'Enter vault id and folder path.';
6391 msg.className = 'settings-msg err';
6392 }
6393 return;
6394 }
6395 const entry = { id, path };
6396 if (label) entry.label = label;
6397 const idx = vaults.findIndex((x) => x && String(x.id) === id);
6398 if (idx >= 0) {
6399 vaults[idx] = Object.assign({}, vaults[idx], entry);
6400 } else {
6401 if (idInp && idInp.readOnly) {
6402 if (msg) {
6403 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
6404 msg.className = 'settings-msg err';
6405 }
6406 return;
6407 }
6408 vaults.push(entry);
6409 }
6410 if (ta) ta.value = JSON.stringify(vaults, null, 2);
6411 populateVaultListExistingSelect(vaults);
6412 const sel = el('vault-list-form-existing');
6413 if (sel) sel.value = '';
6414 fillVaultListFormFromExisting();
6415 const lc = el('vaults-list-container');
6416 if (lc && !isHostedHubFromSettings()) {
6417 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6418 }
6419 if (msg) {
6420 msg.textContent = 'Updated. Click Save vault list to persist.';
6421 msg.className = 'settings-msg ok';
6422 }
6423 };
6424 }
6425
6426 async function loadVaultsPanel() {
6427 const listContainer = el('vaults-list-container');
6428 const serverView = el('vaults-server-view');
6429 const vaultsJson = el('vaults-json');
6430 const accessText = el('vault-access-json');
6431 const scopeText = el('scope-json');
6432 const helpHostedBlock = el('vaults-help-hosted-block');
6433 const helpSelfBlock = el('vaults-help-self-block');
6434 const selfHostedEditors = el('vaults-self-hosted-editors');
6435 const yamlOnly = el('vaults-hub-yaml-only');
6436 const hostedCreate = el('vaults-hosted-create');
6437 const workspacePanel = el('vaults-hosted-workspace');
6438 const workspaceInput = el('workspace-owner-input');
6439 const workspaceMsg = el('workspace-save-msg');
6440 if (listContainer) listContainer.textContent = 'Loading…';
6441 if (serverView) serverView.textContent = 'Loading…';
6442 try {
6443 const settingsRes = await api('/api/v1/settings');
6444 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6445 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6446 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6447 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6448 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6449 const ownerFromSettings =
6450 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6451 ? String(settingsRes.workspace_owner_id).trim()
6452 : '';
6453 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
6454 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
6455 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
6456 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
6457 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
6458 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
6459 const hostedCreateMsg = el('vaults-hosted-create-msg');
6460 if (hostedCreateMsg && isHosted) {
6461 hostedCreateMsg.textContent = '';
6462 hostedCreateMsg.className = 'settings-msg';
6463 }
6464 if (workspaceMsg) {
6465 workspaceMsg.textContent = '';
6466 workspaceMsg.className = 'settings-msg';
6467 }
6468
6469 /** @type {{ vaults?: unknown[] }} */
6470 let vRes = { vaults: [] };
6471 try {
6472 vRes = await api('/api/v1/vaults');
6473 } catch (_) {
6474 vRes = { vaults: [] };
6475 }
6476 /** @type {{ access?: Record<string, unknown> }} */
6477 let aRes = { access: {} };
6478 try {
6479 aRes = await api('/api/v1/vault-access');
6480 } catch (_) {
6481 aRes = { access: {} };
6482 }
6483 /** @type {{ scope?: Record<string, unknown> }} */
6484 let sRes = { scope: {} };
6485 try {
6486 sRes = await api('/api/v1/scope');
6487 } catch (_) {
6488 sRes = { scope: {} };
6489 }
6490
6491 if (isHosted && workspaceInput) {
6492 try {
6493 const w = await api('/api/v1/workspace');
6494 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
6495 } catch (e) {
6496 workspaceInput.value = '';
6497 if (workspaceMsg) {
6498 workspaceMsg.textContent =
6499 (e && e.message) ||
6500 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
6501 workspaceMsg.className = 'settings-msg err';
6502 }
6503 }
6504 } else if (workspaceInput && !isHosted) {
6505 workspaceInput.value = '';
6506 }
6507 const vaults = vRes.vaults || [];
6508 if (serverView) {
6509 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
6510 const allowed = settingsRes.allowed_vault_ids;
6511 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
6512 if (isHosted) {
6513 serverView.innerHTML =
6514 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6515 escapeHtml(uid) +
6516 '</code> · <strong>Vaults:</strong> <code>' +
6517 escapeHtml(allowedStr) +
6518 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
6519 } else {
6520 const dataDir =
6521 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
6522 serverView.innerHTML =
6523 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6524 escapeHtml(uid) +
6525 '</code> · <strong>Allowed vaults:</strong> <code>' +
6526 escapeHtml(allowedStr) +
6527 '</code> · <strong>Data:</strong> <code>' +
6528 dataDir +
6529 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
6530 }
6531 }
6532 if (listContainer) {
6533 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
6534 }
6535 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
6536 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
6537 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
6538
6539 const vaultListJsonDetails = el('vault-list-json-details');
6540 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
6541 const vaultAccessDetails = el('vault-access-json-details');
6542 if (vaultAccessDetails) vaultAccessDetails.open = false;
6543 const scopeJsonDetails = el('scope-json-details');
6544 if (scopeJsonDetails) scopeJsonDetails.open = false;
6545
6546 let roleIds = [];
6547 try {
6548 const ro = await api('/api/v1/roles');
6549 roleIds = Object.keys(ro.roles || {});
6550 } catch (_) {
6551 roleIds = [];
6552 }
6553 populateHostedTeamUserSelect(
6554 el('access-form-user-select'),
6555 roleIds,
6556 settingsRes.user_id,
6557 '— Choose a person —',
6558 );
6559 populateHostedTeamUserSelect(
6560 el('scope-form-user-select'),
6561 roleIds,
6562 settingsRes.user_id,
6563 '— Choose or type User ID below —',
6564 );
6565 const asel = el('access-form-user-select');
6566 if (asel) asel.value = '';
6567 const ssel = el('scope-form-user-select');
6568 if (ssel) ssel.value = '';
6569 accessFormToggleOtherInput();
6570 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
6571 renderAccessVaultCheckboxes(vaultIdsForForm);
6572 accessFormSyncCheckboxesFromAccessJson();
6573 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6574
6575 const scopeVaultSelect = el('scope-form-vault-id');
6576 if (scopeVaultSelect) {
6577 scopeVaultSelect.innerHTML =
6578 vaults.length === 0
6579 ? '<option value="default">default</option>'
6580 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
6581 }
6582
6583 if (!isHosted) {
6584 populateVaultListExistingSelect(vaults);
6585 const vSel = el('vault-list-form-existing');
6586 if (vSel) vSel.value = '';
6587 fillVaultListFormFromExisting();
6588 }
6589 } catch (e) {
6590 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
6591 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
6592 }
6593 }
6594
6595 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
6596 function sanitizeNewHostedVaultId(raw) {
6597 const t = String(raw || '').trim();
6598 if (!t) return { error: 'Enter a vault id.' };
6599 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
6600 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
6601 s = s.slice(0, 64);
6602 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
6603 if (s === 'default') {
6604 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
6605 }
6606 return { id: s };
6607 }
6608
6609 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
6610 if (btnHostedVaultCreate) {
6611 btnHostedVaultCreate.onclick = async () => {
6612 const msgEl = el('vaults-hosted-create-msg');
6613 const inp = el('vaults-hosted-new-id');
6614 const setCreateVaultMsg = (text, isErr) => {
6615 if (!msgEl) return;
6616 msgEl.textContent = text;
6617 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6618 };
6619 if (!isHostedHubFromSettings()) {
6620 setCreateVaultMsg('This action is only available on hosted Hub.', true);
6621 return;
6622 }
6623 if (!hubUserCanWriteNotes()) {
6624 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
6625 return;
6626 }
6627 const ws = lastBackupSettingsPayload;
6628 const ownerId =
6629 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
6630 ? String(ws.workspace_owner_id).trim()
6631 : '';
6632 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
6633 if (ownerId && me && me !== ownerId) {
6634 setCreateVaultMsg(
6635 '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.',
6636 true,
6637 );
6638 return;
6639 }
6640 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
6641 if (parsed.error) {
6642 setCreateVaultMsg(parsed.error, true);
6643 return;
6644 }
6645 const { id } = parsed;
6646 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
6647 setCreateVaultMsg('');
6648 try {
6649 const fresh = await api('/api/v1/settings');
6650 const allowed = fresh.allowed_vault_ids || [];
6651 if (Array.isArray(allowed) && allowed.includes(id)) {
6652 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the header to switch to it.', true);
6653 return;
6654 }
6655 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
6656 await api('/api/v1/notes', {
6657 method: 'POST',
6658 headers: { 'X-Vault-Id': id },
6659 body: JSON.stringify({
6660 path,
6661 body:
6662 'This note was created when you added the "' +
6663 id +
6664 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
6665 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
6666 }),
6667 });
6668 hubMarkSemanticIndexStaleForVault(id);
6669 const s = await api('/api/v1/settings');
6670 lastBackupSettingsPayload = s;
6671 if (s.role) window.__hubUserRole = String(s.role);
6672 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6673 applyHostedUiFromSettings(s);
6674 setCurrentVaultId(id);
6675 const sel = el('vault-switcher');
6676 if (sel) sel.value = id;
6677 loadFacets();
6678 loadNotes();
6679 loadProposals();
6680 await loadVaultsPanel();
6681 if (inp) inp.value = '';
6682 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the header to switch.', false);
6683 } catch (e) {
6684 setCreateVaultMsg(e.message || 'Could not create vault', true);
6685 }
6686 });
6687 };
6688 }
6689
6690 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
6691 if (btnSettingsDeleteVault) {
6692 btnSettingsDeleteVault.onclick = async () => {
6693 const msgEl = el('settings-delete-vault-msg');
6694 const setVaultDelMsg = (text, isErr) => {
6695 if (!msgEl) return;
6696 msgEl.textContent = text;
6697 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6698 };
6699 if (!hubUserMayDeleteVault()) {
6700 setVaultDelMsg('You are not allowed to delete vaults.', true);
6701 return;
6702 }
6703 const sel = el('settings-delete-vault-select');
6704 const vaultId = (sel && sel.value) || '';
6705 const vaultIdTrim = String(vaultId).trim();
6706 if (!vaultIdTrim) {
6707 setVaultDelMsg('Choose a vault to delete.', true);
6708 return;
6709 }
6710 if (vaultIdTrim === 'default') {
6711 setVaultDelMsg('The default vault cannot be deleted.', true);
6712 return;
6713 }
6714 const confirmEl = el('settings-delete-vault-confirm');
6715 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
6716 if (confirmVal !== 'DELETE VAULT') {
6717 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
6718 return;
6719 }
6720 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
6721 setVaultDelMsg('', false);
6722 try {
6723 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
6724 method: 'DELETE',
6725 headers: { 'X-Vault-Id': vaultIdTrim },
6726 });
6727 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
6728 if (wasCurrent) {
6729 setCurrentVaultId('default');
6730 const vSel = el('vault-switcher');
6731 if (vSel) vSel.value = 'default';
6732 }
6733 const s = await api('/api/v1/settings');
6734 lastBackupSettingsPayload = s;
6735 if (s.role) window.__hubUserRole = String(s.role);
6736 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6737 applyHostedUiFromSettings(s);
6738 refreshDeleteProjectPanelVisibility();
6739 loadFacets();
6740 loadNotes();
6741 loadProposals();
6742 await loadVaultsPanel();
6743 if (confirmEl) confirmEl.value = '';
6744 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
6745 } catch (e) {
6746 setVaultDelMsg(e.message || 'Could not delete vault', true);
6747 }
6748 });
6749 };
6750 }
6751
6752 const btnScopeFormApply = el('btn-scope-form-apply');
6753 if (btnScopeFormApply) {
6754 btnScopeFormApply.onclick = () => {
6755 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
6756 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
6757 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
6758 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
6759 const msg = el('scope-form-msg');
6760 if (!userId) {
6761 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
6762 return;
6763 }
6764 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
6765 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
6766 const scopeText = el('scope-json');
6767 let scope = {};
6768 if (scopeText && scopeText.value) {
6769 try {
6770 scope = JSON.parse(scopeText.value);
6771 if (typeof scope !== 'object' || scope === null) scope = {};
6772 } catch (_) { scope = {}; }
6773 }
6774 if (!scope[userId]) scope[userId] = {};
6775 scope[userId][vaultId] = { projects, folders };
6776 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
6777 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
6778 };
6779 }
6780
6781 function isHostedHubFromSettings() {
6782 const s = lastBackupSettingsPayload;
6783 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
6784 }
6785
6786 const BULK_PRESET_EMPTY = '';
6787 const BULK_PRESET_CUSTOM = '__custom__';
6788
6789 function fillBulkPresetSelect(sel, items, includeCustom) {
6790 if (!sel) return;
6791 const preserve = sel.value;
6792 sel.innerHTML = '';
6793 const head = document.createElement('option');
6794 head.value = BULK_PRESET_EMPTY;
6795 head.textContent = '— Select or type below —';
6796 sel.appendChild(head);
6797 for (const item of items) {
6798 if (item == null || item === '') continue;
6799 const o = document.createElement('option');
6800 o.value = item;
6801 o.textContent = item;
6802 sel.appendChild(o);
6803 }
6804 if (includeCustom) {
6805 const c = document.createElement('option');
6806 c.value = BULK_PRESET_CUSTOM;
6807 c.textContent = 'Custom (type below)';
6808 sel.appendChild(c);
6809 }
6810 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
6811 else sel.value = BULK_PRESET_EMPTY;
6812 }
6813
6814 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
6815 if (!selectEl || !inputEl) return;
6816 const p = (inputEl.value || '').trim();
6817 if (!p) {
6818 selectEl.value = BULK_PRESET_EMPTY;
6819 return;
6820 }
6821 let best = BULK_PRESET_CUSTOM;
6822 let bestLen = -1;
6823 for (const opt of selectEl.options) {
6824 const v = opt.value;
6825 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
6826 if (p === v || p.startsWith(v + '/')) {
6827 if (v.length > bestLen) {
6828 best = v;
6829 bestLen = v.length;
6830 }
6831 }
6832 }
6833 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
6834 }
6835
6836 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
6837 if (!selectEl || !inputEl) return;
6838 const p = (inputEl.value || '').trim();
6839 if (!p) {
6840 selectEl.value = BULK_PRESET_EMPTY;
6841 return;
6842 }
6843 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
6844 else selectEl.value = BULK_PRESET_CUSTOM;
6845 }
6846
6847 function wireBulkPathPresetPair(selectEl, inputEl) {
6848 if (!selectEl || !inputEl) return;
6849 selectEl.addEventListener('change', () => {
6850 const v = selectEl.value;
6851 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6852 });
6853 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
6854 }
6855
6856 function wireBulkSlugPresetPair(selectEl, inputEl) {
6857 if (!selectEl || !inputEl) return;
6858 selectEl.addEventListener('change', () => {
6859 const v = selectEl.value;
6860 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6861 });
6862 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
6863 }
6864
6865 let bulkPresetDropdownsToken = 0;
6866 async function refreshBulkDeletePresetDropdowns() {
6867 if (!token) return;
6868 const pathSelect = el('settings-bulk-path-prefix-preset');
6869 const delProjSelect = el('settings-bulk-delete-project-preset');
6870 const renameFromSelect = el('settings-bulk-rename-from-preset');
6871 const pathInput = el('settings-delete-prefix');
6872 const delProjInput = el('settings-delete-project-slug');
6873 const renameFromInput = el('settings-rename-project-from');
6874 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
6875 const my = ++bulkPresetDropdownsToken;
6876 let diskFolders = [];
6877 let facets = { projects: [], folders: [] };
6878 try {
6879 const [vf, fc] = await Promise.all([
6880 api('/api/v1/vault/folders'),
6881 api('/api/v1/notes/facets'),
6882 ]);
6883 if (my !== bulkPresetDropdownsToken) return;
6884 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
6885 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
6886 } catch (_) {
6887 if (my !== bulkPresetDropdownsToken) return;
6888 }
6889 const pathSet = new Set();
6890 for (const f of diskFolders) {
6891 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6892 }
6893 for (const f of facets.folders || []) {
6894 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6895 }
6896 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
6897 const pathPrefixes = ['inbox', ...rest];
6898 const projects = [
6899 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
6900 ].sort((a, b) => a.localeCompare(b));
6901
6902 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
6903 fillBulkPresetSelect(delProjSelect, projects, true);
6904 fillBulkPresetSelect(renameFromSelect, projects, true);
6905
6906 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
6907 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
6908 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
6909 }
6910
6911 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
6912 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
6913 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
6914
6915 const btnDeletePrefix = el('btn-settings-delete-prefix');
6916 if (btnDeletePrefix) {
6917 btnDeletePrefix.onclick = async () => {
6918 const msg = el('settings-delete-prefix-msg');
6919 const prefixEl = el('settings-delete-prefix');
6920 const confirmEl = el('settings-delete-confirm');
6921 if (!hubUserCanWriteNotes()) {
6922 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6923 return;
6924 }
6925 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
6926 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6927 if (!raw) {
6928 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
6929 return;
6930 }
6931 if (conf !== 'DELETE') {
6932 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6933 return;
6934 }
6935 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
6936 try {
6937 const out = await api('/api/v1/notes/delete-by-prefix', {
6938 method: 'POST',
6939 headers: { 'Content-Type': 'application/json' },
6940 body: JSON.stringify({ path_prefix: raw }),
6941 });
6942 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6943 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6944 if (confirmEl) confirmEl.value = '';
6945 if (msg) {
6946 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6947 msg.className = 'settings-msg ok';
6948 }
6949 if (typeof showToast === 'function') {
6950 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
6951 }
6952 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6953 loadNotes();
6954 loadFacets();
6955 if (typeof loadProposals === 'function') loadProposals();
6956 void refreshBulkDeletePresetDropdowns();
6957 } catch (e) {
6958 const m = e && e.message ? String(e.message) : String(e);
6959 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6960 }
6961 });
6962 };
6963 }
6964
6965 const btnDeleteByProject = el('btn-settings-delete-by-project');
6966 if (btnDeleteByProject) {
6967 btnDeleteByProject.onclick = async () => {
6968 const msg = el('settings-delete-by-project-msg');
6969 const slugEl = el('settings-delete-project-slug');
6970 const confirmEl = el('settings-delete-project-confirm');
6971 if (!hubUserCanWriteNotes()) {
6972 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6973 return;
6974 }
6975 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
6976 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6977 if (!slug) {
6978 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
6979 return;
6980 }
6981 if (conf !== 'DELETE') {
6982 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6983 return;
6984 }
6985 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
6986 try {
6987 const out = await api('/api/v1/notes/delete-by-project', {
6988 method: 'POST',
6989 headers: { 'Content-Type': 'application/json' },
6990 body: JSON.stringify({ project: slug }),
6991 });
6992 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6993 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6994 if (confirmEl) confirmEl.value = '';
6995 if (msg) {
6996 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6997 msg.className = 'settings-msg ok';
6998 }
6999 if (typeof showToast === 'function') {
7000 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
7001 }
7002 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7003 loadNotes();
7004 loadFacets();
7005 if (typeof loadProposals === 'function') loadProposals();
7006 void refreshBulkDeletePresetDropdowns();
7007 } catch (e) {
7008 const m = e && e.message ? String(e.message) : String(e);
7009 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7010 }
7011 });
7012 };
7013 }
7014
7015 const btnRenameProject = el('btn-settings-rename-project');
7016 if (btnRenameProject) {
7017 btnRenameProject.onclick = async () => {
7018 const msg = el('settings-rename-project-msg');
7019 const fromEl = el('settings-rename-project-from');
7020 const toEl = el('settings-rename-project-to');
7021 const confirmEl = el('settings-rename-project-confirm');
7022 if (!hubUserCanWriteNotes()) {
7023 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
7024 return;
7025 }
7026 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
7027 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
7028 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7029 if (!from || !to) {
7030 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
7031 return;
7032 }
7033 if (conf !== 'RENAME') {
7034 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
7035 return;
7036 }
7037 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
7038 try {
7039 const out = await api('/api/v1/notes/rename-project', {
7040 method: 'POST',
7041 headers: { 'Content-Type': 'application/json' },
7042 body: JSON.stringify({ from, to }),
7043 });
7044 const n = out && typeof out.updated === 'number' ? out.updated : 0;
7045 if (confirmEl) confirmEl.value = '';
7046 if (msg) {
7047 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
7048 msg.className = 'settings-msg ok';
7049 }
7050 if (typeof showToast === 'function') {
7051 showToast('Renamed project on ' + n + ' note(s).', false);
7052 }
7053 if (n > 0) hubMarkSemanticIndexStale();
7054 loadNotes();
7055 loadFacets();
7056 void refreshBulkDeletePresetDropdowns();
7057 } catch (e) {
7058 const m = e && e.message ? String(e.message) : String(e);
7059 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7060 }
7061 });
7062 };
7063 }
7064
7065 const btnVaultsSave = el('btn-vaults-save');
7066 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
7067 const msg = el('vaults-save-msg');
7068 if (isHostedHubFromSettings()) {
7069 if (msg) {
7070 msg.textContent =
7071 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
7072 msg.className = 'settings-msg err';
7073 }
7074 return;
7075 }
7076 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
7077 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
7078 try {
7079 const vaults = JSON.parse(raw);
7080 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
7081 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
7082 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7083 try {
7084 const s = await api('/api/v1/settings');
7085 applySettingsPayloadToHubChrome(s);
7086 } catch (_) {}
7087 loadVaultsPanel();
7088 } catch (e) {
7089 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7090 }
7091 });
7092 };
7093 function validateVaultAccess(access) {
7094 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
7095 for (const [uid, arr] of Object.entries(access)) {
7096 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
7097 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
7098 }
7099 return null;
7100 }
7101 function validateScope(scope) {
7102 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
7103 for (const [userId, perVault] of Object.entries(scope)) {
7104 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
7105 for (const [vaultId, entry] of Object.entries(perVault)) {
7106 if (typeof entry !== 'object' || entry === null) continue;
7107 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
7108 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
7109 }
7110 }
7111 return null;
7112 }
7113 const btnVaultAccessSave = el('btn-vault-access-save');
7114 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
7115 const msg = el('vault-access-save-msg');
7116 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
7117 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
7118 try {
7119 const access = JSON.parse(raw);
7120 const err = validateVaultAccess(access);
7121 if (err) throw new Error(err);
7122 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
7123 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7124 try {
7125 const s = await api('/api/v1/settings');
7126 applySettingsPayloadToHubChrome(s);
7127 } catch (_) {}
7128 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
7129 } catch (e) {
7130 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7131 }
7132 });
7133 };
7134
7135 const btnAccessFormApply = el('btn-access-form-apply');
7136 if (btnAccessFormApply) {
7137 btnAccessFormApply.onclick = () => {
7138 const msg = el('access-form-msg');
7139 const uid = getAccessFormResolvedUserId();
7140 if (!uid) {
7141 if (msg) {
7142 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
7143 msg.className = 'settings-msg err';
7144 }
7145 return;
7146 }
7147 const checked = Array.from(
7148 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
7149 ).map((c) => c.value);
7150 if (checked.length === 0) {
7151 if (msg) {
7152 msg.textContent = 'Tick at least one vault.';
7153 msg.className = 'settings-msg err';
7154 }
7155 return;
7156 }
7157 const access = parseVaultAccessFromTextarea();
7158 access[uid] = checked;
7159 const ta = el('vault-access-json');
7160 if (ta) ta.value = JSON.stringify(access, null, 2);
7161 refreshAccessRulesSummary(access);
7162 if (msg) {
7163 msg.textContent =
7164 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
7165 msg.className = 'settings-msg ok';
7166 }
7167 };
7168 }
7169
7170 const btnAccessFormRemove = el('btn-access-form-remove-user');
7171 if (btnAccessFormRemove) {
7172 btnAccessFormRemove.onclick = () => {
7173 const msg = el('access-form-msg');
7174 const uid = getAccessFormResolvedUserId();
7175 if (!uid) {
7176 if (msg) {
7177 msg.textContent = 'Choose a person to remove.';
7178 msg.className = 'settings-msg err';
7179 }
7180 return;
7181 }
7182 const access = parseVaultAccessFromTextarea();
7183 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
7184 if (msg) {
7185 msg.textContent = 'No rule for that user.';
7186 msg.className = 'settings-msg err';
7187 }
7188 return;
7189 }
7190 delete access[uid];
7191 const ta = el('vault-access-json');
7192 if (ta) ta.value = JSON.stringify(access, null, 2);
7193 refreshAccessRulesSummary(access);
7194 accessFormSyncCheckboxesFromAccessJson();
7195 if (msg) {
7196 msg.textContent =
7197 'Removed from draft rules only. Click Save vault access below to persist (required).';
7198 msg.className = 'settings-msg ok';
7199 }
7200 };
7201 }
7202
7203 const btnScopeSave = el('btn-scope-save');
7204 if (btnScopeSave) btnScopeSave.onclick = async () => {
7205 const msg = el('scope-save-msg');
7206 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
7207 const raw = (el('scope-json') && el('scope-json').value) || '{}';
7208 try {
7209 const scope = JSON.parse(raw);
7210 const err = validateScope(scope);
7211 if (err) throw new Error(err);
7212 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
7213 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7214 } catch (e) {
7215 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7216 }
7217 });
7218 };
7219
7220 const btnWorkspaceUseMe = el('btn-workspace-use-me');
7221 if (btnWorkspaceUseMe) {
7222 btnWorkspaceUseMe.onclick = async () => {
7223 const input = el('workspace-owner-input');
7224 const msg = el('workspace-save-msg');
7225 let uid =
7226 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7227 ? String(lastBackupSettingsPayload.user_id)
7228 : '';
7229 if (!uid) {
7230 try {
7231 const s = await api('/api/v1/settings');
7232 lastBackupSettingsPayload = s;
7233 uid = s.user_id != null ? String(s.user_id) : '';
7234 } catch (e) {
7235 if (msg) {
7236 msg.textContent = e.message || 'Could not load your User ID.';
7237 msg.className = 'settings-msg err';
7238 }
7239 return;
7240 }
7241 }
7242 if (input) input.value = uid;
7243 if (msg) {
7244 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
7245 msg.className = 'settings-msg ok';
7246 }
7247 };
7248 }
7249
7250 const btnWorkspaceSave = el('btn-workspace-save');
7251 if (btnWorkspaceSave) {
7252 btnWorkspaceSave.onclick = async () => {
7253 const msg = el('workspace-save-msg');
7254 const input = el('workspace-owner-input');
7255 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
7256 try {
7257 const raw = (input && input.value) || '';
7258 const trimmed = raw.trim();
7259 const owner_user_id = trimmed === '' ? null : trimmed;
7260 await api('/api/v1/workspace', {
7261 method: 'POST',
7262 body: JSON.stringify({ owner_user_id }),
7263 });
7264 if (msg) {
7265 msg.textContent = 'Saved.';
7266 msg.className = 'settings-msg ok';
7267 }
7268 } catch (e) {
7269 if (msg) {
7270 msg.textContent = e.message || 'Save failed';
7271 msg.className = 'settings-msg err';
7272 }
7273 }
7274 });
7275 };
7276 }
7277
7278 const btnWorkspaceClear = el('btn-workspace-clear');
7279 if (btnWorkspaceClear) {
7280 btnWorkspaceClear.onclick = async () => {
7281 const msg = el('workspace-save-msg');
7282 const input = el('workspace-owner-input');
7283 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
7284 try {
7285 await api('/api/v1/workspace', {
7286 method: 'POST',
7287 body: JSON.stringify({ owner_user_id: null }),
7288 });
7289 if (input) input.value = '';
7290 if (msg) {
7291 msg.textContent = 'Cleared — each person uses their own cloud space.';
7292 msg.className = 'settings-msg ok';
7293 }
7294 } catch (e) {
7295 if (msg) {
7296 msg.textContent = e.message || 'Clear failed';
7297 msg.className = 'settings-msg err';
7298 }
7299 }
7300 });
7301 };
7302 }
7303
7304 async function loadInvitesList() {
7305 const listEl = el('invites-pending-list');
7306 if (!listEl) return;
7307 listEl.textContent = 'Loading…';
7308 try {
7309 const out = await api('/api/v1/invites');
7310 const invites = out.invites || [];
7311 if (invites.length === 0) {
7312 listEl.textContent = 'No pending invites. Create a link above.';
7313 } else {
7314 listEl.innerHTML = invites.map((inv) => {
7315 const tokenShort = inv.token.slice(0, 12) + '…';
7316 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
7317 return '<div class="team-role-row invite-row">' +
7318 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
7319 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
7320 '</div>';
7321 }).join('');
7322 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
7323 btn.onclick = async () => {
7324 const t = btn.dataset.token;
7325 if (!t) return;
7326 try {
7327 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
7328 loadInvitesList();
7329 } catch (e) {
7330 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
7331 }
7332 };
7333 });
7334 }
7335 } catch (e) {
7336 listEl.textContent = 'Could not load: ' + (e.message || '');
7337 }
7338 }
7339
7340 const btnInviteCreate = el('btn-invite-create');
7341 const inviteLinkBlock = el('invite-link-block');
7342 const inviteLinkUrl = el('invite-link-url');
7343 const inviteCreateMsg = el('invite-create-msg');
7344 if (btnInviteCreate) {
7345 btnInviteCreate.onclick = async () => {
7346 const roleSelect = el('invite-role');
7347 const role = (roleSelect && roleSelect.value) || 'editor';
7348 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
7349 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
7350 try {
7351 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
7352 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
7353 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
7354 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
7355 loadInvitesList();
7356 } catch (e) {
7357 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
7358 }
7359 });
7360 };
7361 }
7362 const btnInviteCopy = el('btn-invite-copy');
7363 if (btnInviteCopy && inviteLinkUrl) {
7364 btnInviteCopy.onclick = () => {
7365 inviteLinkUrl.select();
7366 if (navigator.clipboard && navigator.clipboard.writeText) {
7367 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
7368 if (typeof showToast === 'function') showToast('Link copied.');
7369 }).catch(() => {});
7370 }
7371 };
7372 }
7373
7374 function syncTeamAddEvaluatorMayApproveVisibility() {
7375 const wrap = el('team-add-evaluator-may-approve-wrap');
7376 const sel = el('team-role');
7377 if (!wrap || !sel) return;
7378 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
7379 }
7380 const teamRoleSelect = el('team-role');
7381 if (teamRoleSelect) {
7382 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
7383 syncTeamAddEvaluatorMayApproveVisibility();
7384 }
7385
7386 async function loadTeamRolesList() {
7387 const listEl = el('team-roles-list');
7388 if (!listEl) return;
7389 listEl.textContent = 'Loading…';
7390 try {
7391 const out = await api('/api/v1/roles');
7392 const roles = out.roles || {};
7393 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
7394 const entries = Object.entries(roles);
7395 listEl.innerHTML = '';
7396 if (entries.length === 0) {
7397 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
7398 return;
7399 }
7400 for (const [uid, role] of entries) {
7401 const row = document.createElement('div');
7402 row.className = 'team-role-row team-role-row-flex';
7403 const label = document.createElement('span');
7404 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
7405 row.appendChild(label);
7406 if (role === 'evaluator') {
7407 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
7408 const chk = document.createElement('input');
7409 chk.type = 'checkbox';
7410 chk.title = 'May approve proposals';
7411 chk.checked = Boolean(mayMap[uid]);
7412 chk.addEventListener('change', async () => {
7413 chk.disabled = true;
7414 try {
7415 await api('/api/v1/roles/evaluator-may-approve', {
7416 method: 'POST',
7417 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7418 });
7419 } catch (err) {
7420 chk.checked = !chk.checked;
7421 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7422 } finally {
7423 chk.disabled = false;
7424 }
7425 });
7426 const lab = document.createElement('label');
7427 lab.className = 'team-evaluator-approve-inline';
7428 lab.appendChild(chk);
7429 const sp = document.createElement('span');
7430 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7431 lab.appendChild(sp);
7432 row.appendChild(lab);
7433 }
7434 listEl.appendChild(row);
7435 }
7436 } catch (e) {
7437 listEl.textContent = 'Could not load: ' + (e.message || '');
7438 }
7439 }
7440
7441 const btnTeamUserUseMe = el('btn-team-user-use-me');
7442 if (btnTeamUserUseMe) {
7443 btnTeamUserUseMe.onclick = async () => {
7444 const userIdInput = el('team-user-id');
7445 const msgEl = el('team-save-msg');
7446 let uid =
7447 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7448 ? String(lastBackupSettingsPayload.user_id)
7449 : '';
7450 if (!uid) {
7451 try {
7452 const s = await api('/api/v1/settings');
7453 lastBackupSettingsPayload = s;
7454 uid = s.user_id != null ? String(s.user_id) : '';
7455 } catch (e) {
7456 if (msgEl) {
7457 msgEl.textContent = e.message || 'Could not load your User ID.';
7458 msgEl.className = 'settings-msg err';
7459 }
7460 return;
7461 }
7462 }
7463 if (userIdInput) userIdInput.value = uid;
7464 if (msgEl) {
7465 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
7466 msgEl.className = 'settings-msg';
7467 }
7468 };
7469 }
7470
7471 const btnTeamSave = el('btn-team-save');
7472 if (btnTeamSave) {
7473 btnTeamSave.onclick = async () => {
7474 const userIdInput = el('team-user-id');
7475 const roleSelect = el('team-role');
7476 const msgEl = el('team-save-msg');
7477 const userId = (userIdInput && userIdInput.value || '').trim();
7478 const role = (roleSelect && roleSelect.value) || 'editor';
7479 if (!userId) {
7480 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
7481 return;
7482 }
7483 if (msgEl) msgEl.textContent = '';
7484 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
7485 try {
7486 const body = { user_id: userId, role };
7487 if (role === 'evaluator') {
7488 const cb = el('team-add-evaluator-may-approve');
7489 body.evaluator_may_approve = Boolean(cb && cb.checked);
7490 }
7491 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
7492 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
7493 userIdInput.value = '';
7494 loadTeamRolesList();
7495 } catch (e) {
7496 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
7497 }
7498 });
7499 };
7500 }
7501
7502 const currentAccent = () => {
7503 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
7504 if (inline) return inline;
7505 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
7506 return fromCss || DEFAULT_ACCENT;
7507 };
7508 function accentStringToHex6(str) {
7509 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
7510 const t = str.trim();
7511 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
7512 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
7513 const a = t.slice(1);
7514 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
7515 }
7516 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
7517 if (m) {
7518 return (
7519 '#' +
7520 [1, 2, 3]
7521 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
7522 .join('')
7523 ).toLowerCase();
7524 }
7525 return DEFAULT_ACCENT;
7526 }
7527 function updateAccentCustomHexLabel(hex6) {
7528 const out = el('accent-custom-hex');
7529 if (out && hex6) out.textContent = String(hex6).toUpperCase();
7530 }
7531 function setAccentRuntimeOnly(hex) {
7532 if (!hex) return;
7533 document.documentElement.style.setProperty('--accent', hex);
7534 updateAccentCustomHexLabel(accentStringToHex6(hex));
7535 }
7536 let accentIroPicker = null;
7537 let accentIroSuppressChange = false;
7538 function ensureAccentIroPicker() {
7539 if (accentIroPicker) return accentIroPicker;
7540 const mount = el('accent-iro-root');
7541 const Iro = typeof window !== 'undefined' && window.iro;
7542 if (!mount || !Iro || !Iro.ColorPicker) return null;
7543 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
7544 const br = (brRaw && brRaw.trim()) || '';
7545 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
7546 accentIroPicker = new Iro.ColorPicker(mount, {
7547 width: 280,
7548 color: accentStringToHex6(currentAccent()),
7549 borderWidth: 1,
7550 borderColor,
7551 layout: [
7552 { component: Iro.ui.Box, options: {} },
7553 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
7554 ],
7555 });
7556 accentIroPicker.on('color:change', (color) => {
7557 if (accentIroSuppressChange) return;
7558 setAccentRuntimeOnly(color.hexString);
7559 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
7560 });
7561 accentIroPicker.on('input:end', () => {
7562 if (accentIroSuppressChange) return;
7563 const h = accentIroPicker.color.hexString;
7564 if (h) applyAccent(h);
7565 });
7566 return accentIroPicker;
7567 }
7568 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
7569 function setAccentPickerColor(picker, hexNorm) {
7570 if (!picker || !hexNorm) return;
7571 const col = picker.color;
7572 if (col && typeof col.set === 'function') {
7573 col.set(hexNorm);
7574 return;
7575 }
7576 if (typeof picker.setColor === 'function') {
7577 try {
7578 picker.setColor(hexNorm, { silent: true });
7579 } catch (_) {
7580 picker.setColor(hexNorm);
7581 }
7582 }
7583 }
7584 function paintAccentSwatches() {
7585 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7586 const hex = btn.dataset.accent;
7587 if (hex) btn.style.backgroundColor = hex;
7588 });
7589 }
7590 paintAccentSwatches();
7591 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7592 btn.addEventListener('click', () => {
7593 const hex = btn.dataset.accent;
7594 if (hex) {
7595 applyAccent(hex);
7596 const norm = accentStringToHex6(hex);
7597 document.querySelectorAll('.accent-swatch').forEach((b) => {
7598 const bh = b.dataset.accent;
7599 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7600 });
7601 ensureAccentIroPicker();
7602 if (accentIroPicker) {
7603 accentIroSuppressChange = true;
7604 try {
7605 setAccentPickerColor(accentIroPicker, norm);
7606 } finally {
7607 accentIroSuppressChange = false;
7608 }
7609 }
7610 updateAccentCustomHexLabel(norm);
7611 }
7612 });
7613 });
7614 ensureAccentIroPicker();
7615 function syncAccentUI() {
7616 const norm = accentStringToHex6(currentAccent());
7617 document.querySelectorAll('.accent-swatch').forEach((b) => {
7618 const bh = b.dataset.accent;
7619 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7620 });
7621 ensureAccentIroPicker();
7622 if (accentIroPicker) {
7623 accentIroSuppressChange = true;
7624 try {
7625 setAccentPickerColor(accentIroPicker, norm);
7626 } finally {
7627 accentIroSuppressChange = false;
7628 }
7629 }
7630 updateAccentCustomHexLabel(norm);
7631 }
7632 function currentTheme() {
7633 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
7634 }
7635 function syncThemeUI() {
7636 const theme = currentTheme();
7637 document.querySelectorAll('.theme-btn').forEach((btn) => {
7638 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
7639 });
7640 }
7641 function syncColorPaletteUI() {
7642 const p = currentColorPalette();
7643 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
7644 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
7645 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
7646 });
7647 }
7648 const dashboardThemeGrid = el('dashboard-theme-grid');
7649 if (dashboardThemeGrid) {
7650 dashboardThemeGrid.addEventListener('click', (ev) => {
7651 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
7652 if (!card || !dashboardThemeGrid.contains(card)) return;
7653 const pid = card.dataset.palette;
7654 if (pid == null) return;
7655 applyColorPalette(pid);
7656 syncColorPaletteUI();
7657 });
7658 }
7659 document.querySelectorAll('.theme-btn').forEach((btn) => {
7660 btn.addEventListener('click', () => {
7661 const theme = btn.dataset.theme;
7662 if (theme) {
7663 applyTheme(theme);
7664 syncThemeUI();
7665 }
7666 });
7667 });
7668 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
7669 if (scrollDashColorsBtn) {
7670 scrollDashColorsBtn.addEventListener('click', () => {
7671 const target = el('settings-dashboard-color-theme');
7672 if (target && target.scrollIntoView) {
7673 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7674 }
7675 });
7676 }
7677
7678 el('btn-settings-sync').onclick = async () => {
7679 const syncBtn = el('btn-settings-sync');
7680 const msg = el('settings-sync-msg');
7681 msg.textContent = 'Syncing…';
7682 msg.className = 'settings-msg';
7683 const s = lastBackupSettingsPayload;
7684 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
7685 const hostedPath = isHosted && s.github_connect_available;
7686 let opts = { method: 'POST' };
7687 if (hostedPath) {
7688 const slug =
7689 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
7690 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
7691 normalizeGithubRepoSlug(s.repo);
7692 if (!slug) {
7693 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
7694 msg.className = 'settings-msg err';
7695 return;
7696 }
7697 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
7698 opts.body = JSON.stringify({ repo: slug });
7699 }
7700 setButtonBusy(syncBtn, true, 'Backing up…');
7701 try {
7702 const result = await api('/api/v1/vault/sync', opts);
7703 msg.textContent = result.message || 'Done.';
7704 const initBtnOk = el('btn-vault-git-init');
7705 if (initBtnOk) initBtnOk.classList.add('hidden');
7706 if (hostedPath && s) {
7707 const refreshed = await api('/api/v1/settings');
7708 lastBackupSettingsPayload = refreshed;
7709 const vg = refreshed.vault_git || {};
7710 let gitText = 'Not configured';
7711 if (vg.enabled && vg.has_remote) {
7712 gitText = 'Configured';
7713 if (vg.auto_commit) gitText += ' (auto-commit on)';
7714 if (vg.auto_push) gitText += ', auto-push on';
7715 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7716 el('settings-git-status').textContent = gitText;
7717 const step4 = document.getElementById('setup-step-4');
7718 if (step4) {
7719 const done = !!(vg.enabled && vg.has_remote);
7720 step4.classList.toggle('setup-step-done', done);
7721 const icon = step4.querySelector('.setup-step-icon');
7722 if (icon) icon.textContent = done ? '✓' : '';
7723 }
7724 }
7725 } catch (e) {
7726 msg.textContent = e.message || 'Sync failed';
7727 msg.className = 'settings-msg err';
7728 const initBtn = el('btn-vault-git-init');
7729 if (initBtn) {
7730 const st = lastBackupSettingsPayload;
7731 const hosted =
7732 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
7733 const needInit =
7734 e.code === 'GIT_NOT_INITIALIZED' ||
7735 /not a Git repository/i.test(e.message || '');
7736 initBtn.classList.toggle('hidden', hosted || !needInit);
7737 }
7738 } finally {
7739 setButtonBusy(syncBtn, false);
7740 const st = lastBackupSettingsPayload;
7741 if (syncBtn && st) {
7742 const vg = st.vault_git || {};
7743 const vd = st.vault_path_display || '';
7744 const ih = (vd + '').toLowerCase() === 'canister';
7745 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
7746 }
7747 }
7748 };
7749 const btnVaultGitInit = el('btn-vault-git-init');
7750 if (btnVaultGitInit) {
7751 btnVaultGitInit.onclick = async () => {
7752 const msg = el('settings-sync-msg');
7753 msg.textContent = 'Initializing Git…';
7754 msg.className = 'settings-msg';
7755 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
7756 try {
7757 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
7758 msg.textContent = out.message || 'Git initialized. Try Back up now.';
7759 msg.className = 'settings-msg ok';
7760 btnVaultGitInit.classList.add('hidden');
7761 } catch (e) {
7762 msg.textContent = e.message || 'Git init failed';
7763 msg.className = 'settings-msg err';
7764 }
7765 });
7766 };
7767 }
7768 const saveSetupBtn = el('btn-settings-save');
7769 if (saveSetupBtn) {
7770 saveSetupBtn.onclick = async () => {
7771 const msg = el('settings-save-msg');
7772 if (msg) {
7773 msg.textContent = 'Saving…';
7774 msg.className = 'settings-msg';
7775 }
7776 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
7777 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
7778 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
7779 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
7780 try {
7781 await api('/api/v1/setup', {
7782 method: 'POST',
7783 body: JSON.stringify({
7784 vault_path: vault_path || undefined,
7785 vault_git: { enabled, remote: remote || undefined },
7786 }),
7787 });
7788 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.' : '');
7789 if (msg) {
7790 msg.textContent = successText;
7791 msg.className = 'settings-msg ok';
7792 }
7793 if (typeof showToast === 'function') showToast('Setup saved.');
7794 api('/api/v1/settings').then((s) => {
7795 const vd = s.vault_path_display || '—';
7796 const isHostedNow = (vd + '').toLowerCase() === 'canister';
7797 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
7798 el('settings-vault-display').textContent = vd;
7799 const configureSection = el('settings-configure-backup-section');
7800 const configureHr = el('settings-hr-configure');
7801 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
7802 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
7803 const vg = s.vault_git || {};
7804 let gitText = 'Not configured';
7805 if (vg.enabled && vg.has_remote) {
7806 gitText = 'Configured';
7807 if (vg.auto_commit) gitText += ' (auto-commit on)';
7808 if (vg.auto_push) gitText += ', auto-push on';
7809 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7810 el('settings-git-status').textContent = gitText;
7811 const syncBtn = el('btn-settings-sync');
7812 const isAdmin = s.role === 'admin';
7813 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
7814 if (msg) {
7815 msg.textContent = successText;
7816 msg.className = 'settings-msg ok';
7817 }
7818 }).catch(() => {});
7819 } catch (e) {
7820 const errMsg = e.message || 'Save failed';
7821 if (msg) {
7822 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
7823 ? 'Only admins can save setup. Your role is shown under Status above.'
7824 : errMsg;
7825 msg.className = 'settings-msg err';
7826 }
7827 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
7828 }
7829 });
7830 };
7831 }
7832
7833 function defaultFullPath() {
7834 const sel = el('full-path-folder');
7835 const folder =
7836 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7837 return folder + '/note-' + Date.now() + '.md';
7838 }
7839
7840 let fullPathFolderLoadToken = 0;
7841 async function refreshFullPathFolderSelect() {
7842 const sel = el('full-path-folder');
7843 if (!sel || !token) return;
7844 const my = ++fullPathFolderLoadToken;
7845 let folders = ['inbox'];
7846 try {
7847 const data = await api('/api/v1/vault/folders');
7848 if (my !== fullPathFolderLoadToken) return;
7849 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7850 } catch (_) {
7851 if (my !== fullPathFolderLoadToken) return;
7852 }
7853 lastVaultFoldersForCreate = folders.slice();
7854 const preserve = sel.value;
7855 sel.innerHTML = '';
7856 for (const f of folders) {
7857 const o = document.createElement('option');
7858 o.value = f;
7859 o.textContent = f;
7860 sel.appendChild(o);
7861 }
7862 const custom = document.createElement('option');
7863 custom.value = '__custom__';
7864 custom.textContent = 'Custom (type path below)';
7865 sel.appendChild(custom);
7866 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7867 else sel.value = folders[0] || 'inbox';
7868 refreshFullCreateSubrootSelect();
7869 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
7870 }
7871
7872 let importVaultFolderLoadToken = 0;
7873 async function refreshImportVaultFolderSelect() {
7874 const sel = el('import-vault-folder');
7875 if (!sel || !token) return;
7876 const my = ++importVaultFolderLoadToken;
7877 let folders = ['inbox'];
7878 try {
7879 const data = await api('/api/v1/vault/folders');
7880 if (my !== importVaultFolderLoadToken) return;
7881 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7882 } catch (_) {
7883 if (my !== importVaultFolderLoadToken) return;
7884 }
7885 lastVaultFoldersForCreate = folders.slice();
7886 const preserve = sel.value;
7887 sel.innerHTML = '';
7888 for (const f of folders) {
7889 const o = document.createElement('option');
7890 o.value = f;
7891 o.textContent = f;
7892 sel.appendChild(o);
7893 }
7894 const custom = document.createElement('option');
7895 custom.value = '__custom__';
7896 custom.textContent = 'Custom (type path below)';
7897 sel.appendChild(custom);
7898 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7899 else sel.value = folders[0] || 'inbox';
7900 refreshImportCreateSubrootSelect();
7901 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
7902 }
7903
7904 function syncFolderSelectToPathInput() {
7905 const pathInput = el('full-path');
7906 const sel = el('full-path-folder');
7907 if (!pathInput || !sel) return;
7908 const p = pathInput.value.trim();
7909 if (!p) return;
7910 let best = '__custom__';
7911 let bestLen = -1;
7912 for (const opt of sel.options) {
7913 const v = opt.value;
7914 if (v === '__custom__') continue;
7915 if (p === v || p.startsWith(v + '/')) {
7916 if (v.length > bestLen) {
7917 best = v;
7918 bestLen = v.length;
7919 }
7920 }
7921 }
7922 sel.value = bestLen >= 0 ? best : '__custom__';
7923 }
7924
7925 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
7926 function syncFullProjectFromPath() {
7927 const pi = el('full-path');
7928 const fp = el('full-project');
7929 if (!pi || !fp) return;
7930 const slug = projectSlugFromProjectsPath(pi.value.trim());
7931 if (slug) {
7932 fp.value = slug;
7933 fp.readOnly = true;
7934 fp.title = 'Derived from vault path projects/' + slug + '/';
7935 } else {
7936 fp.readOnly = false;
7937 fp.removeAttribute('title');
7938 }
7939 updateFullPathProjectTypoHint();
7940 }
7941
7942 function updateFullPathProjectTypoHint() {
7943 const pi = el('full-path');
7944 const hint = el('full-path-project-typo-hint');
7945 const fixBtn = el('btn-full-path-fix-typo');
7946 if (!pi || !hint) return;
7947 const raw = pi.value.trim();
7948 const sug = projectsPathTypoSuggestion(raw);
7949 if (sug) {
7950 hint.textContent =
7951 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
7952 hint.className = 'muted small detail-project-hint warn';
7953 hint.classList.remove('hidden');
7954 if (fixBtn) {
7955 fixBtn.classList.remove('hidden');
7956 fixBtn.onclick = () => {
7957 pi.value = sug;
7958 syncFolderSelectToPathInput();
7959 syncFullCreatePickersFromPath();
7960 syncFullProjectFromPath();
7961 scheduleFullCreateSimilarHint();
7962 };
7963 }
7964 } else {
7965 hint.textContent = '';
7966 hint.className = 'muted small detail-project-hint hidden';
7967 hint.classList.add('hidden');
7968 if (fixBtn) {
7969 fixBtn.classList.add('hidden');
7970 fixBtn.onclick = null;
7971 }
7972 }
7973 }
7974
7975 const fullPathFolderEl = () => el('full-path-folder');
7976 const fullPathInputEl = () => el('full-path');
7977 if (fullPathFolderEl() && fullPathInputEl()) {
7978 fullPathFolderEl().addEventListener('change', () => {
7979 const sel = fullPathFolderEl();
7980 if (!sel || sel.value === '__custom__') return;
7981 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
7982 syncFullCreatePickersFromPath();
7983 syncFullProjectFromPath();
7984 updateFullPathProjectTypoHint();
7985 scheduleFullCreateSimilarHint();
7986 });
7987 fullPathInputEl().addEventListener('input', () => {
7988 syncFolderSelectToPathInput();
7989 syncFullCreatePickersFromPath();
7990 syncFullProjectFromPath();
7991 updateFullPathProjectTypoHint();
7992 scheduleFullCreateSimilarHint();
7993 });
7994 fullPathInputEl().addEventListener('change', () => {
7995 syncFullCreatePickersFromPath();
7996 updateFullCreateSimilarInlineHint();
7997 });
7998 }
7999
8000 const fullCreateProjectSlugEl = el('full-create-project-slug');
8001 const fullCreateProjectSubEl = el('full-create-project-subroot');
8002 if (fullCreateProjectSlugEl) {
8003 fullCreateProjectSlugEl.addEventListener('change', () => {
8004 refreshFullCreateSubrootSelect();
8005 updateFullCreatePathLayoutVisibility();
8006 const v = fullCreateProjectSlugEl.value;
8007 const pi = el('full-path');
8008 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
8009 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
8010 syncFolderSelectToPathInput();
8011 syncFullProjectFromPath();
8012 updateFullPathProjectTypoHint();
8013 scheduleFullCreateSimilarHint();
8014 });
8015 }
8016 if (fullCreateProjectSubEl) {
8017 fullCreateProjectSubEl.addEventListener('change', () => {
8018 composeFullPathFromCreatePickers();
8019 syncFolderSelectToPathInput();
8020 syncFullProjectFromPath();
8021 updateFullPathProjectTypoHint();
8022 scheduleFullCreateSimilarHint();
8023 });
8024 }
8025
8026 const importCreateProjectSlugEl = el('import-create-project-slug');
8027 const importCreateProjectSubEl = el('import-create-project-subroot');
8028 const importVaultFolderEl = el('import-vault-folder');
8029 const importOutputDirEl = el('import-output-dir');
8030 if (importVaultFolderEl) {
8031 importVaultFolderEl.addEventListener('change', () => {
8032 const sel = importVaultFolderEl;
8033 const out = el('import-output-dir');
8034 if (!sel || !out || sel.value === '__custom__') return;
8035 out.value = sel.value;
8036 syncImportPickersFromOutputDir();
8037 });
8038 }
8039 if (importOutputDirEl) {
8040 importOutputDirEl.addEventListener('input', () => {
8041 syncImportFolderSelectToOutputDir();
8042 syncImportPickersFromOutputDir();
8043 });
8044 }
8045 if (importCreateProjectSlugEl) {
8046 importCreateProjectSlugEl.addEventListener('change', () => {
8047 refreshImportCreateSubrootSelect();
8048 updateImportPathLayoutVisibility();
8049 const v = importCreateProjectSlugEl.value;
8050 const out = el('import-output-dir');
8051 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
8052 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
8053 const sel = el('import-vault-folder');
8054 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8055 }
8056 syncImportFolderSelectToOutputDir();
8057 syncImportPickersFromOutputDir();
8058 });
8059 }
8060 if (importCreateProjectSubEl) {
8061 importCreateProjectSubEl.addEventListener('change', () => {
8062 composeImportOutputDirFromPickers();
8063 syncImportFolderSelectToOutputDir();
8064 syncImportPickersFromOutputDir();
8065 });
8066 }
8067
8068 document.querySelectorAll('.modal-tab').forEach((t) => {
8069 t.onclick = () => {
8070 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
8071 t.classList.add('active');
8072 const tab = t.dataset.createTab;
8073 el('create-quick').classList.toggle('hidden', tab !== 'quick');
8074 el('create-full').classList.toggle('hidden', tab !== 'full');
8075 if (tab === 'full') {
8076 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
8077 void (async () => {
8078 await refreshFullPathFolderSelect();
8079 if (!lastHubFacets) {
8080 try {
8081 lastHubFacets = await fetchFacetsResolved();
8082 } catch (_) {}
8083 }
8084 hydrateFullCreateProjectSlugSelect(lastHubFacets);
8085 const pi = el('full-path');
8086 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
8087 else syncFolderSelectToPathInput();
8088 syncFullCreatePickersFromPath();
8089 syncFullProjectFromPath();
8090 updateFullPathProjectTypoHint();
8091 updateFullCreateSimilarInlineHint();
8092 })();
8093 }
8094 };
8095 });
8096
8097 el('btn-quick-save').onclick = async () => {
8098 const quickBtn = el('btn-quick-save');
8099 const body = el('quick-body').value.trim();
8100 const msg = el('create-msg-quick');
8101 if (!body) {
8102 msg.textContent = 'Enter some text.';
8103 msg.className = 'create-msg err';
8104 return;
8105 }
8106 const projectRaw = el('quick-project').value.trim();
8107 const pslug = normSlug(projectRaw);
8108 const today = ymd(new Date());
8109 const slug = 'hub_' + Date.now();
8110 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
8111 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
8112 await withButtonBusy(quickBtn, 'Saving…', async () => {
8113 try {
8114 await api('/api/v1/notes', {
8115 method: 'POST',
8116 body: stringifyNotePostPayload(path, body, {
8117 source: 'hub',
8118 date: today,
8119 title,
8120 ...(pslug && { project: pslug }),
8121 }),
8122 });
8123 hubMarkSemanticIndexStale();
8124 msg.textContent = 'Saved: ' + path;
8125 msg.className = 'create-msg ok';
8126 el('quick-body').value = '';
8127 loadFacets();
8128 loadNotes();
8129 closeCreateModal();
8130 } catch (e) {
8131 msg.textContent = e.message;
8132 msg.className = 'create-msg err';
8133 }
8134 });
8135 };
8136
8137 async function submitFullCreateNote() {
8138 const fullBtn = el('btn-full-save');
8139 const notePath = el('full-path').value.trim();
8140 const pathProjFull = projectSlugFromProjectsPath(notePath);
8141 const msg = el('create-msg-full');
8142 if (!notePath) {
8143 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
8144 msg.className = 'create-msg err';
8145 return;
8146 }
8147 const pathTypoSug = projectsPathTypoSuggestion(notePath);
8148 if (pathTypoSug) {
8149 msg.textContent =
8150 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
8151 pathTypoSug;
8152 msg.className = 'create-msg err';
8153 return;
8154 }
8155 if (!notePath.endsWith('.md')) {
8156 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
8157 msg.className = 'create-msg err';
8158 return;
8159 }
8160 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
8161 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
8162 const dest = notePath.replace(/\\/g, '/');
8163 if (src === dest) {
8164 msg.textContent =
8165 'When duplicating, pick a different path than the original (same path would overwrite the original).';
8166 msg.className = 'create-msg err';
8167 return;
8168 }
8169 }
8170 const slugFromPath = projectSlugFromProjectsPath(notePath);
8171 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
8172 const similarGuess =
8173 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
8174 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
8175 : null;
8176 if (similarGuess) {
8177 openFullCreateSimilarModal(notePath, similarGuess);
8178 return;
8179 }
8180 fullCreateSimilarOverrideOnce = false;
8181 const title = el('full-title').value.trim();
8182 const body = el('full-body').value;
8183 const project = pathProjFull || el('full-project').value.trim();
8184 const tags = el('full-tags').value.trim();
8185 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
8186 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
8187 const entityRaw = el('full-entity') && el('full-entity').value.trim();
8188 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
8189 const episode = el('full-episode') && el('full-episode').value.trim();
8190 const followsRaw = el('full-follows') && el('full-follows').value.trim();
8191 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
8192 const fm = {
8193 date: dateVal,
8194 ...(title && { title }),
8195 ...(project && { project }),
8196 ...(tags && { tags }),
8197 ...(causalChain && { causal_chain_id: causalChain }),
8198 ...(entity && entity.length && { entity }),
8199 ...(episode && { episode_id: episode }),
8200 ...(follows && { follows }),
8201 };
8202 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
8203 await withButtonBusy(fullBtn, savingLabel, async () => {
8204 try {
8205 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
8206 hubMarkSemanticIndexStale();
8207 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
8208 msg.className = 'create-msg ok';
8209 const dupSrc = pendingDuplicateDeleteSource;
8210 const delChk = el('duplicate-delete-after-save');
8211 const shouldDeleteOriginal =
8212 dupSrc &&
8213 dupSrc.path &&
8214 delChk &&
8215 delChk.checked &&
8216 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
8217 if (shouldDeleteOriginal) {
8218 try {
8219 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
8220 if (typeof showToast === 'function') showToast('Original note deleted');
8221 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
8222 const bcb = el('btn-detail-copy-body');
8223 if (bcb) bcb.classList.add('hidden');
8224 } catch (delErr) {
8225 if (typeof showToast === 'function') {
8226 showToast(
8227 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
8228 true,
8229 );
8230 }
8231 }
8232 }
8233 void refreshFullPathFolderSelect().then(() => {
8234 el('full-path').value = defaultFullPath();
8235 syncFolderSelectToPathInput();
8236 syncFullCreatePickersFromPath();
8237 syncFullProjectFromPath();
8238 updateFullCreateSimilarInlineHint();
8239 });
8240 el('full-title').value = '';
8241 el('full-body').value = '';
8242 el('full-project').value = '';
8243 el('full-tags').value = '';
8244 if (el('full-date')) el('full-date').value = '';
8245 if (el('full-causal-chain')) el('full-causal-chain').value = '';
8246 if (el('full-entity')) el('full-entity').value = '';
8247 if (el('full-episode')) el('full-episode').value = '';
8248 if (el('full-follows')) el('full-follows').value = '';
8249 loadFacets();
8250 loadNotes();
8251 closeCreateModal();
8252 } catch (e) {
8253 msg.textContent = e.message;
8254 msg.className = 'create-msg err';
8255 }
8256 });
8257 }
8258
8259 el('btn-full-save').onclick = () => {
8260 void submitFullCreateNote();
8261 };
8262
8263 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
8264 const modalSimilarClose = el('modal-create-similar-project-close');
8265 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
8266 const btnSimilarKeep = el('btn-modal-create-similar-keep');
8267 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
8268 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
8269 if (btnSimilarUseExisting) {
8270 btnSimilarUseExisting.onclick = () => {
8271 const path = fullCreateSimilarModalPendingPath;
8272 const slug = fullCreateSimilarModalSuggestedSlug;
8273 closeFullCreateSimilarModal();
8274 if (path && slug) {
8275 const pi = el('full-path');
8276 if (pi) {
8277 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
8278 syncFolderSelectToPathInput();
8279 syncFullCreatePickersFromPath();
8280 syncFullProjectFromPath();
8281 updateFullPathProjectTypoHint();
8282 updateFullCreateSimilarInlineHint();
8283 }
8284 }
8285 fullCreateSimilarOverrideOnce = false;
8286 void submitFullCreateNote();
8287 };
8288 }
8289 if (btnSimilarKeep) {
8290 btnSimilarKeep.onclick = () => {
8291 closeFullCreateSimilarModal();
8292 fullCreateSimilarOverrideOnce = true;
8293 void submitFullCreateNote();
8294 };
8295 }
8296
8297 function formatDetailReadBody(body, fm) {
8298 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8299 const keys = Object.keys(o);
8300 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
8301 if (keys.length === 0 && hubUserCanWriteNotes()) {
8302 text +=
8303 '\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).';
8304 }
8305 return text;
8306 }
8307
8308 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
8309 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
8310
8311 function videoExtToMime(url) {
8312 try {
8313 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
8314 return VIDEO_MIME_MAP[ext] || 'video/mp4';
8315 } catch (_) {
8316 var clean = url.split('?')[0].split('#')[0];
8317 var ext2 = clean.split('.').pop().toLowerCase();
8318 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
8319 }
8320 }
8321
8322 /**
8323 * Ensure standalone video URL lines are surrounded by blank lines in the raw
8324 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
8325 * mode joins adjacent lines (e.g. a video URL followed immediately by image
8326 * markdown) into a single <p>, which prevents the video-URL regex from matching.
8327 */
8328 function isolateVideoUrlLines(md) {
8329 // Match any line whose entire content is a bare https video URL.
8330 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
8331 // and after so marked always puts the URL in its own paragraph.
8332 return md.replace(
8333 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
8334 '\n$1$2\n'
8335 );
8336 }
8337
8338 /**
8339 * Replace bare video URLs (on their own line) with <video> elements.
8340 * Handles two forms that marked produces for a bare URL on its own paragraph:
8341 * 1. GFM autolink: <p><a href="URL">URL</a></p>
8342 * 2. Plain text: <p>URL</p>
8343 * Runs before DOMPurify so the sanitiser validates the output.
8344 */
8345 function expandVideoUrls(html) {
8346 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
8347
8348 // GFM autolink form: <p><a href="URL">...</a></p>
8349 var result = html.replace(
8350 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
8351 function (match, url) {
8352 if (!VIDEO_EXT_PAT.test(url)) return match;
8353 var mime = videoExtToMime(url);
8354 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8355 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8356 'Your browser does not support embedded video.</video>';
8357 }
8358 );
8359
8360 // Plain text form: <p>URL</p>
8361 result = result.replace(
8362 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
8363 function (match, url) {
8364 if (!VIDEO_EXT_PAT.test(url)) return match;
8365 var mime = videoExtToMime(url);
8366 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8367 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8368 'Your browser does not support embedded video.</video>';
8369 }
8370 );
8371
8372 return result;
8373 }
8374
8375 var SANITIZE_OPTS_NOTE = {
8376 ADD_TAGS: ['details', 'summary', 'video', 'source'],
8377 ADD_ATTR: ['controls', 'preload', 'type'],
8378 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
8379 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
8380 };
8381
8382 /**
8383 * Render markdown text as sanitised HTML.
8384 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
8385 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
8386 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
8387 */
8388 var _imageProxyToken = null;
8389 var _imageProxyTokenExp = 0;
8390
8391 async function getImageProxyToken() {
8392 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
8393 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8394 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8395 if (!jwt) return '';
8396 try {
8397 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
8398 headers: { authorization: 'Bearer ' + jwt },
8399 });
8400 if (!res.ok) return '';
8401 var data = await res.json();
8402 _imageProxyToken = data.token || '';
8403 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
8404 return _imageProxyToken;
8405 } catch (_) { return ''; }
8406 }
8407
8408 /**
8409 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
8410 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
8411 * Falls back to no rewrite if no cached image token is available yet.
8412 */
8413 function rewriteGitHubImageUrls(html) {
8414 var tok = _imageProxyToken || '';
8415 if (!tok) {
8416 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8417 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8418 }
8419 if (!tok) return html;
8420 var encodedTok = encodeURIComponent(tok);
8421 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8422 return html.replace(
8423 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8424 function (match, pre, rest) {
8425 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8426 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8427 }
8428 );
8429 }
8430
8431 function renderNoteMarkdownHtml(md) {
8432 try {
8433 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8434 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8435 var withVideo = expandVideoUrls(raw);
8436 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8437 return rewriteGitHubImageUrls(sanitised);
8438 }
8439 } catch (_) { /* fall through */ }
8440 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8441 }
8442
8443 /**
8444 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8445 */
8446 function buildNoteReadHtml(body, fm) {
8447 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8448 const keys = Object.keys(o);
8449 const bodyHtml = renderNoteMarkdownHtml(body || '');
8450 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8451 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8452 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
8453 : '';
8454 return (
8455 bodyHtml +
8456 '<details class="note-meta-block">' +
8457 '<summary>Metadata</summary>' +
8458 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
8459 emptyNote +
8460 '</details>'
8461 );
8462 }
8463
8464 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
8465 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
8466 'absolute_path',
8467 'body',
8468 'body_length',
8469 'byte_offset',
8470 'byte_offsets',
8471 'frontmatter',
8472 'line_range',
8473 'line_ranges',
8474 'mcp_resource_uri',
8475 'provider_payload',
8476 'raw_canister_payload',
8477 'resource_uri',
8478 'section_body',
8479 'section_body_length',
8480 'snippet',
8481 'snippets',
8482 ]);
8483
8484 function normalizeSectionSourcePathForUi(path) {
8485 const value = String(path || '').trim();
8486 if (!value) return '';
8487 if (value.includes('\\') || value.includes('\0')) return '';
8488 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
8489 if (value.split('/').some((part) => part === '..')) return '';
8490 return value;
8491 }
8492
8493 function sectionSourceEndpointForPath(path) {
8494 return '/api/v1/section-source?path=' + encodeURIComponent(path);
8495 }
8496
8497 function sectionSourcePayloadHasForbiddenKeys(value) {
8498 if (!value || typeof value !== 'object') return false;
8499 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
8500 for (const [key, child] of Object.entries(value)) {
8501 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
8502 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
8503 }
8504 return false;
8505 }
8506
8507 function normalizeSectionSourceForRender(data) {
8508 if (!data || typeof data !== 'object' || Array.isArray(data)) {
8509 throw new Error('INVALID_SECTION_SOURCE');
8510 }
8511 if (sectionSourcePayloadHasForbiddenKeys(data)) {
8512 throw new Error('INVALID_SECTION_SOURCE');
8513 }
8514 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
8515 throw new Error('INVALID_SECTION_SOURCE');
8516 }
8517 return {
8518 schema: SECTION_SOURCE_SCHEMA,
8519 path: String(data.path || ''),
8520 title: String(data.title || ''),
8521 truncated: data.truncated === true,
8522 sections: data.sections.map((section) => {
8523 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
8524 const normalized = {
8525 section_id: String(item.section_id || ''),
8526 heading_id: String(item.heading_id || ''),
8527 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
8528 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
8529 heading_text: String(item.heading_text || ''),
8530 child_section_ids: Array.isArray(item.child_section_ids)
8531 ? item.child_section_ids.map((childId) => String(childId))
8532 : [],
8533 body_available: item.body_available === true,
8534 body_returned: item.body_returned === true,
8535 snippet_returned: item.snippet_returned === true,
8536 };
8537 if (normalized.body_returned || normalized.snippet_returned) {
8538 throw new Error('INVALID_SECTION_SOURCE');
8539 }
8540 return normalized;
8541 }),
8542 };
8543 }
8544
8545 function resetDetailSectionSourceState() {
8546 hubSectionSourceSeq += 1;
8547 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
8548 }
8549
8550 function setSectionSourcePanelState(panel, state, message) {
8551 panel.className = 'section-source-panel section-source-panel-' + state;
8552 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
8553 panel.setAttribute('aria-label', 'Body-free section list');
8554 panel.setAttribute('aria-live', 'polite');
8555 panel.replaceChildren();
8556 const text = document.createElement('p');
8557 text.className = 'section-source-state';
8558 text.textContent = message;
8559 panel.appendChild(text);
8560 }
8561
8562 function sectionSourceErrorMessage(error) {
8563 const code = error && error.code ? String(error.code) : '';
8564 const message = error && error.message ? String(error.message) : '';
8565 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
8566 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
8567 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
8568 if (message === 'Unauthorized') return 'Sign in to view sections.';
8569 return 'Sections are unavailable right now.';
8570 }
8571
8572 function appendSectionSourceDebugRow(list, labelText, valueText) {
8573 const label = document.createElement('dt');
8574 label.textContent = labelText;
8575 const value = document.createElement('dd');
8576 value.textContent = valueText;
8577 list.append(label, value);
8578 }
8579
8580 function renderSectionSourceData(panel, source) {
8581 panel.className = 'section-source-panel';
8582 panel.setAttribute('role', 'region');
8583 panel.setAttribute('aria-label', 'Body-free section list');
8584 panel.setAttribute('aria-live', 'polite');
8585 panel.replaceChildren();
8586
8587 const header = document.createElement('div');
8588 header.className = 'section-source-header';
8589 const title = document.createElement('h3');
8590 title.textContent = 'Sections';
8591 const meta = document.createElement('p');
8592 meta.className = 'muted small';
8593 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
8594 header.append(title, meta);
8595 panel.appendChild(header);
8596
8597 if (source.truncated) {
8598 const truncated = document.createElement('p');
8599 truncated.className = 'section-source-state section-source-truncated';
8600 truncated.textContent = 'Section list is capped for display.';
8601 panel.appendChild(truncated);
8602 }
8603
8604 if (source.sections.length === 0) {
8605 const empty = document.createElement('p');
8606 empty.className = 'section-source-state';
8607 empty.textContent = 'No headings are available for this note.';
8608 panel.appendChild(empty);
8609 return;
8610 }
8611
8612 const list = document.createElement('ol');
8613 list.className = 'section-source-list';
8614 for (const section of source.sections) {
8615 const item = document.createElement('li');
8616 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
8617
8618 const heading = document.createElement('p');
8619 heading.className = 'section-source-heading';
8620 const levelBadge = document.createElement('span');
8621 levelBadge.className = 'section-source-level-label';
8622 levelBadge.textContent = 'H' + section.level;
8623 const headingText = document.createElement('span');
8624 headingText.className = 'section-source-heading-text';
8625 headingText.textContent = section.heading_text || '(Untitled section)';
8626 heading.append(levelBadge, headingText);
8627 item.appendChild(heading);
8628
8629 const detail = document.createElement('p');
8630 detail.className = 'section-source-detail muted small';
8631 detail.textContent = 'Heading level: H' + section.level;
8632 item.appendChild(detail);
8633
8634 const pathLine = document.createElement('p');
8635 pathLine.className = 'section-source-path muted small';
8636 pathLine.textContent =
8637 'Heading path: ' +
8638 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
8639 item.appendChild(pathLine);
8640
8641 const childLine = document.createElement('p');
8642 childLine.className = 'section-source-children muted small';
8643 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
8644 item.appendChild(childLine);
8645
8646 const debugDetails = document.createElement('details');
8647 debugDetails.className = 'section-source-debug muted small';
8648 const debugSummary = document.createElement('summary');
8649 debugSummary.textContent = 'IDs';
8650 const debugList = document.createElement('dl');
8651 debugList.className = 'section-source-debug-list';
8652 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
8653 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
8654 appendSectionSourceDebugRow(
8655 debugList,
8656 'Child IDs',
8657 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
8658 );
8659 debugDetails.append(debugSummary, debugList);
8660 item.appendChild(debugDetails);
8661
8662 list.appendChild(item);
8663 }
8664 panel.appendChild(list);
8665 }
8666
8667 async function loadSectionSourceForCurrentNote(actionsEl, button) {
8668 let panel = actionsEl.querySelector('[data-section-source-panel]');
8669 if (!panel) {
8670 panel = document.createElement('div');
8671 panel.dataset.sectionSourcePanel = 'true';
8672 actionsEl.appendChild(panel);
8673 }
8674 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
8675 if (!path) {
8676 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
8677 return;
8678 }
8679 const seq = ++hubSectionSourceSeq;
8680 const openPath = currentOpenNote.path;
8681 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
8682 if (button) {
8683 button.disabled = true;
8684 button.setAttribute('aria-expanded', 'true');
8685 }
8686 try {
8687 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
8688 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8689 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
8690 } catch (error) {
8691 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8692 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
8693 } finally {
8694 if (button && currentOpenNote && currentOpenNote.path === openPath) {
8695 button.disabled = false;
8696 }
8697 }
8698 }
8699
8700 function toggleSectionSourcePanel(actionsEl, button) {
8701 const panel = actionsEl.querySelector('[data-section-source-panel]');
8702 if (panel) {
8703 hubSectionSourceSeq += 1;
8704 panel.remove();
8705 if (button) button.setAttribute('aria-expanded', 'false');
8706 return;
8707 }
8708 void loadSectionSourceForCurrentNote(actionsEl, button);
8709 }
8710
8711 function createSectionSourceButton(actionsEl) {
8712 const sectionBtn = document.createElement('button');
8713 sectionBtn.type = 'button';
8714 sectionBtn.textContent = 'Sections';
8715 sectionBtn.className = 'btn-section-source';
8716 sectionBtn.setAttribute('aria-expanded', 'false');
8717 sectionBtn.setAttribute('aria-controls', 'detail-actions');
8718 sectionBtn.title = 'Show body-free section headings for this note';
8719 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
8720 return sectionBtn;
8721 }
8722
8723 function switchNoteToReadMode() {
8724 if (!currentOpenNote) return;
8725 resetDetailSectionSourceState();
8726 teardownDetailEditBodyLayout();
8727 const bodyEl = el('detail-body');
8728 const actionsEl = el('detail-actions');
8729 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
8730 bodyEl.className = 'note-rendered-body';
8731 actionsEl.innerHTML = '';
8732 attachNoteDetailReadActions(actionsEl);
8733 const bcbRead = el('btn-detail-copy-body');
8734 if (bcbRead) bcbRead.classList.remove('hidden');
8735 }
8736
8737 async function deleteOpenNote() {
8738 if (!currentOpenNote) return;
8739 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
8740 const p = currentOpenNote.path;
8741 try {
8742 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
8743 if (typeof showToast === 'function') showToast('Note deleted');
8744 hubMarkSemanticIndexStale();
8745 currentOpenNote = null;
8746 currentNotePathForCopy = '';
8747 resetDetailSectionSourceState();
8748 teardownDetailEditBodyLayout();
8749 hideDetailPanelChrome();
8750 el('btn-copy-path').classList.add('hidden');
8751 const bcbDel = el('btn-detail-copy-body');
8752 if (bcbDel) bcbDel.classList.add('hidden');
8753 loadNotes();
8754 loadFacets();
8755 } catch (e) {
8756 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
8757 }
8758 }
8759
8760 function attachNoteDetailReadActions(actionsEl) {
8761 const exportBtn = document.createElement('button');
8762 exportBtn.type = 'button';
8763 exportBtn.textContent = 'Export';
8764 exportBtn.onclick = () => exportCurrentNote('md');
8765 const sectionBtn = createSectionSourceButton(actionsEl);
8766
8767 if (hubUserCanWriteNotes()) {
8768 const editBtn = document.createElement('button');
8769 editBtn.type = 'button';
8770 editBtn.textContent = 'Edit';
8771 editBtn.onclick = () => switchNoteToEditMode();
8772 const dupBtn = document.createElement('button');
8773 dupBtn.type = 'button';
8774 dupBtn.textContent = 'Duplicate…';
8775 dupBtn.title =
8776 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
8777 dupBtn.onclick = () => {
8778 void openDuplicateNoteModal();
8779 };
8780 const proposeBtn = document.createElement('button');
8781 proposeBtn.type = 'button';
8782 proposeBtn.textContent = 'Propose change';
8783 proposeBtn.onclick = () => {
8784 if (!currentOpenNote) return;
8785 openCreateProposalModal({
8786 path: currentOpenNote.path,
8787 body: currentOpenNote.body || '',
8788 fromNote: true,
8789 });
8790 };
8791 const delBtn = document.createElement('button');
8792 delBtn.type = 'button';
8793 delBtn.textContent = 'Delete';
8794 delBtn.onclick = () => deleteOpenNote();
8795 if (hubHasMultipleVaultsForCopy()) {
8796 const copyVaultBtn = document.createElement('button');
8797 copyVaultBtn.type = 'button';
8798 copyVaultBtn.textContent = 'Copy to vault…';
8799 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
8800 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
8801 } else {
8802 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
8803 }
8804 return;
8805 }
8806
8807 if (hubUserMayProposeFromNote()) {
8808 const proposeBtn = document.createElement('button');
8809 proposeBtn.type = 'button';
8810 proposeBtn.textContent = 'Propose change';
8811 proposeBtn.onclick = () => {
8812 if (!currentOpenNote) return;
8813 openCreateProposalModal({
8814 path: currentOpenNote.path,
8815 body: currentOpenNote.body || '',
8816 fromNote: true,
8817 });
8818 };
8819 actionsEl.appendChild(proposeBtn);
8820 }
8821 actionsEl.appendChild(sectionBtn);
8822 if (hubUserCanExportNote()) {
8823 actionsEl.appendChild(exportBtn);
8824 }
8825 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
8826 const hint = document.createElement('p');
8827 hint.className = 'muted small';
8828 hint.style.marginTop = '0.5rem';
8829 hint.textContent =
8830 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
8831 actionsEl.appendChild(hint);
8832 }
8833 }
8834
8835 function openCopyNoteToVaultModal() {
8836 if (!currentOpenNote || !token) return;
8837 if (!hubHasMultipleVaultsForCopy()) {
8838 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
8839 return;
8840 }
8841 const existing = document.getElementById('modal-copy-note-vault');
8842 if (existing) existing.remove();
8843 const s = lastBackupSettingsPayload;
8844 const allowed = new Set((s.allowed_vault_ids || []).map(String));
8845 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
8846 const fromId = String(getCurrentVaultId() || 'default');
8847 const targets = vaultList.filter((v) => String(v.id) !== fromId);
8848 if (targets.length === 0) {
8849 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
8850 return;
8851 }
8852 const wrap = document.createElement('div');
8853 wrap.id = 'modal-copy-note-vault';
8854 wrap.className = 'modal';
8855 wrap.setAttribute('role', 'dialog');
8856 wrap.setAttribute('aria-modal', 'true');
8857 wrap.setAttribute('aria-label', 'Copy note to another vault');
8858 const backdrop = document.createElement('div');
8859 backdrop.className = 'modal-backdrop';
8860 const card = document.createElement('div');
8861 card.className = 'modal-card';
8862 card.style.maxWidth = '480px';
8863 const header = document.createElement('div');
8864 header.className = 'modal-header';
8865 const h2 = document.createElement('h2');
8866 h2.textContent = 'Copy to vault';
8867 const btnClose = document.createElement('button');
8868 btnClose.type = 'button';
8869 btnClose.className = 'modal-close';
8870 btnClose.textContent = '×';
8871 btnClose.setAttribute('aria-label', 'Close');
8872 header.appendChild(h2);
8873 header.appendChild(btnClose);
8874 const body = document.createElement('div');
8875 body.style.padding = '1rem 1.25rem';
8876 const lbl = document.createElement('label');
8877 lbl.className = 'detail-field-label';
8878 lbl.textContent = 'Target vault';
8879 lbl.setAttribute('for', 'copy-note-vault-select');
8880 const sel = document.createElement('select');
8881 sel.id = 'copy-note-vault-select';
8882 sel.className = 'vault-switcher-select';
8883 sel.style.width = '100%';
8884 sel.style.marginTop = '0.35rem';
8885 for (const v of targets) {
8886 const id = String(v.id);
8887 const opt = document.createElement('option');
8888 opt.value = id;
8889 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
8890 sel.appendChild(opt);
8891 }
8892 const moveRow = document.createElement('label');
8893 moveRow.style.display = 'flex';
8894 moveRow.style.alignItems = 'center';
8895 moveRow.style.gap = '0.5rem';
8896 moveRow.style.marginTop = '1rem';
8897 moveRow.style.cursor = 'pointer';
8898 const moveChk = document.createElement('input');
8899 moveChk.type = 'checkbox';
8900 moveChk.id = 'copy-note-delete-source';
8901 const moveSpan = document.createElement('span');
8902 moveSpan.textContent = 'Delete from this vault (move)';
8903 moveRow.appendChild(moveChk);
8904 moveRow.appendChild(moveSpan);
8905 const hint = document.createElement('p');
8906 hint.className = 'muted small';
8907 hint.style.marginTop = '0.75rem';
8908 hint.style.fontSize = '0.85rem';
8909 hint.textContent =
8910 '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).';
8911 const actions = document.createElement('div');
8912 actions.style.display = 'flex';
8913 actions.style.justifyContent = 'flex-end';
8914 actions.style.gap = '0.5rem';
8915 actions.style.marginTop = '1.25rem';
8916 const btnCancel = document.createElement('button');
8917 btnCancel.type = 'button';
8918 btnCancel.className = 'btn-secondary';
8919 btnCancel.textContent = 'Cancel';
8920 const btnGo = document.createElement('button');
8921 btnGo.type = 'button';
8922 btnGo.className = 'btn-primary';
8923 btnGo.textContent = 'Copy';
8924 actions.appendChild(btnCancel);
8925 actions.appendChild(btnGo);
8926 body.appendChild(lbl);
8927 body.appendChild(sel);
8928 body.appendChild(moveRow);
8929 body.appendChild(hint);
8930 body.appendChild(actions);
8931 card.appendChild(header);
8932 card.appendChild(body);
8933 wrap.appendChild(backdrop);
8934 wrap.appendChild(card);
8935 function close() {
8936 wrap.remove();
8937 }
8938 backdrop.onclick = close;
8939 btnClose.onclick = close;
8940 btnCancel.onclick = close;
8941 btnGo.onclick = async () => {
8942 const toId = sel.value;
8943 if (!toId || !currentOpenNote) return;
8944 await withButtonBusy(btnGo, 'Copying…', async () => {
8945 try {
8946 const res = await api('/api/v1/notes/copy', {
8947 method: 'POST',
8948 body: JSON.stringify({
8949 from_vault_id: fromId,
8950 to_vault_id: toId,
8951 path: currentOpenNote.path,
8952 delete_source: moveChk.checked,
8953 }),
8954 });
8955 hubMarkSemanticIndexStaleForVault(toId);
8956 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
8957 close();
8958 if (typeof showToast === 'function') {
8959 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
8960 }
8961 if (res.moved) {
8962 currentOpenNote = null;
8963 currentNotePathForCopy = '';
8964 resetDetailSectionSourceState();
8965 hideDetailPanelChrome();
8966 const bcp = el('btn-copy-path');
8967 if (bcp) bcp.classList.add('hidden');
8968 loadNotes();
8969 loadFacets();
8970 }
8971 } catch (e) {
8972 if (typeof showToast === 'function') showToast(e.message || String(e), true);
8973 }
8974 });
8975 };
8976 document.body.appendChild(wrap);
8977 }
8978
8979 async function exportCurrentNote(format) {
8980 if (!currentOpenNote) return;
8981 try {
8982 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
8983 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
8984 const a = document.createElement('a');
8985 a.href = URL.createObjectURL(blob);
8986 a.download = res.filename || 'export.md';
8987 a.click();
8988 URL.revokeObjectURL(a.href);
8989 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
8990 } catch (e) {
8991 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
8992 }
8993 }
8994
8995 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
8996 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
8997 var MEDIA_URL_SAFE = /^https?:\/\//i;
8998
8999 function teardownDetailEditBodyLayout() {
9000 if (detailEditBodyLayoutAbort) {
9001 detailEditBodyLayoutAbort.abort();
9002 detailEditBodyLayoutAbort = null;
9003 }
9004 }
9005
9006 function detailEditBodyMaxTextareaPx() {
9007 var wrap = el('detail-edit-body-wrap');
9008 var ta = el('detail-edit-body');
9009 if (!wrap || !ta) return 400;
9010 var toolbar = el('media-toolbar');
9011 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9012 var tb = toolbar ? toolbar.offsetHeight : 0;
9013 var gh = grip ? grip.offsetHeight : 0;
9014 var slack = 10;
9015 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
9016 var fallback = Math.round(window.innerHeight * 0.28);
9017 var wr = wrap.getBoundingClientRect();
9018 var next = wrap.nextElementSibling;
9019 var slice = 0;
9020 if (next && next.nodeType === 1) {
9021 var nr = next.getBoundingClientRect();
9022 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
9023 } else {
9024 var body = el('detail-body');
9025 if (body) {
9026 var br = body.getBoundingClientRect();
9027 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
9028 }
9029 }
9030 if (!Number.isFinite(slice) || slice < 120) {
9031 slice = fallback;
9032 }
9033 return Math.max(160, Math.min(hard, slice));
9034 }
9035
9036 function sizeDetailEditBodyToFill() {
9037 var ta = el('detail-edit-body');
9038 if (!ta) return;
9039 ta.style.removeProperty('height');
9040 }
9041
9042 function wireDetailEditBodyLayout() {
9043 teardownDetailEditBodyLayout();
9044 var ta = el('detail-edit-body');
9045 var wrap = el('detail-edit-body-wrap');
9046 if (!ta || !wrap) return;
9047 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9048 if (!grip) {
9049 grip = document.createElement('div');
9050 grip.className = 'detail-edit-body-resize-handle';
9051 grip.setAttribute('role', 'separator');
9052 grip.setAttribute('aria-orientation', 'horizontal');
9053 grip.setAttribute('aria-label', 'Resize editor height');
9054 var next = ta.nextSibling;
9055 if (next && next.id === 'media-toolbar') {
9056 wrap.insertBefore(grip, next);
9057 } else {
9058 wrap.appendChild(grip);
9059 }
9060 }
9061 if (grip.dataset.wired !== '1') {
9062 grip.dataset.wired = '1';
9063 function startDrag(clientY) {
9064 var startY = clientY;
9065 var startH = ta.offsetHeight;
9066 document.body.style.userSelect = 'none';
9067 function onMove(e2) {
9068 if (e2.touches && e2.cancelable) e2.preventDefault();
9069 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
9070 var dy = y - startY;
9071 var cap = detailEditBodyMaxTextareaPx();
9072 var nh = Math.max(160, Math.min(cap, startH + dy));
9073 ta.style.height = nh + 'px';
9074 }
9075 function onUp() {
9076 document.body.style.userSelect = '';
9077 document.removeEventListener('mousemove', onMove);
9078 document.removeEventListener('mouseup', onUp);
9079 document.removeEventListener('touchmove', onMove);
9080 document.removeEventListener('touchend', onUp);
9081 }
9082 document.addEventListener('mousemove', onMove);
9083 document.addEventListener('mouseup', onUp);
9084 document.addEventListener('touchmove', onMove, { passive: false });
9085 document.addEventListener('touchend', onUp);
9086 }
9087 grip.addEventListener('mousedown', function (e) {
9088 e.preventDefault();
9089 startDrag(e.clientY);
9090 });
9091 grip.addEventListener('touchstart', function (e) {
9092 if (!e.touches || !e.touches[0]) return;
9093 e.preventDefault();
9094 startDrag(e.touches[0].clientY);
9095 }, { passive: false });
9096 }
9097 window.requestAnimationFrame(function () {
9098 sizeDetailEditBodyToFill();
9099 });
9100 detailEditBodyLayoutAbort = new AbortController();
9101 window.addEventListener(
9102 'resize',
9103 function () {
9104 if (!el('detail-edit-body-wrap')) return;
9105 sizeDetailEditBodyToFill();
9106 },
9107 { signal: detailEditBodyLayoutAbort.signal }
9108 );
9109 }
9110
9111 function attachMediaToolbar() {
9112 var textarea = el('detail-edit-body');
9113 if (!textarea) return;
9114 var existing = document.getElementById('media-toolbar');
9115 if (existing) existing.remove();
9116
9117 var toolbar = document.createElement('div');
9118 toolbar.id = 'media-toolbar';
9119 toolbar.className = 'media-toolbar';
9120
9121 var insertBtn = document.createElement('button');
9122 insertBtn.type = 'button';
9123 insertBtn.textContent = 'Insert Media URL';
9124 insertBtn.className = 'btn-small';
9125 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.';
9126 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
9127 toolbar.appendChild(insertBtn);
9128
9129 var s = lastBackupSettingsPayload;
9130 if (s && s.github_connected && hubUserCanWriteNotes()) {
9131 var uploadBtn = document.createElement('button');
9132 uploadBtn.type = 'button';
9133 uploadBtn.textContent = 'Upload Image';
9134 uploadBtn.className = 'btn-small';
9135 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.';
9136 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
9137 toolbar.appendChild(uploadBtn);
9138 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
9139 var connectHint = document.createElement('span');
9140 connectHint.className = 'media-toolbar-hint';
9141 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
9142 connectHint.textContent = 'Connect GitHub to upload images';
9143 toolbar.appendChild(connectHint);
9144 }
9145
9146 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
9147 }
9148
9149 function toggleMediaUrlDialog(toolbar, textarea) {
9150 var existing = document.getElementById('media-url-dialog');
9151 if (existing) { existing.remove(); return; }
9152
9153 var dialog = document.createElement('div');
9154 dialog.id = 'media-url-dialog';
9155 dialog.className = 'media-url-dialog';
9156
9157 var input = document.createElement('input');
9158 input.type = 'text';
9159 input.placeholder = 'Paste image or video URL (https://...)';
9160 input.className = 'media-url-input';
9161
9162 var preview = document.createElement('div');
9163 preview.className = 'media-preview';
9164
9165 var actions = document.createElement('div');
9166 actions.className = 'media-url-actions';
9167
9168 var doInsert = document.createElement('button');
9169 doInsert.type = 'button';
9170 doInsert.textContent = 'Insert';
9171 doInsert.className = 'btn-primary btn-small';
9172 doInsert.disabled = true;
9173
9174 var doCancel = document.createElement('button');
9175 doCancel.type = 'button';
9176 doCancel.textContent = 'Cancel';
9177 doCancel.className = 'btn-small';
9178 doCancel.onclick = function () { dialog.remove(); };
9179
9180 actions.appendChild(doInsert);
9181 actions.appendChild(doCancel);
9182
9183 var detectedType = null;
9184
9185 function onUrlChange() {
9186 var url = input.value.trim();
9187 preview.innerHTML = '';
9188 doInsert.disabled = true;
9189 detectedType = null;
9190 if (!url || !MEDIA_URL_SAFE.test(url)) return;
9191 if (MEDIA_IMAGE_EXTS.test(url)) {
9192 detectedType = 'image';
9193 var img = document.createElement('img');
9194 img.src = url;
9195 img.style.maxHeight = '200px';
9196 img.style.maxWidth = '100%';
9197 img.crossOrigin = 'anonymous';
9198 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9199 preview.appendChild(img);
9200 doInsert.disabled = false;
9201 } else if (MEDIA_VIDEO_EXTS.test(url)) {
9202 detectedType = 'video';
9203 var vid = document.createElement('video');
9204 vid.controls = true;
9205 vid.preload = 'metadata';
9206 vid.style.maxHeight = '200px';
9207 vid.style.maxWidth = '100%';
9208 vid.src = url;
9209 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9210 preview.appendChild(vid);
9211 doInsert.disabled = false;
9212 } else {
9213 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>';
9214 }
9215 }
9216
9217 input.addEventListener('input', onUrlChange);
9218 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
9219
9220 doInsert.onclick = function () {
9221 var url = input.value.trim();
9222 if (!url) return;
9223 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
9224 insertAtCursor(textarea, insertion);
9225 dialog.remove();
9226 };
9227
9228 dialog.appendChild(input);
9229 dialog.appendChild(preview);
9230 dialog.appendChild(actions);
9231 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
9232 input.focus();
9233 }
9234
9235 function insertAtCursor(textarea, text) {
9236 var start = textarea.selectionStart;
9237 var end = textarea.selectionEnd;
9238 var val = textarea.value;
9239 var before = val.substring(0, start);
9240 var needsNewline = before.length > 0 && !before.endsWith('\n');
9241 var insertion = (needsNewline ? '\n' : '') + text + '\n';
9242 textarea.value = before + insertion + val.substring(end);
9243 var newPos = start + insertion.length;
9244 textarea.setSelectionRange(newPos, newPos);
9245 textarea.focus();
9246 }
9247
9248 /**
9249 * Compress an image File/Blob using the Canvas API so it fits within the
9250 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
9251 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
9252 * Falls back to the original file if Canvas is unavailable or the image is
9253 * already small enough.
9254 */
9255 function compressImageIfNeeded(file) {
9256 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
9257 var MAX_DIM = 2048;
9258 return new Promise(function (resolve) {
9259 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
9260 return resolve(file);
9261 }
9262 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
9263 return resolve(file);
9264 }
9265 var img = new window.Image();
9266 var objectUrl = URL.createObjectURL(file);
9267 img.onload = function () {
9268 URL.revokeObjectURL(objectUrl);
9269 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
9270 var w = Math.round(img.width * ratio);
9271 var h = Math.round(img.height * ratio);
9272 var canvas = document.createElement('canvas');
9273 canvas.width = w;
9274 canvas.height = h;
9275 var ctx = canvas.getContext('2d');
9276 ctx.drawImage(img, 0, 0, w, h);
9277 var tryQuality = function (quality, attempt) {
9278 canvas.toBlob(function (blob) {
9279 if (!blob) return resolve(file); // Canvas failed — upload original
9280 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
9281 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
9282 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
9283 } else {
9284 tryQuality(quality - 0.2, attempt + 1);
9285 }
9286 }, 'image/jpeg', quality);
9287 };
9288 tryQuality(0.82, 0);
9289 };
9290 img.onerror = function () {
9291 URL.revokeObjectURL(objectUrl);
9292 resolve(file);
9293 };
9294 img.src = objectUrl;
9295 });
9296 }
9297
9298 function triggerImageUpload(textarea) {
9299 var fileInput = document.createElement('input');
9300 fileInput.type = 'file';
9301 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
9302 fileInput.onchange = async function () {
9303 var file = fileInput.files && fileInput.files[0];
9304 if (!file || !currentOpenNote) return;
9305 try {
9306 if (typeof showToast === 'function') showToast('Uploading image…');
9307 // Compress before uploading to stay within the Netlify Lambda 6 MB
9308 // payload limit (~4.5 MB binary after base64 overhead).
9309 var uploadFile = await compressImageIfNeeded(file);
9310 var form = new FormData();
9311 form.append('image', uploadFile);
9312 var notePath = encodeURIComponent(currentOpenNote.path);
9313 var vaultIdParam = '';
9314 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
9315 // Build auth headers from the shared helper (omit Content-Type so the
9316 // browser sets the correct multipart/form-data boundary automatically).
9317 var uploadHeaders = headers();
9318 delete uploadHeaders['Content-Type'];
9319 // Use apiBase so this request reaches the gateway when the frontend is served
9320 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
9321 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9322 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
9323 method: 'POST',
9324 headers: uploadHeaders,
9325 body: form,
9326 });
9327 if (!res.ok) {
9328 var errData = await res.json().catch(function () { return {}; });
9329 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
9330 }
9331 var data = await res.json();
9332 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
9333 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
9334 } catch (e) {
9335 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
9336 }
9337 };
9338 fileInput.click();
9339 }
9340
9341 function switchNoteToEditMode() {
9342 if (!currentOpenNote) return;
9343 closeCreateModal();
9344 resetDetailSectionSourceState();
9345 const bcbEdit = el('btn-detail-copy-body');
9346 if (bcbEdit) bcbEdit.classList.add('hidden');
9347 const bodyEl = el('detail-body');
9348 const actionsEl = el('detail-actions');
9349 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
9350 bodyEl.className = 'detail-edit-container create-panel';
9351 bodyEl.innerHTML =
9352 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
9353 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
9354 '<label for="detail-edit-title">Title</label>' +
9355 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
9356 '<label for="detail-edit-body">Body (Markdown)</label>' +
9357 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
9358 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
9359 '</div>' +
9360 '<label for="detail-edit-date">Date</label>' +
9361 '<input type="date" id="detail-edit-date" />' +
9362 '<label for="detail-edit-project">Project (slug)</label>' +
9363 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
9364 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
9365 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
9366 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
9367 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
9368 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
9369 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
9370 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
9371 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
9372 '<label for="detail-edit-episode">Episode ID</label>' +
9373 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
9374 '<label for="detail-edit-follows">Follows (vault path)</label>' +
9375 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
9376 const pathDisp = el('detail-edit-path-display');
9377 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
9378 fillDetailEditFieldsFromFrontmatter(fm);
9379 attachMediaToolbar();
9380 wireDetailEditBodyLayout();
9381 actionsEl.innerHTML = '';
9382 const saveBtn = document.createElement('button');
9383 saveBtn.textContent = 'Save';
9384 saveBtn.className = 'btn-primary';
9385 saveBtn.onclick = async () => {
9386 closeCreateModal();
9387 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
9388 const frontmatter = mergedFrontmatterForDetailSave();
9389 await withButtonBusy(saveBtn, 'Saving…', async () => {
9390 try {
9391 await api('/api/v1/notes', {
9392 method: 'POST',
9393 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
9394 });
9395 hubMarkSemanticIndexStale();
9396 if (typeof showToast === 'function') showToast('Note saved');
9397 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
9398 const nfm = materializeFrontmatter(refreshed.frontmatter);
9399 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
9400 switchNoteToReadMode();
9401 if (typeof loadNotes === 'function') loadNotes();
9402 if (typeof loadFacets === 'function') loadFacets();
9403 } catch (e) {
9404 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
9405 }
9406 });
9407 };
9408 const cancelBtn = document.createElement('button');
9409 cancelBtn.textContent = 'Cancel';
9410 cancelBtn.onclick = () => switchNoteToReadMode();
9411 const delBtn = document.createElement('button');
9412 delBtn.type = 'button';
9413 delBtn.textContent = 'Delete';
9414 delBtn.onclick = () => deleteOpenNote();
9415 actionsEl.append(saveBtn, delBtn, cancelBtn);
9416 }
9417
9418 function openNote(path) {
9419 const seq = ++hubOpenNoteSeq;
9420 resetDetailSectionSourceState();
9421 teardownDetailEditBodyLayout();
9422 closeCreateModal();
9423 currentNotePathForCopy = path;
9424 currentOpenNote = null;
9425 const panel = el('detail-panel');
9426 panel.classList.remove('detail-panel-proposal-wide');
9427 const title = el('detail-title');
9428 const bodyEl = el('detail-body');
9429 const actionsEl = el('detail-actions');
9430 const btnCopy = el('btn-copy-path');
9431 const btnCopyBody = el('btn-detail-copy-body');
9432 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9433 title.textContent = path;
9434 bodyEl.textContent = 'Loading…';
9435 bodyEl.className = '';
9436 actionsEl.innerHTML = '';
9437 btnCopy.classList.remove('hidden');
9438 panel.classList.remove('hidden');
9439 api('/api/v1/notes/' + encodeURIComponent(path))
9440 .then((note) => {
9441 if (seq !== hubOpenNoteSeq) return;
9442 const fm = materializeFrontmatter(note.frontmatter);
9443 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9444 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9445 bodyEl.className = 'note-rendered-body';
9446 actionsEl.innerHTML = '';
9447 attachNoteDetailReadActions(actionsEl);
9448 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9449 })
9450 .catch((e) => {
9451 if (seq !== hubOpenNoteSeq) return;
9452 bodyEl.textContent = 'Error: ' + e.message;
9453 bodyEl.className = '';
9454 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9455 });
9456 }
9457
9458 el('btn-copy-path').onclick = () => {
9459 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
9460 };
9461
9462 const btnDetailCopyBody = el('btn-detail-copy-body');
9463 if (btnDetailCopyBody) {
9464 btnDetailCopyBody.onclick = () => {
9465 if (!currentOpenNote) {
9466 if (typeof showToast === 'function') showToast('Open a note first.', true);
9467 return;
9468 }
9469 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
9470 if (navigator.clipboard && navigator.clipboard.writeText) {
9471 navigator.clipboard.writeText(text).then(
9472 () => {
9473 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
9474 },
9475 () => {
9476 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
9477 },
9478 );
9479 } else if (typeof showToast === 'function') {
9480 showToast('Clipboard not available in this browser.', true);
9481 }
9482 };
9483 }
9484
9485 const btnCopyUserId = el('btn-copy-user-id');
9486 if (btnCopyUserId) {
9487 btnCopyUserId.onclick = () => {
9488 const idEl = el('settings-user-id');
9489 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
9490 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9491 navigator.clipboard.writeText(text).then(() => {
9492 if (typeof showToast === 'function') showToast('User ID copied.');
9493 }).catch(() => {});
9494 }
9495 };
9496 }
9497 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
9498 if (btnCopyAgentceptionEnv) {
9499 btnCopyAgentceptionEnv.onclick = () => {
9500 const envEl = el('integrations-agentception-env');
9501 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
9502 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9503 navigator.clipboard.writeText(text).then(() => {
9504 if (typeof showToast === 'function') showToast('Env snippet copied.');
9505 }).catch(() => {});
9506 }
9507 };
9508 }
9509 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
9510 if (btnIntegrationsHowToAgentception) {
9511 btnIntegrationsHowToAgentception.onclick = () => {
9512 closeSettings();
9513 openHowToUse('setup');
9514 };
9515 }
9516 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
9517 if (btnHowToFlexibleNetwork) {
9518 btnHowToFlexibleNetwork.onclick = () => {
9519 closeSettings();
9520 openHowToUse('setup', 'how-to-flexible-network');
9521 };
9522 }
9523
9524 function renderProposalMarkdownHtml(md) {
9525 try {
9526 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9527 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9528 var withVideo = expandVideoUrls(raw);
9529 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9530 return rewriteGitHubImageUrls(sanitised);
9531 }
9532 } catch (_) {
9533 /* fall through */
9534 }
9535 return escapeHtml(md || '');
9536 }
9537
9538 /** Canister stores checklist as JSON text; Node may return an array. */
9539 function parseProposalEvaluationChecklist(raw) {
9540 if (Array.isArray(raw)) return raw;
9541 if (raw == null || raw === '') return [];
9542 const s = String(raw).trim();
9543 if (!s) return [];
9544 try {
9545 const j = JSON.parse(s);
9546 return Array.isArray(j) ? j : [];
9547 } catch (_) {
9548 return [];
9549 }
9550 }
9551
9552 /**
9553 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
9554 */
9555 function buildProposalEvaluationRecordHtml(p, rubricItems) {
9556 const st = p.status;
9557 if (st !== 'approved' && st !== 'discarded') return '';
9558 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9559 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
9560 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
9561 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
9562 const meaningfulStatus = es && es !== 'none';
9563 let waiverText = '';
9564 const w = p.evaluation_waiver;
9565 if (w != null && w !== '') {
9566 try {
9567 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
9568 if (o && typeof o === 'object') {
9569 const r1 = o.reason != null ? String(o.reason).trim() : '';
9570 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
9571 waiverText = r1 || r2;
9572 }
9573 } catch (_) {
9574 /* ignore */
9575 }
9576 }
9577 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
9578 const rubricById = new Map(
9579 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
9580 String(it.id || '').trim(),
9581 String(it.label || it.id || '').trim(),
9582 ]),
9583 );
9584 const rows = checklist
9585 .map((c) => {
9586 const rid = c && c.id != null ? String(c.id) : '';
9587 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
9588 const pass = c && c.passed === true;
9589 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
9590 })
9591 .join('');
9592 return (
9593 '<div class="proposal-eval proposal-eval-readonly">' +
9594 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
9595 '<p class="small">' +
9596 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
9597 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
9598 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
9599 (p.evaluated_at
9600 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
9601 : '') +
9602 '</p>' +
9603 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
9604 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
9605 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
9606 '</div>'
9607 );
9608 }
9609
9610 function openProposal(id) {
9611 resetDetailSectionSourceState();
9612 currentNotePathForCopy = '';
9613 currentOpenNote = null;
9614 el('btn-copy-path').classList.add('hidden');
9615 const bcbProp = el('btn-detail-copy-body');
9616 if (bcbProp) bcbProp.classList.add('hidden');
9617 const panel = el('detail-panel');
9618 panel.classList.add('detail-panel-proposal-wide');
9619 const title = el('detail-title');
9620 const body = el('detail-body');
9621 const actions = el('detail-actions');
9622 body.className = 'detail-body-proposal';
9623 panel.classList.remove('hidden');
9624 body.innerHTML = '<p class="muted">Loading…</p>';
9625 actions.innerHTML = '';
9626 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
9627 api('/api/v1/proposals/' + encodeURIComponent(id))
9628 .then((p) =>
9629 api('/api/v1/notes/' + pathEnc(p.path)).then(
9630 (note) => ({ p, note }),
9631 () => ({ p, note: null }),
9632 ),
9633 )
9634 .then(({ p, note }) => {
9635 title.textContent = p.path + ' (' + p.status + ')';
9636 const pFm = materializeFrontmatter(p.frontmatter);
9637 const currentBlock = note
9638 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
9639 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
9640 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
9641 const mdHtml = renderProposalMarkdownHtml(p.body || '');
9642 const chips = [];
9643 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
9644 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
9645 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
9646 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
9647 });
9648 if (p.external_ref) {
9649 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
9650 }
9651 const role = window.__hubUserRole || 'member';
9652 const isAdmin = role === 'admin';
9653 const isEvaluator = role === 'evaluator';
9654 const canEvaluate = isAdmin || isEvaluator;
9655 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
9656 const canDiscard = isAdmin;
9657 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
9658 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9659 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
9660 function prevEvalPassed(rid) {
9661 const row = prevChecklist.find((c) => c && c.id === rid);
9662 return Boolean(row && row.passed === true);
9663 }
9664 let evalHtml = '';
9665 let waiverHtml = '';
9666 if (canEvaluate && p.status === 'proposed') {
9667 const es = p.evaluation_status || 'none';
9668 let evalIntro = '';
9669 if (es && es !== 'none' && es !== 'pending') {
9670 evalIntro =
9671 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
9672 escapeHtml(es) +
9673 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
9674 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
9675 (p.evaluation_comment
9676 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
9677 : '') +
9678 '</div>';
9679 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
9680 evalIntro =
9681 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
9682 }
9683 const checks = rubricItems.length
9684 ? rubricItems
9685 .map((it) => {
9686 const rid = String(it.id || '').trim();
9687 if (!rid) return '';
9688 const lab = String(it.label || rid);
9689 const ck = prevEvalPassed(rid) ? ' checked' : '';
9690 return (
9691 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
9692 escapeHtml(rid) +
9693 '"' +
9694 ck +
9695 ' /> ' +
9696 escapeHtml(lab) +
9697 '</label>'
9698 );
9699 })
9700 .join('')
9701 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
9702 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
9703 evalHtml =
9704 '<div class="proposal-eval">' +
9705 '<h4 class="proposal-md-heading">Evaluation</h4>' +
9706 evalIntro +
9707 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
9708 '<option value="pass">Pass</option>' +
9709 '<option value="fail">Fail</option>' +
9710 '<option value="needs_changes">Needs changes</option>' +
9711 '</select></label>' +
9712 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
9713 gradeVal +
9714 '" placeholder="e.g. A or 4" /></label>' +
9715 '<div class="proposal-eval-checklist">' +
9716 checks +
9717 '</div>' +
9718 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
9719 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
9720 '</textarea></label>' +
9721 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
9722 '</div>';
9723 }
9724 if (canApprove && p.status === 'proposed') {
9725 waiverHtml =
9726 '<div class="proposal-eval-waiver">' +
9727 '<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>' +
9728 '</div>';
9729 }
9730 let autoFlagHtml = '';
9731 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
9732 autoFlagHtml =
9733 '<p class="small muted">Auto-flagged: ' +
9734 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
9735 '</p>';
9736 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
9737 try {
9738 const ar = JSON.parse(String(p.auto_flag_reasons_json));
9739 if (Array.isArray(ar) && ar.length) {
9740 autoFlagHtml =
9741 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
9742 }
9743 } catch (_) {
9744 /* ignore */
9745 }
9746 }
9747 let hintsHtml = '';
9748 if (p.review_hints) {
9749 hintsHtml =
9750 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
9751 (p.review_hints_model
9752 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
9753 : '') +
9754 (p.review_hints_at
9755 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
9756 : '') +
9757 '<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>' +
9758 '<pre class="proposal-pre">' +
9759 escapeHtml(String(p.review_hints)) +
9760 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
9761 }
9762 let assistantHtml = '';
9763 if (p.assistant_notes) {
9764 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
9765 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
9766 .join('');
9767 assistantHtml =
9768 '<div class="proposal-assistant"><strong>Assistant</strong>' +
9769 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
9770 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
9771 '<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>' +
9772 '<p>' +
9773 escapeHtml(String(p.assistant_notes)) +
9774 '</p>' +
9775 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
9776 '</div>';
9777 }
9778 let suggestedFmHtml = '';
9779 {
9780 let fm = p.assistant_suggested_frontmatter;
9781 if (typeof fm === 'string') {
9782 try {
9783 fm = JSON.parse(fm);
9784 } catch {
9785 fm = null;
9786 }
9787 }
9788 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
9789 const keys = Object.keys(fm).filter((k) => {
9790 const v = fm[k];
9791 return v !== undefined && v !== null && v !== '';
9792 });
9793 if (keys.length) {
9794 const rows = keys
9795 .map((k) => {
9796 const v = fm[k];
9797 let cell;
9798 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
9799 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
9800 else cell = String(v);
9801 return (
9802 '<tr><th scope="row">' +
9803 escapeHtml(k) +
9804 '</th><td>' +
9805 escapeHtml(cell) +
9806 '</td></tr>'
9807 );
9808 })
9809 .join('');
9810 suggestedFmHtml =
9811 '<div class="proposal-suggested-fm">' +
9812 '<strong>Suggested frontmatter</strong> ' +
9813 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
9814 '<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>' +
9815 '<table class="proposal-suggested-fm-table"><tbody>' +
9816 rows +
9817 '</tbody></table></div>';
9818 }
9819 }
9820 }
9821 const openVaultNoteLine = note
9822 ? '<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>'
9823 : '<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>';
9824 body.innerHTML =
9825 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
9826 autoFlagHtml +
9827 '<p class="small muted">Intent: ' +
9828 escapeHtml(p.intent || '—') +
9829 ' · base_state_id: ' +
9830 escapeHtml(p.base_state_id || '—') +
9831 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
9832 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
9833 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
9834 '</p>' +
9835 openVaultNoteLine +
9836 '<div class="proposal-diff-grid">' +
9837 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
9838 escapeHtml(currentBlock) +
9839 '</pre></div>' +
9840 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
9841 escapeHtml(proposedBlock) +
9842 '</pre></div>' +
9843 '</div>' +
9844 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
9845 '<div class="proposal-md">' +
9846 mdHtml +
9847 '</div>' +
9848 evalRecordHtml +
9849 evalHtml +
9850 waiverHtml +
9851 assistantHtml +
9852 suggestedFmHtml +
9853 hintsHtml;
9854 actions.innerHTML = '';
9855 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
9856 if (openNoteBtn && note && p.path) {
9857 openNoteBtn.onclick = () => openNote(String(p.path));
9858 }
9859 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
9860 if (copyFmBtn) {
9861 let fmForCopy = p.assistant_suggested_frontmatter;
9862 if (typeof fmForCopy === 'string') {
9863 try {
9864 fmForCopy = JSON.parse(fmForCopy);
9865 } catch {
9866 fmForCopy = null;
9867 }
9868 }
9869 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
9870 copyFmBtn.onclick = async () => {
9871 try {
9872 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
9873 showToast('Copied suggested frontmatter JSON.');
9874 } catch (err) {
9875 showToast(err.message || 'Copy failed', true);
9876 }
9877 };
9878 }
9879 }
9880 const saveEvalBtn = body.querySelector('#proposal-eval-save');
9881 if (saveEvalBtn) {
9882 saveEvalBtn.onclick = async () => {
9883 const outcomeEl = body.querySelector('#proposal-eval-outcome');
9884 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
9885 const gradeEl = body.querySelector('#proposal-eval-grade');
9886 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
9887 const commentEl = body.querySelector('#proposal-eval-comment');
9888 const comment = commentEl ? String(commentEl.value || '').trim() : '';
9889 const checklist = [];
9890 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
9891 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
9892 });
9893 try {
9894 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
9895 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
9896 method: 'POST',
9897 body: JSON.stringify({
9898 outcome,
9899 grade: grade || undefined,
9900 comment: comment || undefined,
9901 checklist,
9902 }),
9903 });
9904 });
9905 showToast('Evaluation saved.');
9906 openProposal(id);
9907 loadProposals();
9908 } catch (err) {
9909 showToast(err.message || 'Evaluation failed', true);
9910 }
9911 };
9912 }
9913 if (p.status === 'proposed') {
9914 if (canApprove) {
9915 const approveBtn = document.createElement('button');
9916 approveBtn.textContent = 'Approve';
9917 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
9918 actions.append(approveBtn);
9919 }
9920 if (canDiscard) {
9921 const discardBtn = document.createElement('button');
9922 discardBtn.textContent = 'Discard';
9923 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
9924 actions.append(discardBtn);
9925 }
9926 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
9927 const enrichBtn = document.createElement('button');
9928 enrichBtn.type = 'button';
9929 enrichBtn.className = 'btn-secondary';
9930 enrichBtn.textContent = 'Enrich (AI)';
9931 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
9932 actions.append(enrichBtn);
9933 }
9934 if (isEvaluator && !canApprove) {
9935 const hintEv = document.createElement('p');
9936 hintEv.className = 'muted small';
9937 hintEv.textContent =
9938 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
9939 actions.append(hintEv);
9940 } else if (!canEvaluate) {
9941 const hint = document.createElement('p');
9942 hint.className = 'muted small';
9943 hint.textContent =
9944 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
9945 actions.append(hint);
9946 }
9947 }
9948 })
9949 .catch((e) => {
9950 body.className = 'detail-body-proposal';
9951 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
9952 });
9953 }
9954
9955 async function enrichProposal(id, panel, btn) {
9956 try {
9957 await withButtonBusy(btn, 'Enriching…', async () => {
9958 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
9959 });
9960 showToast('Proposal enriched.');
9961 openProposal(id);
9962 loadProposals();
9963 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
9964 // is visible instead of the browser staying at whatever scroll position it was at.
9965 const scrollHost = el('detail-body');
9966 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
9967 // Also highlight the matching row in the Suggested/Activity list so the user can see which
9968 // proposal was enriched.
9969 requestAnimationFrame(() => {
9970 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
9971 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
9972 });
9973 } catch (e) {
9974 showToast(e.message || 'Enrich failed', true);
9975 }
9976 }
9977
9978 async function approveProposal(id, panel, btn) {
9979 try {
9980 const db = el('detail-body');
9981 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
9982 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
9983 const approveBody = {};
9984 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
9985 let approveOut = null;
9986 await withButtonBusy(btn, 'Approving…', async () => {
9987 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
9988 method: 'POST',
9989 body: JSON.stringify(approveBody),
9990 });
9991 });
9992 if (approveOut && approveOut.approval_log_written === false) {
9993 showToast(
9994 approveOut.approval_log_error
9995 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
9996 : 'Approved, but approval log was not written. Check server logs and re-index.',
9997 true,
9998 );
9999 }
10000 hideDetailPanelChrome();
10001 hubMarkSemanticIndexStale();
10002 loadProposals();
10003 loadNotes();
10004 loadActivity();
10005 } catch (e) {
10006 const msg = e.message || String(e);
10007 showToast('Approve failed: ' + msg, true);
10008 }
10009 }
10010
10011 async function discardProposal(id, panel, btn) {
10012 try {
10013 await withButtonBusy(btn, 'Discarding…', async () => {
10014 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
10015 });
10016 hideDetailPanelChrome();
10017 loadProposals();
10018 loadActivity();
10019 } catch (e) {
10020 const msg = e.message || String(e);
10021 showToast('Discard failed: ' + msg, true);
10022 }
10023 }
10024
10025 el('detail-close').onclick = () => closeDetailPanel();
10026 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
10027 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
10028 (function wireDetailPanelFooterClose() {
10029 const panel = el('detail-panel');
10030 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
10031 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
10032 })();
10033
10034 // Resizable detail panel — drag the left edge to widen/narrow.
10035 (function initDetailPanelResize() {
10036 const panel = el('detail-panel');
10037 if (!panel) return;
10038 const handle = document.createElement('div');
10039 handle.className = 'detail-resize-handle';
10040 handle.title = 'Drag to resize panel';
10041 panel.prepend(handle);
10042 const MIN_W = 280;
10043 const MAX_W = Math.round(window.innerWidth * 0.92);
10044 let startX = 0, startW = 0, dragging = false;
10045 const onMove = (e) => {
10046 if (!dragging) return;
10047 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
10048 const delta = startX - clientX;
10049 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
10050 panel.style.width = newW + 'px';
10051 };
10052 const onUp = () => {
10053 if (!dragging) return;
10054 dragging = false;
10055 handle.classList.remove('dragging');
10056 document.removeEventListener('mousemove', onMove);
10057 document.removeEventListener('mouseup', onUp);
10058 document.removeEventListener('touchmove', onMove);
10059 document.removeEventListener('touchend', onUp);
10060 document.body.style.userSelect = '';
10061 };
10062 handle.addEventListener('mousedown', (e) => {
10063 e.preventDefault();
10064 dragging = true;
10065 startX = e.clientX;
10066 startW = panel.offsetWidth;
10067 handle.classList.add('dragging');
10068 document.body.style.userSelect = 'none';
10069 document.addEventListener('mousemove', onMove);
10070 document.addEventListener('mouseup', onUp);
10071 });
10072 handle.addEventListener('touchstart', (e) => {
10073 dragging = true;
10074 startX = e.touches[0].clientX;
10075 startW = panel.offsetWidth;
10076 handle.classList.add('dragging');
10077 document.addEventListener('touchmove', onMove, { passive: true });
10078 document.addEventListener('touchend', onUp);
10079 });
10080 })();
10081
10082 document.addEventListener('keydown', (e) => {
10083 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
10084 if (e.key === 'Escape') {
10085 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
10086 closeDetailPanel();
10087 e.preventDefault();
10088 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
10089 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
10090 closeHowToUse();
10091 e.preventDefault();
10092 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
10093 closeIntegGuideModal();
10094 e.preventDefault();
10095 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
10096 closeSettings();
10097 e.preventDefault();
10098 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
10099 closeProjectsHelpModal();
10100 e.preventDefault();
10101 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
10102 closeOnboardingWizardResume();
10103 e.preventDefault();
10104 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
10105 closeImportModal();
10106 e.preventDefault();
10107 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
10108 closeFullCreateSimilarModal();
10109 e.preventDefault();
10110 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
10111 closeCreateProposalModal();
10112 e.preventDefault();
10113 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
10114 closeCreateModal();
10115 e.preventDefault();
10116 } else if (el('search-key-help')?.open) {
10117 el('search-key-help').open = false;
10118 e.preventDefault();
10119 }
10120 return;
10121 }
10122 if (inInput && e.key !== 'Escape') return;
10123 if (e.key === '/') {
10124 searchQuery.focus();
10125 e.preventDefault();
10126 return;
10127 }
10128 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
10129 // run semantic search instead of opening the selected row (avoids "second search does nothing").
10130 if (e.key === 'Enter') {
10131 const q = (searchQuery.value || '').trim();
10132 if (q) {
10133 e.preventDefault();
10134 void runVaultSearch();
10135 return;
10136 }
10137 }
10138 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
10139 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
10140 const items = notesList.querySelectorAll('.list-item');
10141 if (notesTabActive && listViewVisible && items.length > 0) {
10142 if (e.key === 'j' || e.key === 'J') {
10143 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
10144 updateListSelection();
10145 e.preventDefault();
10146 } else if (e.key === 'k' || e.key === 'K') {
10147 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
10148 updateListSelection();
10149 e.preventDefault();
10150 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
10151 const node = items[listSelectedIndex];
10152 if (node.dataset.path) openNote(node.dataset.path);
10153 else if (node.dataset.id) openProposal(node.dataset.id);
10154 e.preventDefault();
10155 }
10156 }
10157 });
10158
10159 document.addEventListener('click', (e) => {
10160 const keyHelp = el('search-key-help');
10161 if (!keyHelp || !keyHelp.open) return;
10162 if (keyHelp.contains(e.target)) return;
10163 keyHelp.open = false;
10164 });
10165
10166 document.querySelectorAll('.tab').forEach((tab) => {
10167 tab.onclick = () => {
10168 switchHubMainTab(tab.dataset.tab);
10169 };
10170 });
10171 if (btnHeaderSuggested) {
10172 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
10173 }
10174
10175 function escapeHtml(s) {
10176 const div = document.createElement('div');
10177 div.textContent = s == null ? '' : String(s);
10178 return div.innerHTML;
10179 }
10180
10181 // ── Consolidation UI (Stream 2) ───────────────────────────────
10182
10183 function consolModeFromSettings(s) {
10184 if (!s || !s.daemon) return 'off';
10185 if (s.daemon.enabled) return 'daemon';
10186 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
10187 return 'off';
10188 }
10189
10190 function populateConsolSettingsForm(s) {
10191 if (!s || !s.daemon) return;
10192 const d = s.daemon;
10193 const mode = consolModeFromSettings(s);
10194 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
10195 applyConsolModeVisibility(mode);
10196 const iv = el('consol-interval');
10197 if (iv) iv.value = d.interval_minutes ?? 120;
10198 const idle = el('consol-idle-only');
10199 if (idle) idle.checked = d.idle_only !== false;
10200 const idleTh = el('consol-idle-threshold');
10201 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
10202 const ros = el('consol-run-on-start');
10203 if (ros) ros.checked = Boolean(d.run_on_start);
10204 const pc = el('pass-consolidate');
10205 if (pc) pc.checked = d.passes?.consolidate !== false;
10206 const pv = el('pass-verify');
10207 if (pv) pv.checked = d.passes?.verify !== false;
10208 const pd = el('pass-discover');
10209 if (pd) pd.checked = Boolean(d.passes?.discover);
10210 const lp = el('consol-llm-provider');
10211 if (lp) lp.value = d.llm?.provider || '';
10212 const lm = el('consol-llm-model');
10213 if (lm) lm.value = d.llm?.model || '';
10214 const lb = el('consol-llm-base-url');
10215 if (lb) lb.value = d.llm?.base_url || '';
10216 const lbh = el('consol-lookback-hours');
10217 if (lbh) lbh.value = d.lookback_hours ?? 24;
10218 const me = el('consol-max-events');
10219 if (me) me.value = d.max_events_per_pass ?? 200;
10220 const mt = el('consol-max-topics');
10221 if (mt) mt.value = d.max_topics_per_pass ?? 10;
10222 const lmt = el('consol-llm-max-tokens');
10223 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
10224 const cc = el('consol-cost-cap');
10225 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
10226 const chi = el('consol-hosted-interval');
10227 if (chi && d.interval_minutes != null) {
10228 const v = String(d.interval_minutes);
10229 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
10230 chi.value = allowed.includes(v) ? v : '120';
10231 }
10232 }
10233
10234 function buildConsolSettingsPayload() {
10235 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
10236 const mode = modeRadio ? modeRadio.value : 'off';
10237 const hostedSel = el('consol-hosted-interval');
10238 const intervalRaw =
10239 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
10240 const llm = {
10241 provider: el('consol-llm-provider')?.value || '',
10242 model: el('consol-llm-model')?.value || '',
10243 base_url: el('consol-llm-base-url')?.value || '',
10244 };
10245 if (mode === 'daemon') {
10246 llm.max_tokens = Math.max(
10247 64,
10248 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
10249 );
10250 }
10251 const payload = {
10252 mode,
10253 enabled: mode === 'daemon',
10254 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
10255 idle_only: Boolean(el('consol-idle-only')?.checked),
10256 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
10257 run_on_start: Boolean(el('consol-run-on-start')?.checked),
10258 passes: {
10259 consolidate: Boolean(el('pass-consolidate')?.checked),
10260 verify: Boolean(el('pass-verify')?.checked),
10261 discover: Boolean(el('pass-discover')?.checked),
10262 },
10263 llm,
10264 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
10265 };
10266 if (mode === 'daemon') {
10267 payload.lookback_hours = Math.max(
10268 1,
10269 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
10270 );
10271 payload.max_events_per_pass = Math.max(
10272 1,
10273 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
10274 );
10275 payload.max_topics_per_pass = Math.max(
10276 1,
10277 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
10278 );
10279 }
10280 return payload;
10281 }
10282
10283 function applyConsolModeVisibility(mode) {
10284 const daemonSection = el('consol-daemon-settings');
10285 const hostedSection = el('consol-hosted-settings');
10286 const llmSection = el('consol-llm-settings');
10287 const costGuard = el('consol-cost-guard');
10288 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
10289 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
10290 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
10291 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
10292 }
10293
10294 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
10295 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
10296 });
10297
10298 let lastChatKeyAvailable = {};
10299
10300 function chatProviderKeyHintText(provider, keyAvail) {
10301 const ka = keyAvail || {};
10302 switch (provider) {
10303 case '':
10304 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
10305 case 'ollama':
10306 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
10307 case 'openrouter':
10308 return ka.openrouter
10309 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
10310 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
10311 case 'openai':
10312 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
10313 case 'anthropic':
10314 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
10315 case 'deepinfra':
10316 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
10317 default:
10318 return '';
10319 }
10320 }
10321
10322 function applyChatProviderSettings(s) {
10323 const chat = (s && s.chat) || {};
10324 const sel = el('chat-provider-select');
10325 const keyHint = el('chat-provider-key-hint');
10326 const envHint = el('chat-provider-env-hint');
10327 const adminHint = el('chat-provider-admin-hint');
10328 const saveBtn = el('btn-chat-provider-save');
10329 const msg = el('chat-provider-msg');
10330 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10331 if (!sel) return;
10332 lastChatKeyAvailable = chat.key_available || {};
10333 const isAdmin = String(s && s.role) === 'admin';
10334 const envLocked = Boolean(chat.env_locked);
10335 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
10336 sel.disabled = envLocked || !isAdmin;
10337 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
10338 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
10339 if (envHint) {
10340 if (envLocked) {
10341 envHint.textContent =
10342 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
10343 envHint.classList.remove('hidden');
10344 } else {
10345 envHint.classList.add('hidden');
10346 }
10347 }
10348 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10349
10350 if (!sel.dataset.knowtationBound) {
10351 sel.dataset.knowtationBound = '1';
10352 sel.addEventListener('change', () => {
10353 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10354 });
10355 }
10356 const btn = el('btn-chat-provider-save');
10357 if (btn && !btn.dataset.knowtationBound) {
10358 btn.dataset.knowtationBound = '1';
10359 btn.addEventListener('click', async () => {
10360 const m = el('chat-provider-msg');
10361 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
10362 try {
10363 const res = await api('/api/v1/settings/chat', {
10364 method: 'POST',
10365 body: JSON.stringify({ provider: sel.value }),
10366 });
10367 if (res && res.chat) sel.value = res.chat.provider || '';
10368 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
10369 } catch (e) {
10370 if (m) {
10371 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
10372 m.className = 'settings-msg err';
10373 }
10374 }
10375 });
10376 }
10377 }
10378
10379 async function loadConsolidationSettings() {
10380 const msg = el('consol-save-status');
10381 if (msg) msg.textContent = '';
10382 try {
10383 const s = await api('/api/v1/settings');
10384 populateConsolSettingsForm(s);
10385 } catch (e) {
10386 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
10387 }
10388 }
10389
10390 const btnConsolSave = el('btn-consol-save');
10391 if (btnConsolSave) {
10392 btnConsolSave.addEventListener('click', async () => {
10393 const msg = el('consol-save-status');
10394 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10395 const payload = buildConsolSettingsPayload();
10396 if (payload.enabled && payload.interval_minutes < 30) {
10397 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
10398 return;
10399 }
10400 setButtonBusy(btnConsolSave, true, 'Saving…');
10401 try {
10402 await api('/api/v1/settings/consolidation', {
10403 method: 'POST',
10404 body: JSON.stringify(payload),
10405 });
10406 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
10407 } catch (e) {
10408 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
10409 }
10410 setButtonBusy(btnConsolSave, false);
10411 });
10412 }
10413
10414 const linkConsolHelp = el('link-consol-help');
10415 if (linkConsolHelp) {
10416 linkConsolHelp.addEventListener('click', (e) => {
10417 e.preventDefault();
10418 closeSettings();
10419 openHowToUse('consolidation');
10420 });
10421 }
10422
10423 // ── Consolidation Dashboard Card ──────────────────────────────
10424
10425 function formatCostMeter(costUsd, capUsd) {
10426 const cost = Math.max(0, Number(costUsd) || 0);
10427 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
10428 const display = '$' + cost.toFixed(3) + ' today';
10429 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
10430 const pct = Math.min(100, (cost / cap) * 100);
10431 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
10432 }
10433
10434 function renderConsolidationHistory(events, container) {
10435 if (!container) return;
10436 if (!events || events.length === 0) {
10437 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
10438 return;
10439 }
10440 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>';
10441 events.forEach((ev) => {
10442 const ts = ev.ts || ev.timestamp || ev.created_at;
10443 const date = ts ? new Date(ts).toLocaleString() : '—';
10444 const rawTopics = ev.data?.topics_count;
10445 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
10446 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
10447 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
10448 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
10449 });
10450 html += '</tbody></table>';
10451 container.innerHTML = html;
10452 }
10453
10454 async function refreshConsolidationCard() {
10455 const card = el('consolidation-card');
10456 const badge = el('consol-status-badge');
10457 const lastPass = el('consol-last-pass');
10458 const nextPass = el('consol-next-pass');
10459 const quotaMeter = el('consol-quota-meter');
10460 const quotaLabel = el('consol-quota-label');
10461 const quotaFill = el('consol-quota-fill');
10462 const btnNow = el('btn-consol-now');
10463 if (!card) return;
10464
10465 try {
10466 const s = await api('/api/v1/settings');
10467 const mode = consolModeFromSettings(s);
10468 if (mode === 'off') {
10469 card.style.display = 'none';
10470 return;
10471 }
10472 card.style.display = '';
10473
10474 if (mode === 'hosted') {
10475 try {
10476 const st = await api('/api/v1/memory/consolidate/status');
10477 if (badge) {
10478 badge.textContent = '● Active (hosted)';
10479 badge.className = 'consol-badge consol-badge-success';
10480 }
10481 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
10482 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
10483
10484 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
10485 const passUsed = st.pass_count_month ?? 0;
10486 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10487 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10488 if (quotaMeter) {
10489 if (passLimit === null) {
10490 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
10491 if (quotaFill) quotaFill.style.width = '0%';
10492 } else if (passLimit > 0) {
10493 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
10494 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
10495 if (quotaFill) quotaFill.style.width = pct + '%';
10496 }
10497 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
10498 }
10499
10500 // Disable "Consolidate Now" during cooldown; show time remaining.
10501 const cooldown = st.cooldown_minutes ?? 0;
10502 if (btnNow && cooldown > 0) {
10503 btnNow.disabled = true;
10504 btnNow.textContent = 'Available in ' + cooldown + ' min';
10505 } else if (btnNow) {
10506 btnNow.disabled = false;
10507 btnNow.textContent = 'Consolidate Now';
10508 }
10509 } catch (_) {
10510 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
10511 }
10512 } else {
10513 if (badge) {
10514 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
10515 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
10516 }
10517 if (lastPass) lastPass.textContent = 'Last pass: —';
10518 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
10519 if (quotaMeter) quotaMeter.style.display = 'none';
10520 }
10521 } catch (_) {
10522 card.style.display = 'none';
10523 }
10524 }
10525
10526 const btnConsolNow = el('btn-consol-now');
10527 if (btnConsolNow) {
10528 btnConsolNow.addEventListener('click', async () => {
10529 setButtonBusy(btnConsolNow, true, 'Previewing…');
10530 try {
10531 const preview = await api('/api/v1/memory/consolidate', {
10532 method: 'POST',
10533 body: JSON.stringify({ dry_run: true }),
10534 });
10535 setButtonBusy(btnConsolNow, false);
10536 const topicsRaw = preview.topics;
10537 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
10538 const events = preview.total_events ?? 0;
10539 // Fetch current quota to show remaining passes in the preview dialog.
10540 let quotaLine = '';
10541 try {
10542 const st = await api('/api/v1/memory/consolidate/status');
10543 const passUsed = st.pass_count_month ?? 0;
10544 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10545 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10546 if (passLimit === null) {
10547 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
10548 } else if (passLimit > 0) {
10549 const remaining = Math.max(0, passLimit - passUsed);
10550 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
10551 }
10552 } catch (_) {}
10553 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
10554 if (!ok) return;
10555 setButtonBusy(btnConsolNow, true, 'Consolidating…');
10556 await api('/api/v1/memory/consolidate', {
10557 method: 'POST',
10558 body: JSON.stringify({ dry_run: false }),
10559 });
10560 if (typeof showToast === 'function') showToast('Consolidation complete.');
10561 refreshConsolidationCard();
10562 } catch (e) {
10563 const msg = e?.message || 'Consolidation failed';
10564 if (typeof showToast === 'function') showToast(msg, true);
10565 // Re-check cooldown after a rate-limit response so the button state updates.
10566 refreshConsolidationCard();
10567 }
10568 setButtonBusy(btnConsolNow, false);
10569 });
10570 }
10571
10572 const btnConsolHistory = el('btn-consol-history');
10573 if (btnConsolHistory) {
10574 btnConsolHistory.addEventListener('click', async () => {
10575 setButtonBusy(btnConsolHistory, true, 'Loading…');
10576 try {
10577 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
10578 const events = res.events || res.history || [];
10579 setButtonBusy(btnConsolHistory, false);
10580 const modal = document.createElement('div');
10581 modal.className = 'modal';
10582 modal.setAttribute('aria-modal', 'true');
10583 modal.innerHTML =
10584 '<div class="modal-backdrop"></div>' +
10585 '<div class="modal-card consol-history-modal">' +
10586 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
10587 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
10588 document.body.appendChild(modal);
10589 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
10590 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
10591 modal.querySelector('.modal-close').onclick = () => modal.remove();
10592 } catch (e) {
10593 setButtonBusy(btnConsolHistory, false);
10594 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
10595 }
10596 });
10597 }
10598
10599 function openSettingsConsolidationTab() {
10600 openSettings();
10601 document.querySelectorAll('.settings-tab').forEach((t) => {
10602 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
10603 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
10604 });
10605 document.querySelectorAll('.settings-panel').forEach((p) => {
10606 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
10607 });
10608 loadConsolidationSettings();
10609 }
10610
10611 const btnConsolSettings = el('btn-consol-settings');
10612 if (btnConsolSettings) {
10613 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
10614 }
10615
10616 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
10617 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
10618 // Billing consolidation row is populated inline in loadBillingPanel's try block.
10619 // We add to the existing billing flow by hooking the billing API response.
10620
10621 // Refresh consolidation card when dashboard renders
10622 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
10623 })();
File History 7 commits
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 40 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 57 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 74 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 76 days ago
sha256:f4def6a1a567d25eac87e879d96235c0588804a6c9b770d3958e74b22231db59 fix(test): align hub.js cache-bust contract with hub integr… Human 96 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 96 days ago