hub.js javascript
10,361 lines 419.9 KB
Raw
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor ⚠ breaking 9 days ago
1 /**
2 * Knowtation Hub UI — list, calendar, overview, quick add, presets. Phase 11C.
3 */
4
5 (function () {
6 const params = new URLSearchParams(location.search);
7 // Build-time or deployment config: set window.HUB_API_BASE_URL (e.g. from config.js). Empty string = same origin (when static host proxies /api to the gateway).
8 const apiBase = (function resolveApiBase() {
9 if (typeof window === 'undefined') return 'http://localhost:3333';
10 const paramApi = params.get('api');
11 if (paramApi != null && String(paramApi).trim()) {
12 return String(paramApi).trim().replace(/\/$/, '');
13 }
14 const hostname = location.hostname || '';
15 const isLocalDev =
16 hostname === 'localhost' ||
17 hostname === '127.0.0.1' ||
18 hostname === '[::1]' ||
19 hostname === '::1';
20 // Self-hosted dev: always call the same origin as the page (npm run hub). Stale localStorage
21 // hub_api_url often points at a hosted gateway and causes HTML 404 for Node-only routes.
22 if (isLocalDev) {
23 return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
24 }
25 if (Object.prototype.hasOwnProperty.call(window, 'HUB_API_BASE_URL')) {
26 const v = window.HUB_API_BASE_URL;
27 if (v == null) {
28 return (
29 localStorage.getItem('hub_api_url') ||
30 location.origin ||
31 'http://localhost:3333'
32 ).replace(/\/$/, '');
33 }
34 const s = String(v).trim();
35 if (s === '') return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
36 return s.replace(/\/$/, '');
37 }
38 return (localStorage.getItem('hub_api_url') || location.origin || 'http://localhost:3333').replace(/\/$/, '');
39 })();
40 /** Public MCP endpoint (https://…/mcp) when operator sets window.HUB_MCP_PUBLIC_URL in web/hub/config.js; else ''. */
41 const mcpPublicUrl = (function resolveMcpPublicUrl() {
42 if (typeof window === 'undefined') return '';
43 if (!Object.prototype.hasOwnProperty.call(window, 'HUB_MCP_PUBLIC_URL')) return '';
44 const v = window.HUB_MCP_PUBLIC_URL;
45 if (v == null) return '';
46 const s = String(v).trim();
47 if (s === '') return '';
48 return s.replace(/\/$/, '');
49 })();
50 /** Canonical doc: where Hub token, REST, remote MCP, and local CLI differ (copy blocks point here). */
51 const INTEGRATION_DOC_URL = 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md';
52 const hashParams = new URLSearchParams(location.hash.replace(/^#/, ''));
53 /** Used to defer onboarding until invite consume has run (see scheduleMaybeShowOnboardingWizard). */
54 const pageLoadHadInviteQuery = Boolean(params.get('invite'));
55 let token = hashParams.get('token') || params.get('token') || localStorage.getItem('hub_token');
56 if (token) {
57 localStorage.setItem('hub_token', token);
58 if (hashParams.has('token')) {
59 history.replaceState({}, '', location.pathname + location.search);
60 } else if (params.has('token')) {
61 const u = new URL(location.href);
62 u.searchParams.delete('token');
63 history.replaceState({}, '', u.toString());
64 }
65 }
66
67 /** Latest GET /api/v1/settings used for Backup tab (hosted repo field + sync body). */
68 let lastBackupSettingsPayload = null;
69
70 const PRESETS_KEY = 'hub_view_presets';
71 const el = (id) => document.getElementById(id);
72 const app = el('app');
73 const main = el('main');
74 const loginRequired = el('login-required');
75 const btnLoginGoogle = el('btn-login-google');
76 const btnLoginGithub = el('btn-login-github');
77 const btnLogout = el('btn-logout');
78 const btnNewNote = el('btn-new-note');
79 const btnImport = el('btn-import');
80 const btnHeaderSuggested = el('btn-header-suggested');
81 const btnHowToUse = el('btn-how-to-use');
82 const btnSettings = el('btn-settings');
83 const browseToolbar = el('browse-toolbar');
84 const userName = el('user-name');
85 const oauthNotConfigured = el('oauth-not-configured');
86 const loginIntro = el('login-intro');
87 const searchQuery = el('search-query');
88 const filterProject = el('filter-project');
89 const filterTag = el('filter-tag');
90 const filterFolder = el('filter-folder');
91 const filterSince = el('filter-since');
92 const filterUntil = el('filter-until');
93 const filterContentScope = el('filter-content-scope');
94 const filterNetwork = el('filter-network');
95 const filterWallet = el('filter-wallet');
96 const searchMode = el('search-mode');
97 const btnSearch = el('btn-search');
98 const btnClearSearch = el('btn-clear-search');
99 const btnApplyFilters = el('btn-apply-filters');
100 const btnReindex = el('btn-reindex');
101 const notesList = el('notes-list');
102 const notesTotal = el('notes-total');
103 /** True when the last unfiltered browse list (loadNotes, no list filters) returned zero notes. */
104 let hubBrowseListEmptyUnfiltered = false;
105 /** Last facets from {@link fetchFacetsResolved} (Hub create panel project pickers + similarity guard). */
106 let lastHubFacets = null;
107 /** Latest `/api/v1/vault/folders` list for subfolder derivation under `projects/<slug>/`. */
108 let lastVaultFoldersForCreate = [];
109 /** After “Keep my path” on similar-project modal, allow one create without re-prompting. */
110 let fullCreateSimilarOverrideOnce = false;
111 let fullCreateSimilarModalSuggestedSlug = '';
112 let fullCreateSimilarModalPendingPath = '';
113 let fullPathSimilarDebounceTimer = 0;
114 const filterChipsEl = el('filter-chips');
115 const presetsListEl = el('presets-list');
116 const presetNameInput = el('preset-name');
117 const hubBetaNote = el('hub-beta-note');
118 if (hubBetaNote && window.location.hostname !== 'knowtation.store' && window.location.hostname !== 'www.knowtation.store') hubBetaNote.classList.add('hidden');
119
120 let providers = null;
121 let calendarMonth = new Date();
122 let currentNotePathForCopy = '';
123 /** @type {{ path: string, body: string, frontmatter: Record<string, string> } | null} */
124 let currentOpenNote = null;
125 /** Increments when the SectionSource panel is reset so stale body-free reads do not render. */
126 let hubSectionSourceSeq = 0;
127 /** When set, full-create save may delete this path after posting the duplicate (optional checkbox). */
128 /** @type {{ path: string } | null} */
129 let pendingDuplicateDeleteSource = null;
130 /** AbortController for window resize while note edit body layout is active. */
131 let detailEditBodyLayoutAbort = null;
132
133 /** Hide the detail drawer (does not clear currentOpenNote). */
134 function hideDetailPanelChrome() {
135 const dp = el('detail-panel');
136 if (dp) {
137 dp.classList.add('hidden');
138 dp.classList.remove('detail-panel-proposal-wide');
139 }
140 }
141
142 /** User dismisses the drawer (Escape, Close): clear open-note state. */
143 function closeDetailPanel() {
144 currentOpenNote = null;
145 currentNotePathForCopy = '';
146 resetDetailSectionSourceState();
147 teardownDetailEditBodyLayout();
148 hideDetailPanelChrome();
149 const bcbClose = el('btn-detail-copy-body');
150 if (bcbClose) bcbClose.classList.add('hidden');
151 const bcp = el('btn-copy-path');
152 if (bcp) bcp.classList.add('hidden');
153 }
154
155 let listSelectedIndex = 0;
156 /** Increments on each `openNote` call so stale fetch completions do not append duplicate actions or overwrite UI. */
157 let hubOpenNoteSeq = 0;
158 /** @type {import('chart.js').Chart[]} */
159 let chartInstances = [];
160
161 const FILTER_CHIPS_EXPANDED_KEY = 'hub_filter_chips_expanded';
162 let filterChipsExpanded = false;
163 try {
164 filterChipsExpanded = localStorage.getItem(FILTER_CHIPS_EXPANDED_KEY) === '1';
165 } catch (_) {
166 filterChipsExpanded = false;
167 }
168
169 const ACCENT_STORAGE_KEY = 'hub_accent_color';
170 const THEME_STORAGE_KEY = 'hub_theme';
171 const COLOR_PALETTE_STORAGE_KEY = 'hub_color_palette';
172 const DEFAULT_ACCENT = '#89cff0';
173 const DEFAULT_THEME = 'dark';
174 const DEFAULT_COLOR_PALETTE = 'default';
175 const VALID_COLOR_PALETTES = new Set([
176 'default',
177 'ocean',
178 'forest',
179 'sunset',
180 'lavender',
181 'ember',
182 'arctic',
183 'slate',
184 'midnight',
185 'sakura',
186 'sand',
187 'mint',
188 ]);
189 const loadingHtml = '<div class="loading-state" aria-live="polite">Loading…</div>';
190 function applyAccent(hex) {
191 if (hex) {
192 document.documentElement.style.setProperty('--accent', hex);
193 try {
194 localStorage.setItem(ACCENT_STORAGE_KEY, hex);
195 } catch (_) {}
196 }
197 }
198 function applyTheme(theme) {
199 const value = theme === 'light' ? 'light' : 'dark';
200 document.documentElement.setAttribute('data-theme', value === 'dark' ? '' : value);
201 try {
202 localStorage.setItem(THEME_STORAGE_KEY, value);
203 } catch (_) {}
204 }
205 function applyColorPalette(id) {
206 const p =
207 id && VALID_COLOR_PALETTES.has(String(id)) ? String(id) : DEFAULT_COLOR_PALETTE;
208 if (p === DEFAULT_COLOR_PALETTE) {
209 document.documentElement.removeAttribute('data-palette');
210 } else {
211 document.documentElement.setAttribute('data-palette', p);
212 }
213 try {
214 localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, p);
215 } catch (_) {}
216 }
217 function currentColorPalette() {
218 const a = document.documentElement.getAttribute('data-palette');
219 if (a && VALID_COLOR_PALETTES.has(a) && a !== DEFAULT_COLOR_PALETTE) return a;
220 return DEFAULT_COLOR_PALETTE;
221 }
222 (function initThemeAndAccent() {
223 try {
224 const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
225 if (savedTheme === 'light') applyTheme('light');
226 const savedAccent = localStorage.getItem(ACCENT_STORAGE_KEY);
227 if (savedAccent) applyAccent(savedAccent);
228 const savedPalette = localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
229 if (savedPalette) applyColorPalette(savedPalette);
230 } catch (_) {}
231 })();
232
233 function headers() {
234 const h = { 'Content-Type': 'application/json' };
235 if (token) h['Authorization'] = 'Bearer ' + token;
236 const vid = getCurrentVaultId();
237 if (vid) h['X-Vault-Id'] = vid;
238 return h;
239 }
240
241 // Persistent sessions: when the short-lived access token expires, silently exchange the
242 // HttpOnly refresh cookie for a new one instead of dropping the user to the login screen.
243 // Single-flight so a burst of 401s triggers exactly one refresh.
244 let refreshInFlight = null;
245 async function refreshAccessToken() {
246 if (refreshInFlight) return refreshInFlight;
247 refreshInFlight = (async () => {
248 try {
249 const res = await fetch(apiBase + '/api/v1/auth/refresh', {
250 method: 'POST',
251 credentials: 'include', // send the HttpOnly refresh cookie
252 cache: 'no-store',
253 headers: { 'Content-Type': 'application/json' },
254 });
255 if (!res.ok) return false;
256 const data = await res.json().catch(() => null);
257 if (data && typeof data.access_token === 'string' && data.access_token) {
258 token = data.access_token;
259 try { localStorage.setItem('hub_token', token); } catch (_) {}
260 return true;
261 }
262 return false;
263 } catch (_) {
264 return false;
265 }
266 })();
267 try {
268 return await refreshInFlight;
269 } finally {
270 refreshInFlight = null;
271 }
272 }
273
274 async function api(path, opts = {}) {
275 const method = (opts.method || 'GET').toUpperCase();
276 // GET/HEAD: retry up to 2×. POST/PATCH/DELETE: retry once only on pure network failures
277 // (before any HTTP response), which means the server never received the request so retrying
278 // is safe. Never retry on HTTP error responses (4xx/5xx) — those were received and processed.
279 //
280 // `opts.noRetry: true` opts out of retries entirely. Used by `POST /api/v1/index`: a 30s
281 // gateway timeout (Netlify Function cap) drops the client connection, which the browser
282 // surfaces as `Failed to fetch`. With retry on, the bridge then receives a SECOND index
283 // request while the first is still running, double-billing DeepInfra and worsening contention.
284 const maxNetworkRetries = opts.noRetry === true
285 ? 0
286 : (method === 'GET' || method === 'HEAD') ? 2 : 1;
287 // Strip non-fetch keys before forwarding to fetch() so they don't pollute the request init.
288 const { noRetry: _noRetry, ...fetchOpts } = opts;
289 // Internal one-shot control flag for the 401 silent-refresh retry; never forward to fetch().
290 delete fetchOpts._retriedAfterRefresh;
291 let res;
292 let networkRetries = maxNetworkRetries;
293 for (;;) {
294 try {
295 res = await fetch(apiBase + path, {
296 ...fetchOpts,
297 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
298 headers: { ...headers(), ...fetchOpts.headers },
299 });
300 break;
301 } catch (e) {
302 const m = e && e.message ? String(e.message) : String(e);
303 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
304 networkRetries--;
305 await new Promise(resolve => setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000));
306 continue;
307 }
308 if (m === 'Failed to fetch' || m.includes('NetworkError')) {
309 throw new Error(
310 'Could not reach the API (' +
311 apiBase +
312 '). Check gateway status, CORS (HUB_CORS_ORIGIN), ad blockers, and Netlify limits.',
313 );
314 }
315 throw e instanceof Error ? e : new Error(m);
316 }
317 }
318 if (res.status === 401) {
319 // Try a one-time silent refresh before forcing re-login. Never recurse on the auth
320 // endpoints themselves, and only retry once per original request.
321 if (
322 path !== '/api/v1/auth/refresh' &&
323 path !== '/api/v1/auth/logout' &&
324 !opts._retriedAfterRefresh
325 ) {
326 const refreshed = await refreshAccessToken();
327 if (refreshed) {
328 return api(path, { ...opts, _retriedAfterRefresh: true });
329 }
330 }
331 token = null;
332 localStorage.removeItem('hub_token');
333 if (app) app.classList.add('login-screen');
334 main.classList.add('hidden');
335 loginRequired.classList.remove('hidden');
336 browseToolbar.classList.add('hidden');
337 btnNewNote.classList.add('hidden');
338 if (btnImport) btnImport.classList.add('hidden');
339 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
340 if (btnHowToUse) btnHowToUse.classList.add('hidden');
341 if (btnSettings) btnSettings.classList.add('hidden');
342 showLoginChrome();
343 throw new Error('Unauthorized');
344 }
345 let text = await res.text();
346 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
347 let data;
348 try {
349 data = text ? JSON.parse(text) : null;
350 } catch (_) {
351 const t = text.trim();
352 if (/^<!DOCTYPE/i.test(t) || /<html/i.test(t)) {
353 throw new Error(
354 `Server returned a web page (${res.status}) instead of API JSON. Restart the Hub (\`npm run hub\`) after pulling. On localhost, the UI must use the same origin as Node Hub (not a hosted gateway); use \`?api=\` only if you intentionally point at another API base.`,
355 );
356 }
357 throw new Error(
358 'Response was not valid JSON (' +
359 res.status +
360 '). Start of body: ' +
361 t.slice(0, 120) +
362 (t.length > 120 ? '...' : ''),
363 );
364 }
365 if (!res.ok) {
366 const label = data?.error || res.statusText;
367 const detail = data?.message != null && String(data.message).trim() ? String(data.message).trim() : '';
368 const combined = detail ? `${label}: ${detail}` : label;
369 const err = new Error(combined);
370 if (data && data.code) err.code = data.code;
371 throw err;
372 }
373 return data;
374 }
375
376 /** Busy state for buttons during slow API calls (clear feedback on hosted). */
377 function setButtonBusy(btn, busy, labelWhenBusy) {
378 if (!btn || btn.nodeType !== 1) return;
379 const busyText = labelWhenBusy || 'Working…';
380 if (busy) {
381 if (btn.dataset.knowtationBtnRestLabel == null) {
382 btn.dataset.knowtationBtnRestLabel = btn.textContent;
383 }
384 btn.textContent = busyText;
385 btn.disabled = true;
386 btn.classList.add('btn-busy');
387 btn.setAttribute('aria-busy', 'true');
388 } else {
389 if (btn.dataset.knowtationBtnRestLabel != null) {
390 btn.textContent = btn.dataset.knowtationBtnRestLabel;
391 delete btn.dataset.knowtationBtnRestLabel;
392 }
393 btn.classList.remove('btn-busy');
394 btn.removeAttribute('aria-busy');
395 btn.disabled = false;
396 }
397 }
398
399 async function withButtonBusy(btn, labelWhenBusy, fn) {
400 if (!btn) return fn();
401 setButtonBusy(btn, true, labelWhenBusy);
402 try {
403 return await fn();
404 } finally {
405 setButtonBusy(btn, false);
406 }
407 }
408
409 const HOSTED_BACKUP_REPO_LS = 'knowtation_hosted_backup_repo';
410 /** If set, `resolveApiBase` uses this instead of `location.origin` — can point local Hub UI at Netlify by mistake. */
411 const HUB_API_URL_LS = 'hub_api_url';
412
413 const VAULT_ID_LS = 'hub_vault_id';
414 /** @see `web/hub/hub-client-import-zip.mjs` — 4B sequential import cap. */
415 const HUB_IMPORT_MAX_SEQUENTIAL = 200;
416 const importFileEl = el('import-file');
417 const importFileFolderEl = el('import-file-folder');
418 const importFolderHintEl = el('import-folder-hint');
419 const importBatchCancelBtn = el('import-batch-cancel');
420 const importBatchAriaEl = el('import-batch-aria');
421 /** Dropped files/folder (4C) — when set, submit uses this instead of the file inputs. */
422 /** @type {File[] | null} */
423 let importPendingDropFiles = null;
424 const importDropZoneEl = el('import-drop-zone');
425 const importDropStatusEl = el('import-drop-status');
426 /** @type {AbortController | null} */
427 let importBatchAbort = null;
428 const btnImportChooseFolder = el('btn-import-choose-folder');
429
430 function wrapFileWithWebkitRel(file, relPath) {
431 const w = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
432 const rel = String(relPath || file.name).replace(/^\//, '');
433 try {
434 Object.defineProperty(w, 'webkitRelativePath', { value: rel, enumerable: true, configurable: true });
435 } catch (_) {}
436 return w;
437 }
438
439 /**
440 * @param {FileSystemFileEntry} fe
441 * @param {string} pathPrefix
442 * @returns {Promise<File>}
443 */
444 function fileEntryToFileWithPath(fe, pathPrefix) {
445 return new Promise((resolve, reject) => {
446 fe.file(
447 (file) => {
448 const rel = (String(pathPrefix || '') + file.name).replace(/^\//, '');
449 resolve(wrapFileWithWebkitRel(file, rel));
450 },
451 reject,
452 );
453 });
454 }
455
456 /**
457 * @param {FileSystemDirectoryEntry} dirEntry
458 * @param {string} pathPrefix
459 * @returns {Promise<File[]>}
460 */
461 async function readAllFilesInDirectoryEntry(dirEntry, pathPrefix) {
462 const all = [];
463 const reader = dirEntry.createReader();
464 let batch;
465 do {
466 /** @type {FileSystemEntry[]} */
467 batch = await new Promise((res, rej) => reader.readEntries(res, rej));
468 for (const e of batch) {
469 if (e.isFile) {
470 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(e), pathPrefix));
471 } else if (e.isDirectory) {
472 all.push(
473 ...(await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(e), pathPrefix + e.name + '/')),
474 );
475 }
476 }
477 } while (batch.length > 0);
478 return all;
479 }
480
481 /**
482 * @param {DataTransfer} dataTransfer
483 * @returns {Promise<File[]>}
484 */
485 async function collectFilesFromDataTransfer(dataTransfer) {
486 if (!dataTransfer) return [];
487 const canEntry =
488 dataTransfer.items &&
489 dataTransfer.items.length > 0 &&
490 Array.from(dataTransfer.items).some((it) => it.kind === 'file' && 'webkitGetAsEntry' in it);
491 if (canEntry) {
492 const all = [];
493 for (const item of Array.from(dataTransfer.items)) {
494 if (item.kind !== 'file') continue;
495 if (item.webkitGetAsEntry) {
496 const entry = item.webkitGetAsEntry();
497 if (entry) {
498 if (entry.isFile) {
499 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(entry), ''));
500 } else if (entry.isDirectory) {
501 all.push(
502 ...(
503 await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(entry), entry.name + '/')
504 ),
505 );
506 }
507 } else {
508 const f = item.getAsFile();
509 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
510 }
511 } else {
512 const f = item.getAsFile();
513 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
514 }
515 }
516 return all;
517 }
518 if (dataTransfer.files && dataTransfer.files.length) {
519 return Array.from(dataTransfer.files).map((f) => wrapFileWithWebkitRel(f, f.name));
520 }
521 return [];
522 }
523
524 function updateImportDropStatusUi() {
525 if (!importDropStatusEl) return;
526 if (importPendingDropFiles && importPendingDropFiles.length > 0) {
527 importDropStatusEl.hidden = false;
528 importDropStatusEl.textContent =
529 importPendingDropFiles.length +
530 ' file(s) from drop. Click Import, or use the file picker above to replace.';
531 } else {
532 importDropStatusEl.hidden = true;
533 importDropStatusEl.textContent = '';
534 }
535 }
536
537 function clearImportDropPending() {
538 importPendingDropFiles = null;
539 if (importDropZoneEl) importDropZoneEl.classList.remove('import-drop-zone--over');
540 updateImportDropStatusUi();
541 }
542
543 function setImportBatchAria(s) {
544 if (importBatchAriaEl) importBatchAriaEl.textContent = s || '';
545 }
546
547 function normalizeUrlOrigin(base) {
548 try {
549 const s = String(base || '').trim().replace(/\/$/, '');
550 if (!s) return '';
551 const u = new URL(s.startsWith('http') ? s : 'https://' + s);
552 return u.origin;
553 } catch (_) {
554 return '';
555 }
556 }
557
558 function isLocalHubHostname() {
559 const h = location.hostname;
560 return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
561 }
562
563 /** Local Hub tab but `apiBase` targets another origin (e.g. Netlify) — causes “Could not reach the API … knowtation-gateway…”. */
564 function localApiBaseFootgunActive() {
565 if (!isLocalHubHostname()) return false;
566 const pageO = normalizeUrlOrigin(location.origin);
567 const apiO = normalizeUrlOrigin(apiBase);
568 if (!pageO || !apiO) return false;
569 return pageO !== apiO;
570 }
571
572 function refreshApiBaseFootgunBanner() {
573 const b = el('hub-api-base-footgun-banner');
574 if (!b) return;
575 if (!localApiBaseFootgunActive()) {
576 b.classList.add('hidden');
577 b.innerHTML = '';
578 return;
579 }
580 let lsHint = false;
581 try {
582 lsHint = Boolean(localStorage.getItem(HUB_API_URL_LS));
583 } catch (_) {}
584 const qsHint = Boolean(params.get('api'));
585 b.classList.remove('hidden');
586 const hint =
587 (lsHint ? ' <code>localStorage.' + HUB_API_URL_LS + '</code> is set.' : '') +
588 (qsHint ? ' This URL has an <code>?api=</code> override.' : '');
589 b.innerHTML =
590 '<p><strong>Wrong API for this tab.</strong> This page is on <code>' +
591 escapeHtml(location.origin) +
592 '</code> but the Hub calls <code>' +
593 escapeHtml(apiBase) +
594 '</code> for requests (settings, backup, notes).' +
595 hint +
596 ' For self-hosted <code>npm run hub</code>, clear the override so the API matches this origin, then reload.</p>' +
597 '<p><button type="button" class="btn-secondary" id="hub-api-footgun-clear">Clear API override &amp; reload</button></p>';
598 const clearBtn = el('hub-api-footgun-clear');
599 if (clearBtn) {
600 clearBtn.onclick = () => {
601 try {
602 localStorage.removeItem(HUB_API_URL_LS);
603 } catch (_) {}
604 const u = new URL(location.href);
605 u.searchParams.delete('api');
606 window.location.href = u.toString();
607 };
608 }
609 }
610
611 function getCurrentVaultId() {
612 try {
613 return localStorage.getItem(VAULT_ID_LS) || 'default';
614 } catch (_) {
615 return 'default';
616 }
617 }
618
619 function setCurrentVaultId(id) {
620 try {
621 localStorage.setItem(VAULT_ID_LS, id);
622 } catch (_) {}
623 }
624
625 /** Per-vault hint: Meaning (semantic) search may lag vault edits until Re-index runs successfully. */
626 const HUB_SEMANTIC_INDEX_STALE_PREFIX = 'hub_semantic_index_stale_v1:';
627
628 function hubSemanticIndexStaleLsKey(vaultId) {
629 const v = vaultId != null && String(vaultId).trim() !== '' ? String(vaultId).trim() : 'default';
630 return HUB_SEMANTIC_INDEX_STALE_PREFIX + v;
631 }
632
633 function hubRefreshIndexStaleBanner() {
634 const banner = el('hub-index-stale-banner');
635 if (!banner) return;
636 let flagged = false;
637 try {
638 flagged = Boolean(localStorage.getItem(hubSemanticIndexStaleLsKey(getCurrentVaultId())));
639 } catch (_) {
640 flagged = false;
641 }
642 if (!flagged) {
643 banner.classList.add('hidden');
644 return;
645 }
646 banner.classList.remove('hidden');
647 }
648
649 function hubMarkSemanticIndexStaleForVault(vaultId) {
650 try {
651 localStorage.setItem(hubSemanticIndexStaleLsKey(vaultId), String(Date.now()));
652 } catch (_) {}
653 hubRefreshIndexStaleBanner();
654 }
655
656 function hubMarkSemanticIndexStale() {
657 hubMarkSemanticIndexStaleForVault(getCurrentVaultId());
658 }
659
660 function hubClearSemanticIndexStaleForVault(vaultId) {
661 try {
662 localStorage.removeItem(hubSemanticIndexStaleLsKey(vaultId));
663 } catch (_) {}
664 hubRefreshIndexStaleBanner();
665 }
666
667 function hubClearSemanticIndexStale() {
668 hubClearSemanticIndexStaleForVault(getCurrentVaultId());
669 }
670
671 function updateVaultSwitcher(vaultList, allowedVaultIds) {
672 const wrap = el('vault-switcher-wrap');
673 const select = el('vault-switcher');
674 if (!wrap || !select) return;
675 const rows = Array.isArray(vaultList) ? vaultList : [];
676 const byId = new Map(rows.map((v) => [String(v.id), v]));
677 let allowed =
678 Array.isArray(allowedVaultIds) && allowedVaultIds.length
679 ? allowedVaultIds.map(String)
680 : rows.length
681 ? rows.map((v) => String(v.id))
682 : ['default'];
683 allowed = [...new Set(allowed)];
684 const options = allowed.map((id) => {
685 const v = byId.get(id);
686 return { id, label: v && (v.label || v.id) ? String(v.label || v.id) : id };
687 });
688 select.innerHTML = options
689 .map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label) + '</option>')
690 .join('');
691 select.value = getCurrentVaultId();
692 if (!allowed.includes(select.value)) select.value = allowed[0] || 'default';
693 setCurrentVaultId(select.value);
694 wrap.classList.toggle('hidden', options.length <= 1);
695 if (allowed.length >= 2 && options.length === 1) {
696 select.title =
697 'This Hub has more vaults. To use them, copy your User ID from Settings → Backup into Vault access on Settings → Vaults, then save and refresh.';
698 } else {
699 select.title = '';
700 }
701 select.onchange = () => {
702 setCurrentVaultId(select.value);
703 loadFacets();
704 loadNotes();
705 loadProposals();
706 hubRefreshIndexStaleBanner();
707 };
708 }
709
710 function applyHostedUiFromSettings(s) {
711 if (!s || typeof s !== 'object') return;
712 const hosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
713 window.__hubIsHosted = hosted;
714 const btn = el('btn-projects-help');
715 if (btn) btn.classList.toggle('hidden', !hosted);
716 }
717
718 function normalizeGithubRepoSlug(raw) {
719 let t = (raw || '').trim();
720 if (!t) return '';
721 t = t.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
722 const parts = t.split('/').filter(Boolean);
723 if (parts.length >= 2) return parts[0] + '/' + parts[1];
724 return t;
725 }
726
727 /** Hosted (canister): any logged-in user may sync to their own GitHub; self-hosted still requires admin. */
728 function settingsSyncDisabled(s, vg, isHosted) {
729 const isAdmin = s.role === 'admin';
730 const hostedGitBackup = isHosted && s.github_connect_available;
731 if (hostedGitBackup) {
732 const inputEl = el('settings-hosted-repo');
733 const inputRepo = normalizeGithubRepoSlug(inputEl && inputEl.value);
734 const slug = inputRepo || normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) || normalizeGithubRepoSlug(s.repo);
735 return !s.github_connected || !slug;
736 }
737 return !vg.enabled || !vg.has_remote || !isAdmin;
738 }
739
740 /** After Connect GitHub, blob read-after-write can lag; retry settings until github_connected or timeout. */
741 async function fetchSettingsForBackupModal() {
742 const pendingRaw = sessionStorage.getItem('knowtation_github_connect_pending');
743 const pendingTs = pendingRaw ? parseInt(pendingRaw, 10) : NaN;
744 const pendingFresh = Number.isFinite(pendingTs) && Date.now() - pendingTs < 120000;
745 if (!pendingFresh) {
746 if (pendingRaw) sessionStorage.removeItem('knowtation_github_connect_pending');
747 return api('/api/v1/settings');
748 }
749 let s;
750 for (let attempt = 0; attempt < 8; attempt++) {
751 s = await api('/api/v1/settings');
752 if (s.github_connected || !s.github_connect_available) break;
753 if (attempt < 7) await new Promise((r) => setTimeout(r, 600));
754 }
755 sessionStorage.removeItem('knowtation_github_connect_pending');
756 return s;
757 }
758
759 /** Align with hub/server effectiveRole: viewer read-only; member maps to editor for writes. */
760 function hubUserCanWriteNotes() {
761 const r = window.__hubUserRole;
762 return r === 'editor' || r === 'admin' || r === 'member';
763 }
764
765 /** Same roles as POST /api/v1/proposals on Hub (evaluators propose; viewers do not). */
766 function hubUserMayProposeFromNote() {
767 const r = window.__hubUserRole;
768 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
769 }
770
771 /** Download current note (POST /api/v1/export); allowed for any vault reader including viewer. */
772 function hubUserCanExportNote() {
773 const r = window.__hubUserRole || 'member';
774 return (
775 r === 'editor' || r === 'admin' || r === 'member' || r === 'viewer' || r === 'evaluator'
776 );
777 }
778
779 /** Proposal Enrich (AI): evaluators may run it without note-write roles; editors/admins/members still qualify. */
780 function hubUserMayEnrichProposal() {
781 const r = window.__hubUserRole;
782 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
783 }
784
785 /** Multi-vault copy/move in note detail (Settings must list ≥2 allowed vaults). */
786 function hubHasMultipleVaultsForCopy() {
787 const s = lastBackupSettingsPayload;
788 if (!s || !Array.isArray(s.allowed_vault_ids)) return false;
789 return s.allowed_vault_ids.filter(Boolean).length >= 2;
790 }
791
792 function hubUserIsAdmin() {
793 return window.__hubUserRole === 'admin';
794 }
795
796 /** Delete vault: self-hosted admins only; hosted matches “create vault” (writer + workspace owner when set). */
797 function hubUserMayDeleteVault() {
798 if (!hubUserCanWriteNotes()) return false;
799 if (isHostedHubFromSettings()) {
800 const ws = lastBackupSettingsPayload;
801 const ownerId =
802 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
803 ? String(ws.workspace_owner_id).trim()
804 : '';
805 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
806 if (ownerId && me && me !== ownerId) return false;
807 return true;
808 }
809 return hubUserIsAdmin();
810 }
811
812 function populateSettingsDeleteVaultSelect(s) {
813 const sel = el('settings-delete-vault-select');
814 if (!sel) return;
815 const vaultList = (s && Array.isArray(s.vault_list) && s.vault_list) || [];
816 const allowedRaw = s && Array.isArray(s.allowed_vault_ids) ? s.allowed_vault_ids : null;
817 const allowedSet = allowedRaw && allowedRaw.length > 0 ? new Set(allowedRaw.map(String)) : null;
818 const opts = vaultList.filter((v) => {
819 if (!v || v.id == null) return false;
820 const id = String(v.id).trim();
821 if (!id || id === 'default') return false;
822 if (allowedSet && !allowedSet.has(id)) return false;
823 return true;
824 });
825 sel.innerHTML =
826 opts.length === 0
827 ? '<option value="">(no extra vaults)</option>'
828 : '<option value="">— Choose vault —</option>' +
829 opts
830 .map(
831 (v) =>
832 '<option value="' +
833 escapeHtml(String(v.id)) +
834 '">' +
835 escapeHtml(String(v.label != null && v.label !== '' ? v.label : v.id)) +
836 '</option>',
837 )
838 .join('');
839 }
840
841 function refreshVaultDeleteSubsection() {
842 const wrap = el('settings-danger-zone-vault');
843 if (!wrap) return;
844 const s = lastBackupSettingsPayload;
845 if (!s || !hubUserMayDeleteVault()) {
846 wrap.classList.add('hidden');
847 return;
848 }
849 populateSettingsDeleteVaultSelect(s);
850 const vaultList = (s.vault_list) || [];
851 const extra = vaultList.filter((v) => v && String(v.id).trim() && String(v.id).trim() !== 'default');
852 if (extra.length === 0) {
853 wrap.classList.add('hidden');
854 return;
855 }
856 wrap.classList.remove('hidden');
857 }
858
859 function refreshDeleteProjectPanelVisibility() {
860 const panel = el('settings-danger-zone-panel');
861 if (panel) panel.classList.toggle('hidden', !hubUserCanWriteNotes());
862 refreshVaultDeleteSubsection();
863 }
864
865 /** Apply GET /api/v1/settings payload to header vault switcher, hosted flag, and cached backup modal state. */
866 function applySettingsPayloadToHubChrome(s) {
867 if (!s || typeof s !== 'object') return;
868 lastBackupSettingsPayload = s;
869 if (s.role) window.__hubUserRole = String(s.role);
870 refreshDeleteProjectPanelVisibility();
871 refreshNewProposalTabVisibility();
872 const allowed = (s.allowed_vault_ids || []).map(String);
873 const current = String(getCurrentVaultId());
874 if (allowed.length && !allowed.includes(current)) {
875 setCurrentVaultId(allowed[0] || 'default');
876 }
877 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
878 applyHostedUiFromSettings(s);
879 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
880 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
881 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
882 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
883 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
884 const metaSelf = el('settings-bulk-metadata-self-only');
885 if (metaSelf) metaSelf.classList.remove('hidden');
886 applyMuseBridgePanel(s);
887 }
888
889 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
890 function applyMuseBridgePanel(s) {
891 if (!s || typeof s !== 'object') return;
892 const mb = s.muse_bridge;
893 const statusEl = el('settings-muse-status');
894 const envHint = el('settings-muse-env-hint');
895 const input = el('settings-muse-url');
896 const saveBtn = el('btn-settings-muse-save');
897 const msg = el('settings-muse-msg');
898 if (msg) {
899 msg.textContent = '';
900 msg.className = 'settings-msg';
901 }
902 if (!mb) {
903 if (statusEl) statusEl.textContent = '—';
904 if (input) {
905 input.value = '';
906 input.disabled = true;
907 }
908 if (saveBtn) saveBtn.classList.add('hidden');
909 return;
910 }
911 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
912 const isAdmin = s.role === 'admin';
913 if (statusEl) {
914 statusEl.textContent =
915 mb.enabled && mb.origin
916 ? 'Server status: linked — ' + mb.origin
917 : 'Server status: Muse link not configured for this Hub.';
918 }
919 if (envHint) {
920 envHint.classList.toggle('hidden', !mb.env_override_active);
921 envHint.textContent = mb.env_override_active
922 ? 'This Hub process has MUSE_URL set in its environment; that value overrides config/local.yaml. Change or unset it on the server to edit the field below.'
923 : '';
924 }
925 if (input) {
926 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
927 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
928 input.disabled = !canEdit;
929 input.title = canEdit
930 ? ''
931 : isHosted
932 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
933 : !isAdmin
934 ? 'Only admins can save the Muse URL.'
935 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
936 }
937 if (saveBtn) {
938 const show = !isHosted && isAdmin && mb.url_editable === true;
939 saveBtn.classList.toggle('hidden', !show);
940 }
941 }
942
943 function showLoginChrome() {
944 btnLogout.classList.add('hidden');
945 userName.textContent = '';
946 if (!providers) return;
947 if (providers.google) btnLoginGoogle.classList.remove('hidden');
948 if (providers.github) btnLoginGithub.classList.remove('hidden');
949 if (!providers.google && !providers.github) {
950 oauthNotConfigured.classList.remove('hidden');
951 if (loginIntro) loginIntro.classList.add('hidden');
952 }
953 }
954
955 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
956 let onboardingModulePromise = null;
957 function loadOnboardingModule() {
958 if (!onboardingModulePromise) {
959 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
960 }
961 return onboardingModulePromise;
962 }
963
964 function getOnboardingUserKey() {
965 if (!token) return '';
966 try {
967 const payload = JSON.parse(atob(token.split('.')[1]));
968 return String(payload.sub || payload.email || 'unknown');
969 } catch (_) {
970 return 'unknown';
971 }
972 }
973
974 /**
975 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
976 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
977 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
978 */
979 function wizardHostedFromContext(settingsPayload) {
980 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
981 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
982 if (vd === 'canister') return true;
983 try {
984 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
985 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
986 } catch (_) {}
987 return false;
988 }
989
990 function persistOnboardingProgress(mod, partial) {
991 const userKey = getOnboardingUserKey();
992 const isHosted = wizardHostedFromContext();
993 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
994 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
995 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
996 st = mod.createFreshState(userKey, hostingPath);
997 }
998 Object.assign(st, partial);
999 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1000 }
1001
1002 let onboardingWizardBindingsDone = false;
1003 let onboardingRenderStep = function () {};
1004
1005 function closeOnboardingWizardResume() {
1006 const modal = el('modal-onboarding');
1007 if (!modal || modal.classList.contains('hidden')) return;
1008 modal.classList.add('hidden');
1009 }
1010
1011 function closeOnboardingWizardDismiss() {
1012 loadOnboardingModule()
1013 .then((mod) => {
1014 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1015 updateEmptyVaultStripVisibility();
1016 })
1017 .catch(function () {});
1018 const modal = el('modal-onboarding');
1019 if (modal) modal.classList.add('hidden');
1020 }
1021
1022 function bindOnboardingWizardOnce(mod) {
1023 if (onboardingWizardBindingsDone) return;
1024 onboardingWizardBindingsDone = true;
1025 const modal = el('modal-onboarding');
1026 const closeBtn = el('modal-onboarding-close');
1027 const backdrop = el('modal-onboarding-backdrop');
1028 const btnSkip = el('btn-onboarding-skip');
1029 const btnBack = el('btn-onboarding-back');
1030 const btnNext = el('btn-onboarding-next');
1031 const body = el('onboarding-step-body');
1032 const progress = el('onboarding-progress');
1033 const live = el('onboarding-live');
1034 const secondary = el('onboarding-secondary-actions');
1035
1036 function handleSecondaryAction(id) {
1037 /* Keep onboarding open underneath: Settings / How to use / Projects stack on top (DOM order + z-index). Close the top modal to return to the guide. */
1038 if (id === 'projectsHelp') {
1039 openProjectsHelpModal();
1040 return;
1041 }
1042 if (id === 'howToKnowledge') {
1043 openHowToUse('knowledge-agents');
1044 return;
1045 }
1046 if (id === 'openSettingsBackup') {
1047 openSettings();
1048 return;
1049 }
1050 if (id === 'openSettingsIntegrations') {
1051 openSettingsIntegrationsTab();
1052 return;
1053 }
1054 if (id === 'howToSetup4') {
1055 openHowToUse('setup', 'how-to-step-selfhosted-index');
1056 return;
1057 }
1058 if (id === 'howToSetup3') {
1059 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1060 return;
1061 }
1062 if (id === 'openWhyTokenDoc') {
1063 window.open(
1064 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1065 '_blank',
1066 'noopener,noreferrer',
1067 );
1068 return;
1069 }
1070 if (id === 'openImportModal') {
1071 closeOnboardingWizardResume();
1072 openImportModal();
1073 return;
1074 }
1075 if (id === 'openImportSourcesDoc') {
1076 window.open(
1077 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1078 '_blank',
1079 'noopener,noreferrer',
1080 );
1081 return;
1082 }
1083 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1084 window.open(
1085 id === 'openAgentDocProposals'
1086 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1087 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1088 '_blank',
1089 'noopener,noreferrer',
1090 );
1091 return;
1092 }
1093 if (id === 'focusSuggestedTab') {
1094 closeOnboardingWizardResume();
1095 switchHubMainTab('suggested');
1096 return;
1097 }
1098 }
1099
1100 onboardingRenderStep = function renderOnboardingStep() {
1101 const userKey = getOnboardingUserKey();
1102 const isHosted = wizardHostedFromContext();
1103 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1104 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1105 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1106 st = mod.createFreshState(userKey, hostingPath);
1107 }
1108 const total = mod.getStepCount(isHosted);
1109 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1110 const content = mod.getStepContent(isHosted, idx);
1111 if (body) body.innerHTML = content ? content.bodyHtml : '';
1112 if (content && content.id === 'h-imports' && body) {
1113 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1114 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1115 }
1116
1117 if (progress) {
1118 progress.innerHTML = '';
1119 for (let i = 0; i < total; i++) {
1120 const d = document.createElement('span');
1121 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1122 d.title = 'Step ' + (i + 1) + ' of ' + total;
1123 progress.appendChild(d);
1124 }
1125 }
1126 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1127
1128 if (btnBack) btnBack.disabled = idx <= 0;
1129 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1130
1131 if (secondary) {
1132 secondary.innerHTML = '';
1133 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1134 const b = document.createElement('button');
1135 b.type = 'button';
1136 b.className = 'btn-link btn-link-small';
1137 b.textContent = a.label;
1138 b.addEventListener('click', () => handleSecondaryAction(a.id));
1139 secondary.appendChild(b);
1140 });
1141 }
1142 };
1143
1144 if (btnBack) {
1145 btnBack.addEventListener('click', () => {
1146 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1147 if (!st || st.status !== 'in_progress') return;
1148 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1149 onboardingRenderStep();
1150 });
1151 }
1152 if (btnNext) {
1153 btnNext.addEventListener('click', () => {
1154 const userKey = getOnboardingUserKey();
1155 const isHosted = wizardHostedFromContext();
1156 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1157 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1158 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1159 const total = mod.getStepCount(isHosted);
1160 if (st.stepIndex >= total - 1) {
1161 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1162 if (modal) modal.classList.add('hidden');
1163 return;
1164 }
1165 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1166 onboardingRenderStep();
1167 });
1168 }
1169 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1170 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1171 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1172
1173 modal.addEventListener('click', (ev) => {
1174 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1175 if (!copyBtn || !body) return;
1176 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1177 const txt = ta && ta.value ? String(ta.value) : '';
1178 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1179 ev.preventDefault();
1180 void navigator.clipboard.writeText(txt).then(() => {
1181 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1182 });
1183 });
1184 }
1185
1186 async function openOnboardingWizard(opts) {
1187 const restart = opts && opts.restart;
1188 const mod = await loadOnboardingModule();
1189 bindOnboardingWizardOnce(mod);
1190 const userKey = getOnboardingUserKey();
1191 const isHosted = wizardHostedFromContext();
1192 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1193 if (restart) {
1194 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1195 } else {
1196 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1197 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1198 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1199 }
1200 }
1201 const modal = el('modal-onboarding');
1202 if (modal) modal.classList.remove('hidden');
1203 onboardingRenderStep();
1204 const btnNext = el('btn-onboarding-next');
1205 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1206 }
1207
1208 async function scheduleMaybeShowOnboardingWizard(s) {
1209 if (!token) return;
1210 if (params.get('open') === 'billing') return;
1211 try {
1212 const mod = await loadOnboardingModule();
1213 const userKey = getOnboardingUserKey();
1214 const isHosted = wizardHostedFromContext(s);
1215 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1216 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1217 if (st && st.userKey !== userKey) st = null;
1218 if (st && st.hostingPath !== hostingPath) st = null;
1219 if (!mod.shouldAutoOpenWizard(st, userKey, hostingPath)) return;
1220 const delayMs = pageLoadHadInviteQuery ? 2200 : 0;
1221 setTimeout(() => {
1222 void openOnboardingWizard({ restart: false });
1223 }, delayMs);
1224 } catch (_) {}
1225 }
1226
1227 function showMain() {
1228 if (app) app.classList.remove('login-screen');
1229 loginRequired.classList.add('hidden');
1230 main.classList.remove('hidden');
1231 btnHowToUse.classList.remove('hidden');
1232 if (btnSettings) btnSettings.classList.remove('hidden');
1233 browseToolbar.classList.remove('hidden');
1234 if (token) {
1235 btnLoginGoogle.classList.add('hidden');
1236 btnLoginGithub.classList.add('hidden');
1237 oauthNotConfigured.classList.add('hidden');
1238 btnLogout.classList.remove('hidden');
1239 try {
1240 const payload = JSON.parse(atob(token.split('.')[1]));
1241 userName.textContent = payload.name || payload.sub || 'Logged in';
1242 window.__hubUserRole = payload.role || 'member';
1243 const isViewer = window.__hubUserRole === 'viewer';
1244 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1245 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1246 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1247 refreshDeleteProjectPanelVisibility();
1248 } catch (_) {
1249 userName.textContent = 'Logged in';
1250 window.__hubUserRole = 'member';
1251 if (btnNewNote) btnNewNote.classList.remove('hidden');
1252 if (btnImport) btnImport.classList.remove('hidden');
1253 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1254 refreshDeleteProjectPanelVisibility();
1255 }
1256 } else {
1257 if (btnNewNote) btnNewNote.classList.add('hidden');
1258 if (btnImport) btnImport.classList.add('hidden');
1259 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1260 }
1261 hubRefreshIndexStaleBanner();
1262 }
1263
1264 function loginUrl(provider) {
1265 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1266 const invite = params.get('invite');
1267 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1268 }
1269 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1270 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1271 // because a direct window.location.href navigation has no retry mechanism.
1272 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1273 // guaranteed warm, and the OAuth redirect hits a hot instance.
1274 async function oauthNavigate(provider, btn) {
1275 const original = btn.textContent;
1276 btn.disabled = true;
1277 btn.textContent = 'Connecting…';
1278 try {
1279 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1280 // during this time so the user knows something is happening.
1281 await fetch(apiBase + '/api/v1/auth/providers', {
1282 cache: 'no-store',
1283 signal: AbortSignal.timeout(22000),
1284 });
1285 } catch (_) {
1286 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1287 // OAuth handler itself has the full 26 s budget once TCP is established.
1288 }
1289 window.location.href = loginUrl(provider);
1290 // Navigation is underway; restore button state in case the browser returns here.
1291 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1292 }
1293 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1294 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1295
1296 btnLogout.onclick = () => {
1297 // Revoke the refresh token server-side (real logout), then clear local state regardless
1298 // of whether the network call succeeds.
1299 try {
1300 fetch(apiBase + '/api/v1/auth/logout', {
1301 method: 'POST',
1302 credentials: 'include',
1303 cache: 'no-store',
1304 headers: { 'Content-Type': 'application/json' },
1305 }).catch(() => {});
1306 } catch (_) { /* best effort */ }
1307 token = null;
1308 localStorage.removeItem('hub_token');
1309 if (app) app.classList.add('login-screen');
1310 main.classList.add('hidden');
1311 browseToolbar.classList.add('hidden');
1312 btnNewNote.classList.add('hidden');
1313 if (btnImport) btnImport.classList.add('hidden');
1314 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1315 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1316 if (btnSettings) btnSettings.classList.add('hidden');
1317 closeOnboardingWizardResume();
1318 loginRequired.classList.remove('hidden');
1319 if (loginIntro) loginIntro.classList.remove('hidden');
1320 showLoginChrome();
1321 };
1322
1323 async function initProviders() {
1324 for (let attempt = 0; attempt < 3; attempt++) {
1325 try {
1326 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1327 if (!r.ok) throw new Error('providers');
1328 providers = await r.json();
1329 break;
1330 } catch (_) {
1331 if (attempt < 2) {
1332 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1333 continue;
1334 }
1335 providers = { google: false, github: false };
1336 oauthNotConfigured.classList.remove('hidden');
1337 if (loginIntro) loginIntro.classList.add('hidden');
1338 const first = oauthNotConfigured.querySelector('p');
1339 if (first) {
1340 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1341 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1342 if (isHosted && sameOrigin) {
1343 first.innerHTML =
1344 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1345 '</code> is calling itself for the API, but the API runs on the <strong>gateway</strong>. Set <code>window.HUB_API_BASE_URL</code> in <code>web/hub/config.js</code> to your gateway URL (e.g. <code>https://knowtation-gateway.netlify.app</code>), then commit and redeploy so 4Everland serves the updated config.';
1346 } else if (isHosted && !sameOrigin) {
1347 first.innerHTML =
1348 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1349 '</code> is deployed and allows this site (check <strong>HUB_CORS_ORIGIN</strong> includes <code>' + escapeHtml(location.origin) + '</code>). If the gateway is still deploying on Netlify, wait a few minutes and refresh.';
1350 } else {
1351 first.innerHTML =
1352 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1353 escapeHtml(apiBase) +
1354 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1355 }
1356 }
1357 return;
1358 }
1359 }
1360
1361 if (!providers.google && !providers.github) {
1362 oauthNotConfigured.classList.remove('hidden');
1363 if (loginIntro) loginIntro.classList.add('hidden');
1364 } else {
1365 oauthNotConfigured.classList.add('hidden');
1366 if (loginIntro) loginIntro.classList.remove('hidden');
1367 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1368 const loggedIn =
1369 Boolean(token) ||
1370 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1371 if (!loggedIn) {
1372 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1373 if (providers.github) btnLoginGithub.classList.remove('hidden');
1374 }
1375 }
1376 }
1377
1378 if (token) {
1379 if (params.get('invite')) {
1380 (async () => {
1381 const inviteToken = params.get('invite');
1382 let lastErr;
1383 for (let attempt = 0; attempt < 3; attempt++) {
1384 try {
1385 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1386 const u = new URL(location.href);
1387 u.searchParams.delete('invite');
1388 u.searchParams.set('invite_accepted', '1');
1389 history.replaceState({}, '', u.toString());
1390 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1391 return;
1392 } catch (e) {
1393 lastErr = e;
1394 const code = e && e.code;
1395 const msg = String(e && e.message ? e.message : e || '');
1396 const staleInvite =
1397 code === 'NOT_FOUND' ||
1398 code === 'EXPIRED' ||
1399 /not found|already used|expired/i.test(msg);
1400 if (staleInvite) {
1401 const u = new URL(location.href);
1402 u.searchParams.delete('invite');
1403 history.replaceState({}, '', u.toString());
1404 if (code === 'EXPIRED' && typeof showToast === 'function') {
1405 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
1406 }
1407 return;
1408 }
1409 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
1410 }
1411 }
1412 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
1413 })();
1414 }
1415 showMain();
1416 getImageProxyToken().catch(function () {});
1417 (async function ensureVaultAndSwitcherThenLoad() {
1418 let settingsPayload = null;
1419 try {
1420 settingsPayload = await api('/api/v1/settings');
1421 applySettingsPayloadToHubChrome(settingsPayload);
1422 } catch (_) {}
1423 syncHubListSortUI('notes');
1424 setProposalFiltersBarVisible(false);
1425 refreshNewProposalTabVisibility();
1426 loadFacets();
1427 loadNotes();
1428 loadProposals();
1429 loadActivity();
1430 renderPresets();
1431 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
1432 })();
1433 initProviders();
1434 if (params.get('open') === 'billing') {
1435 const checkoutSuccess = params.get('checkout') === 'success';
1436 // Clean up params before opening so back-button doesn't re-trigger.
1437 const u = new URL(location.href);
1438 u.searchParams.delete('open');
1439 u.searchParams.delete('checkout');
1440 history.replaceState({}, '', u.toString());
1441 // Small delay so the main Hub has rendered before the modal opens.
1442 setTimeout(() => {
1443 openSettingsBillingTab();
1444 if (checkoutSuccess && typeof showToast === 'function') {
1445 showToast('Subscription activated — welcome to your new plan!');
1446 }
1447 }, 400);
1448 }
1449 if (params.get('github_connected') === '1') {
1450 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
1451 setTimeout(() => {
1452 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
1453 const u = new URL(location.href);
1454 u.searchParams.delete('github_connected');
1455 history.replaceState({}, '', u.toString());
1456 }, 500);
1457 } else if (params.get('github_connect_error')) {
1458 setTimeout(() => {
1459 const code = params.get('github_connect_error');
1460 const msg =
1461 code === 'blob_storage'
1462 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
1463 : 'GitHub connect: ' + code;
1464 if (typeof showToast === 'function') showToast(msg, true);
1465 const u = new URL(location.href);
1466 u.searchParams.delete('github_connect_error');
1467 history.replaceState({}, '', u.toString());
1468 }, 500);
1469 }
1470 } else {
1471 if (app) app.classList.add('login-screen');
1472 main.classList.add('hidden');
1473 loginRequired.classList.remove('hidden');
1474 btnNewNote.classList.add('hidden');
1475 if (btnImport) btnImport.classList.add('hidden');
1476 const inviteBanner = el('login-invite-banner');
1477 if (inviteBanner && params.get('invite')) {
1478 inviteBanner.textContent = "You've been invited. Sign in to join.";
1479 inviteBanner.classList.remove('hidden');
1480 }
1481 initProviders();
1482 }
1483 refreshApiBaseFootgunBanner();
1484 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
1485 setTimeout(() => {
1486 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1487 const u = new URL(location.href);
1488 u.searchParams.delete('invite_accepted');
1489 history.replaceState({}, '', u.pathname + u.search);
1490 }, 500);
1491 }
1492
1493 function dateSlice(d) {
1494 if (!d || typeof d !== 'string') return '';
1495 return d.trim().slice(0, 10);
1496 }
1497
1498 /** Hosted canister returns frontmatter as a JSON string; self-hosted often uses an object. List metadata (date, title, …) is flattened on self-hosted list responses — mirror that here. Keep in sync with lib/parse-frontmatter-json.mjs. */
1499 function materializeFrontmatter(fm) {
1500 if (fm == null) return {};
1501 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
1502 if (typeof fm === 'string') {
1503 let cur = fm.replace(/^\uFEFF/, '').trim();
1504 if (!cur) return {};
1505 for (let i = 0; i < 8; i++) {
1506 try {
1507 const o = JSON.parse(cur);
1508 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
1509 if (typeof o === 'string') {
1510 const next = o.trim();
1511 if (next === cur) return {};
1512 cur = next;
1513 continue;
1514 }
1515 return {};
1516 } catch {
1517 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
1518 try {
1519 const inner = JSON.parse(cur);
1520 if (typeof inner === 'string') {
1521 cur = inner.trim();
1522 continue;
1523 }
1524 } catch {
1525 /* fall through */
1526 }
1527 }
1528 return {};
1529 }
1530 }
1531 return {};
1532 }
1533 return {};
1534 }
1535
1536 function tagsFromFrontmatter(fm) {
1537 const raw = fm && fm.tags;
1538 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1539 if (typeof raw === 'string' && raw.trim()) {
1540 return raw
1541 .split(/[,\n]/)
1542 .map((s) => s.trim())
1543 .filter(Boolean);
1544 }
1545 return [];
1546 }
1547
1548 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
1549 function isoDateLocalFromMs(ms) {
1550 const d = new Date(ms);
1551 if (Number.isNaN(d.getTime())) return null;
1552 const y = d.getFullYear();
1553 const mo = String(d.getMonth() + 1).padStart(2, '0');
1554 const day = String(d.getDate()).padStart(2, '0');
1555 return y + '-' + mo + '-' + day;
1556 }
1557
1558 /**
1559 * Calendar bucket for Hub list/calendar/overview.
1560 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
1561 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
1562 */
1563 function calendarDisplayDayKey(raw) {
1564 if (raw == null) return null;
1565 const s = String(raw).trim();
1566 if (!s) return null;
1567 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
1568 const ms = Date.parse(s);
1569 if (Number.isNaN(ms)) return s.slice(0, 10);
1570 return isoDateLocalFromMs(ms);
1571 }
1572
1573 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
1574 function inferredDisplayDateFromNotePath(notePath) {
1575 if (!notePath || typeof notePath !== 'string') return null;
1576 const base = notePath.split('/').pop() || '';
1577 const m = /^note-(\d{10,})\.md$/i.exec(base);
1578 if (!m) return null;
1579 const ms = Number(m[1]);
1580 if (!Number.isFinite(ms)) return null;
1581 return isoDateLocalFromMs(ms);
1582 }
1583
1584 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
1585 function listItemDisplayDate(n, fm) {
1586 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
1587 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
1588 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
1589 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
1590 const inferred = inferredDisplayDateFromNotePath(n.path);
1591 return inferred || null;
1592 }
1593
1594 function noteSortOrCalendarDay(n) {
1595 const raw = n.date || n.updated || '';
1596 return calendarDisplayDayKey(raw) || dateSlice(raw);
1597 }
1598
1599 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
1600 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
1601 const HUB_SORT_NOTES_OPTS = [
1602 { v: 'date_desc', l: 'Newest first' },
1603 { v: 'date_asc', l: 'Oldest first' },
1604 { v: 'year_desc', l: 'Year (newest first)' },
1605 { v: 'year_asc', l: 'Year (oldest first)' },
1606 { v: 'path_asc', l: 'Path A–Z' },
1607 { v: 'title_asc', l: 'Title A–Z' },
1608 ];
1609 const HUB_SORT_PROP_OPTS = [
1610 { v: 'updated_desc', l: 'Newest first' },
1611 { v: 'updated_asc', l: 'Oldest first' },
1612 { v: 'path_asc', l: 'Path A–Z' },
1613 { v: 'status_asc', l: 'Status A–Z' },
1614 ];
1615
1616 function hubListSortGetSelect() {
1617 return el('hub-list-sort');
1618 }
1619
1620 function syncHubListSortUI(activeTab) {
1621 const sel = hubListSortGetSelect();
1622 if (!sel) return;
1623 const isNotes = activeTab === 'notes';
1624 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
1625 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
1626 let saved = '';
1627 try {
1628 saved = localStorage.getItem(key) || '';
1629 } catch (_) {}
1630 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
1631 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
1632 sel.value = saved;
1633 }
1634
1635 function setProposalFiltersBarVisible(show) {
1636 const bar = el('proposal-filters-bar');
1637 if (bar) bar.classList.toggle('hidden', !show);
1638 }
1639
1640 function refreshNewProposalTabVisibility() {
1641 const btn = el('btn-new-proposal');
1642 if (!btn) return;
1643 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1644 const show = tab === 'suggested' && hubUserCanWriteNotes();
1645 btn.classList.toggle('hidden', !show);
1646 }
1647
1648 function applySortedNotesClient(notes) {
1649 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
1650 if (tab !== 'notes') return notes;
1651 const S = globalThis.HubListSort;
1652 const sel = hubListSortGetSelect();
1653 const mode = sel && sel.value ? sel.value : 'date_desc';
1654 if (!S || typeof S.sortNotesList !== 'function') return notes;
1655 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
1656 }
1657
1658 function applySortedProposalsClient(list) {
1659 const S = globalThis.HubListSort;
1660 const sel = hubListSortGetSelect();
1661 const mode = sel && sel.value ? sel.value : 'updated_desc';
1662 if (!S || typeof S.sortProposalsList !== 'function') return list;
1663 return S.sortProposalsList(list, mode);
1664 }
1665
1666 function normalizeHubListItem(n) {
1667 if (!n || typeof n !== 'object') return n;
1668 const fm = materializeFrontmatter(n.frontmatter);
1669 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
1670 const displayDate = listItemDisplayDate(n, fm);
1671 const updated =
1672 n.updated != null
1673 ? String(n.updated)
1674 : fm.knowtation_edited_at != null
1675 ? String(fm.knowtation_edited_at)
1676 : null;
1677 return {
1678 ...n,
1679 frontmatter: fm,
1680 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
1681 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
1682 tags,
1683 date: displayDate,
1684 updated,
1685 };
1686 }
1687
1688 function facetsAreEmpty(f) {
1689 if (!f || typeof f !== 'object') return true;
1690 const pl = f.projects && f.projects.length;
1691 const tl = f.tags && f.tags.length;
1692 const fl = f.folders && f.folders.length;
1693 return !pl && !tl && !fl;
1694 }
1695
1696 async function deriveFacetsFromNotes() {
1697 const out = await api('/api/v1/notes?limit=500&offset=0');
1698 const projects = new Set();
1699 const tags = new Set();
1700 const folders = new Set();
1701 for (const raw of out.notes || []) {
1702 const n = normalizeHubListItem(raw);
1703 if (n.path) {
1704 const seg = String(n.path).split('/')[0];
1705 if (seg) folders.add(seg);
1706 }
1707 if (n.project) projects.add(String(n.project));
1708 (n.tags || []).forEach((t) => tags.add(String(t)));
1709 }
1710 return {
1711 projects: [...projects].sort((a, b) => a.localeCompare(b)),
1712 tags: [...tags].sort((a, b) => a.localeCompare(b)),
1713 folders: [...folders].sort((a, b) => a.localeCompare(b)),
1714 };
1715 }
1716
1717 async function fetchFacetsResolved() {
1718 let facets = await api('/api/v1/notes/facets');
1719 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
1720 return facets;
1721 }
1722
1723 function hubRowIsApprovalLog(n) {
1724 if (!n || !n.path) return false;
1725 const path = String(n.path).replace(/\\/g, '/');
1726 if (path === 'approvals' || path.startsWith('approvals/')) return true;
1727 const k =
1728 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
1729 return String(k) === 'approval_log';
1730 }
1731
1732 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
1733 function applyVaultListFilters(notes, opts) {
1734 let out = notes.slice();
1735 if (opts.folder) {
1736 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
1737 const prefix = f + '/';
1738 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
1739 }
1740 if (opts.project) {
1741 const p = normSlug(opts.project);
1742 out = out.filter(
1743 (n) =>
1744 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
1745 );
1746 }
1747 if (opts.tag) {
1748 const t = normSlug(opts.tag);
1749 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
1750 }
1751 if (opts.since) {
1752 const s = dateSlice(opts.since);
1753 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
1754 }
1755 if (opts.until) {
1756 const u = dateSlice(opts.until);
1757 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
1758 }
1759 const cs = opts.content_scope;
1760 if (cs === 'notes') {
1761 out = out.filter((n) => !hubRowIsApprovalLog(n));
1762 } else if (cs === 'approval_logs') {
1763 out = out.filter((n) => hubRowIsApprovalLog(n));
1764 }
1765 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
1766 if (opts.network) {
1767 const net = String(opts.network).trim().toLowerCase();
1768 out = out.filter((n) => {
1769 const v = n.frontmatter?.network ?? n.network;
1770 return v != null && String(v).trim().toLowerCase() === net;
1771 });
1772 }
1773 if (opts.wallet_address) {
1774 const wa = String(opts.wallet_address).trim().toLowerCase();
1775 out = out.filter((n) => {
1776 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
1777 return v != null && String(v).trim().toLowerCase() === wa;
1778 });
1779 }
1780 if (opts.payment_status) {
1781 const ps = String(opts.payment_status).trim().toLowerCase();
1782 out = out.filter((n) => {
1783 const v = n.frontmatter?.payment_status ?? n.payment_status;
1784 return v != null && String(v).trim().toLowerCase() === ps;
1785 });
1786 }
1787 return out;
1788 }
1789
1790 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
1791 const HUB_RESERVED_FM_KEYS = new Set([
1792 'knowtation_editor',
1793 'knowtation_edited_at',
1794 'author_kind',
1795 'knowtation_proposed_by',
1796 'knowtation_approved_by',
1797 ]);
1798
1799 function stripReservedHubFm(fm) {
1800 const out = {};
1801 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
1802 for (const [k, v] of Object.entries(fm)) {
1803 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
1804 out[k] = v;
1805 }
1806 return out;
1807 }
1808
1809 /**
1810 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
1811 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
1812 */
1813 function stringifyNotePostPayload(path, body, frontmatter) {
1814 const fmStr =
1815 typeof frontmatter === 'string'
1816 ? frontmatter
1817 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
1818 return JSON.stringify({ path, body, frontmatter: fmStr });
1819 }
1820
1821 const DETAIL_EDIT_FM_KEYS = [
1822 'title',
1823 'date',
1824 'project',
1825 'tags',
1826 'causal_chain_id',
1827 'entity',
1828 'episode_id',
1829 'follows',
1830 ];
1831
1832 function mergedFrontmatterForDetailSave() {
1833 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
1834 const preserved = {};
1835 for (const [k, v] of Object.entries(base)) {
1836 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
1837 }
1838 const dateVal =
1839 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
1840 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
1841 const tTitle = title.trim();
1842 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1843 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
1844 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
1845 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
1846 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
1847 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
1848 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
1849 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
1850 const follows = followsRaw
1851 ? followsRaw.includes(',')
1852 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
1853 : followsRaw
1854 : undefined;
1855 const out = { ...preserved, date: dateVal };
1856 if (tTitle) out.title = tTitle;
1857 else delete out.title;
1858 if (project) out.project = project;
1859 else delete out.project;
1860 if (tags) out.tags = tags;
1861 else delete out.tags;
1862 if (causalChain) out.causal_chain_id = causalChain;
1863 else delete out.causal_chain_id;
1864 if (entity.length) out.entity = entity;
1865 else delete out.entity;
1866 if (episode) out.episode_id = episode;
1867 else delete out.episode_id;
1868 if (follows) out.follows = follows;
1869 else delete out.follows;
1870 return out;
1871 }
1872
1873 function fillDetailEditFieldsFromFrontmatter(fm) {
1874 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
1875 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
1876 const savedProj = f.project != null ? String(f.project).trim() : '';
1877 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
1878 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
1879 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
1880 if (el('detail-edit-project')) {
1881 const inp = el('detail-edit-project');
1882 if (pathProj) {
1883 inp.value = pathProj;
1884 inp.readOnly = true;
1885 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
1886 } else {
1887 inp.readOnly = false;
1888 inp.title = '';
1889 inp.value = savedProj;
1890 }
1891 }
1892 const hint = el('detail-edit-project-hint');
1893 if (hint) {
1894 if (pathProj) {
1895 hint.classList.remove('hidden');
1896 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
1897 hint.textContent = mismatch
1898 ? 'Path implies project «' +
1899 pathProj +
1900 '»; saved frontmatter had «' +
1901 savedProj +
1902 '». Saving will store «' +
1903 pathProj +
1904 '» to match the path.'
1905 : 'Project slug matches vault path projects/' + pathProj + '/.';
1906 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
1907 } else {
1908 hint.classList.remove('hidden');
1909 hint.className = 'muted small detail-project-hint';
1910 hint.textContent =
1911 'Optional frontmatter label for filters and charts. It does not have to match the file path. If you use a path like projects/your-slug/…, the Hub keeps this field aligned with that folder name.';
1912 }
1913 }
1914 const pathTypoEl = el('detail-edit-path-typo-hint');
1915 if (pathTypoEl && currentOpenNote) {
1916 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
1917 if (sug) {
1918 pathTypoEl.textContent =
1919 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
1920 sug +
1921 '. Rename or move the file in your vault (path cannot be edited here).';
1922 pathTypoEl.className = 'muted small detail-project-hint warn';
1923 pathTypoEl.classList.remove('hidden');
1924 } else {
1925 pathTypoEl.textContent = '';
1926 pathTypoEl.className = 'muted small detail-project-hint hidden';
1927 pathTypoEl.classList.add('hidden');
1928 }
1929 }
1930 const tags = f.tags;
1931 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
1932 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
1933 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
1934 const ent = f.entity;
1935 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
1936 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
1937 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
1938 const fol = f.follows;
1939 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
1940 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
1941 }
1942
1943 async function loadFacets() {
1944 try {
1945 const savedProject = filterProject.value;
1946 const savedTag = filterTag.value;
1947 const savedFolder = filterFolder.value;
1948 const savedNetwork = filterNetwork ? filterNetwork.value : '';
1949 const savedWallet = filterWallet ? filterWallet.value : '';
1950 const facets = await fetchFacetsResolved();
1951 lastHubFacets = facets;
1952 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
1953 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
1954 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
1955 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
1956 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
1957 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
1958 // Phase 12 — blockchain filter dropdowns (hidden when no data)
1959 if (filterNetwork) {
1960 const nets = facets.networks || [];
1961 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
1962 filterNetwork.classList.toggle('hidden', nets.length === 0);
1963 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
1964 }
1965 if (filterWallet) {
1966 const wallets = facets.wallets || [];
1967 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
1968 filterWallet.classList.toggle('hidden', wallets.length === 0);
1969 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
1970 }
1971 renderFilterChips(facets);
1972 hydrateFullCreateProjectSlugSelect(facets);
1973 hydrateImportCreateProjectSlugSelect(facets);
1974 } catch (_) {
1975 renderFilterChips(null);
1976 lastHubFacets = null;
1977 hydrateFullCreateProjectSlugSelect(null);
1978 hydrateImportCreateProjectSlugSelect(null);
1979 }
1980 }
1981
1982 function normSlug(s) {
1983 return String(s || '')
1984 .toLowerCase()
1985 .replace(/[^a-z0-9-]/g, '-')
1986 .replace(/-+/g, '-')
1987 .replace(/^-|-$/g, '');
1988 }
1989
1990 /**
1991 * First path segment after `projects/` (vault-relative). Used so project frontmatter
1992 * stays aligned with on-disk layout (projects/<slug>/…).
1993 */
1994 function projectSlugFromProjectsPath(path) {
1995 if (!path || typeof path !== 'string') return null;
1996 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
1997 return m ? m[1] : null;
1998 }
1999
2000 /**
2001 * Common typo: vault path starts with `project/` instead of `projects/`.
2002 * Returns the same path with the corrected prefix, or null if no typo.
2003 */
2004 function projectsPathTypoSuggestion(path) {
2005 const p = String(path || '').trim();
2006 if (!p) return null;
2007 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2008 return null;
2009 }
2010
2011 function normalizeProjectKeyForSimilarity(s) {
2012 return String(s || '')
2013 .toLowerCase()
2014 .trim()
2015 .replace(/[\s_]+/g, '-')
2016 .replace(/-+/g, '-')
2017 .replace(/^-|-$/g, '');
2018 }
2019
2020 function levenshteinHub(a, b) {
2021 const m = a.length;
2022 const n = b.length;
2023 if (!m) return n;
2024 if (!n) return m;
2025 const row = new Array(n + 1);
2026 for (let j = 0; j <= n; j++) row[j] = j;
2027 for (let i = 1; i <= m; i++) {
2028 let prev = row[0];
2029 row[0] = i;
2030 for (let j = 1; j <= n; j++) {
2031 const cur = row[j];
2032 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2033 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2034 prev = cur;
2035 }
2036 }
2037 return row[n];
2038 }
2039
2040 /**
2041 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2042 * Exact normSlug match returns null (no warning).
2043 */
2044 function findSimilarFacetProject(userSlug, projectsArr) {
2045 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2046 const uNorm = normSlug(String(userSlug));
2047 if (!uNorm) return null;
2048 for (const p of projectsArr) {
2049 if (normSlug(String(p)) === uNorm) return null;
2050 }
2051 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2052 let best = null;
2053 let bestScore = Infinity;
2054 for (const p of projectsArr) {
2055 const pv = String(p).trim();
2056 if (!pv) continue;
2057 const pNorm = normSlug(pv);
2058 if (!pNorm) continue;
2059 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2060 let score = Infinity;
2061 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2062 if (score > 0) {
2063 const a = normalizeProjectKeyForSimilarity(userSlug);
2064 const b = normalizeProjectKeyForSimilarity(pv);
2065 const d = levenshteinHub(a, b);
2066 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2067 }
2068 if (score > 0) {
2069 const a = normalizeProjectKeyForSimilarity(userSlug);
2070 const b = normalizeProjectKeyForSimilarity(pv);
2071 const shorter = a.length <= b.length ? a : b;
2072 const longer = a.length <= b.length ? b : a;
2073 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2074 score = Math.min(score, longer.length - shorter.length + 0.5);
2075 }
2076 }
2077 if (score < bestScore) {
2078 bestScore = score;
2079 best = pv;
2080 }
2081 }
2082 return bestScore < 10 ? best : null;
2083 }
2084
2085 function collectProjectSubroots(slug, folderStrings) {
2086 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2087 const subs = new Set();
2088 for (const f of folderStrings || []) {
2089 if (!f || typeof f !== 'string') continue;
2090 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2091 if (!n.startsWith(prefix)) continue;
2092 const rest = n.slice(prefix.length);
2093 if (!rest) continue;
2094 const first = rest.split('/')[0];
2095 if (first) subs.add(first);
2096 }
2097 return [...subs].sort((a, b) => a.localeCompare(b));
2098 }
2099
2100 function fullCreatePathFilename(pathVal) {
2101 const t = String(pathVal || '').trim();
2102 const parts = t.split('/').filter(Boolean);
2103 const last = parts[parts.length - 1];
2104 if (last && /\.md$/i.test(last)) return last;
2105 return 'note-' + Date.now() + '.md';
2106 }
2107
2108 function mergeFolderStringsForSubroots() {
2109 const out = new Set();
2110 for (const f of lastVaultFoldersForCreate || []) {
2111 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2112 }
2113 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2114 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2115 }
2116 return [...out];
2117 }
2118
2119 function updateFullCreatePathLayoutVisibility() {
2120 const slugSel = el('full-create-project-slug');
2121 const subWrap = el('full-create-project-subroot-wrap');
2122 const nonProj = el('full-create-nonproject-folder-wrap');
2123 const subSel = el('full-create-project-subroot');
2124 if (!slugSel) return;
2125 const v = slugSel.value;
2126 const useProject = v && v !== '__custom__';
2127 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2128 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2129 if (subSel) subSel.disabled = !useProject;
2130 }
2131
2132 function refreshFullCreateSubrootSelect() {
2133 const slugSel = el('full-create-project-slug');
2134 const subSel = el('full-create-project-subroot');
2135 if (!slugSel || !subSel) return;
2136 const slug = slugSel.value;
2137 const preserve = subSel.value;
2138 if (!slug || slug === '__custom__') {
2139 subSel.innerHTML = '';
2140 subSel.disabled = true;
2141 return;
2142 }
2143 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2144 const head = document.createElement('option');
2145 head.value = '';
2146 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2147 subSel.innerHTML = '';
2148 subSel.appendChild(head);
2149 for (const s of subs) {
2150 const o = document.createElement('option');
2151 o.value = s;
2152 o.textContent = s;
2153 subSel.appendChild(o);
2154 }
2155 const custom = document.createElement('option');
2156 custom.value = '__custom_sub__';
2157 custom.textContent = 'Custom (edit path)';
2158 subSel.appendChild(custom);
2159 subSel.disabled = false;
2160 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2161 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2162 else if (subs.includes('inbox')) subSel.value = 'inbox';
2163 else if (subs.length === 1) subSel.value = subs[0];
2164 else subSel.value = '';
2165 }
2166
2167 function composeFullPathFromCreatePickers() {
2168 const slugSel = el('full-create-project-slug');
2169 const subSel = el('full-create-project-subroot');
2170 const pathInp = el('full-path');
2171 if (!slugSel || !pathInp) return;
2172 const slugVal = slugSel.value;
2173 if (!slugVal || slugVal === '__custom__') return;
2174 if (subSel && subSel.value === '__custom_sub__') return;
2175 const sub =
2176 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2177 const fname = fullCreatePathFilename(pathInp.value);
2178 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2179 pathInp.value = base;
2180 }
2181
2182 function syncFullCreatePickersFromPath() {
2183 const slugSel = el('full-create-project-slug');
2184 const subSel = el('full-create-project-subroot');
2185 const pathInp = el('full-path');
2186 if (!slugSel || !pathInp) return;
2187 const raw = pathInp.value.trim();
2188 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2189 if (!m) {
2190 slugSel.value = raw ? '__custom__' : '';
2191 refreshFullCreateSubrootSelect();
2192 updateFullCreatePathLayoutVisibility();
2193 return;
2194 }
2195 const diskSlug = m[1];
2196 const rest = m[2];
2197 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2198 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2199 if (match) slugSel.value = match;
2200 else slugSel.value = '__custom__';
2201 refreshFullCreateSubrootSelect();
2202 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2203 const segments = rest.split('/').filter(Boolean);
2204 const lastSeg = segments[segments.length - 1];
2205 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2206 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2207 const firstDir = dirParts[0] || '';
2208 const allowed = new Set(
2209 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2210 );
2211 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2212 else if (firstDir) subSel.value = '__custom_sub__';
2213 else subSel.value = '';
2214 }
2215 updateFullCreatePathLayoutVisibility();
2216 }
2217
2218 function hydrateFullCreateProjectSlugSelect(facets) {
2219 const sel = el('full-create-project-slug');
2220 if (!sel) return;
2221 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2222 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2223 const preserve = sel.value;
2224 sel.innerHTML =
2225 '<option value="">— Not under projects/ —</option>' +
2226 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2227 '<option value="__custom__">Custom (type full path)</option>';
2228 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2229 refreshFullCreateSubrootSelect();
2230 updateFullCreatePathLayoutVisibility();
2231 }
2232
2233 function updateImportPathLayoutVisibility() {
2234 const slugSel = el('import-create-project-slug');
2235 const subWrap = el('import-create-project-subroot-wrap');
2236 const nonProj = el('import-nonproject-folder-wrap');
2237 const subSel = el('import-create-project-subroot');
2238 if (!slugSel) return;
2239 const v = slugSel.value;
2240 const useProject = v && v !== '__custom__';
2241 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2242 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2243 if (subSel) subSel.disabled = !useProject;
2244 }
2245
2246 function refreshImportCreateSubrootSelect() {
2247 const slugSel = el('import-create-project-slug');
2248 const subSel = el('import-create-project-subroot');
2249 if (!slugSel || !subSel) return;
2250 const slug = slugSel.value;
2251 const preserve = subSel.value;
2252 if (!slug || slug === '__custom__') {
2253 subSel.innerHTML = '';
2254 subSel.disabled = true;
2255 return;
2256 }
2257 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2258 const head = document.createElement('option');
2259 head.value = '';
2260 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2261 subSel.innerHTML = '';
2262 subSel.appendChild(head);
2263 for (const s of subs) {
2264 const o = document.createElement('option');
2265 o.value = s;
2266 o.textContent = s;
2267 subSel.appendChild(o);
2268 }
2269 const custom = document.createElement('option');
2270 custom.value = '__custom_sub__';
2271 custom.textContent = 'Custom (edit path)';
2272 subSel.appendChild(custom);
2273 subSel.disabled = false;
2274 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2275 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2276 else if (subs.includes('inbox')) subSel.value = 'inbox';
2277 else if (subs.length === 1) subSel.value = subs[0];
2278 else subSel.value = '';
2279 }
2280
2281 function composeImportOutputDirFromPickers() {
2282 const slugSel = el('import-create-project-slug');
2283 const subSel = el('import-create-project-subroot');
2284 const outInp = el('import-output-dir');
2285 if (!slugSel || !outInp) return;
2286 const slugVal = slugSel.value;
2287 if (!slugVal || slugVal === '__custom__') return;
2288 if (subSel && subSel.value === '__custom_sub__') return;
2289 const sub =
2290 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2291 const subUse = sub || 'inbox';
2292 outInp.value = 'projects/' + slugVal + '/' + subUse;
2293 }
2294
2295 function syncImportPickersFromOutputDir() {
2296 const slugSel = el('import-create-project-slug');
2297 const subSel = el('import-create-project-subroot');
2298 const outInp = el('import-output-dir');
2299 if (!slugSel || !outInp) return;
2300 const raw = outInp.value.trim().replace(/\/+$/, '');
2301 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2302 if (!m) {
2303 slugSel.value = raw ? '__custom__' : '';
2304 refreshImportCreateSubrootSelect();
2305 updateImportPathLayoutVisibility();
2306 return;
2307 }
2308 const diskSlug = m[1];
2309 const rest = m[2] || '';
2310 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2311 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2312 if (match) slugSel.value = match;
2313 else slugSel.value = '__custom__';
2314 refreshImportCreateSubrootSelect();
2315 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2316 const segments = rest.split('/').filter(Boolean);
2317 const firstDir = segments[0] || '';
2318 const allowed = new Set(
2319 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2320 );
2321 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2322 else if (firstDir) subSel.value = '__custom_sub__';
2323 else subSel.value = '';
2324 }
2325 updateImportPathLayoutVisibility();
2326 }
2327
2328 function hydrateImportCreateProjectSlugSelect(facets) {
2329 const sel = el('import-create-project-slug');
2330 if (!sel) return;
2331 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2332 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2333 const preserve = sel.value;
2334 sel.innerHTML =
2335 '<option value="">— Not under projects/ —</option>' +
2336 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2337 '<option value="__custom__">Custom (type full path)</option>';
2338 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2339 refreshImportCreateSubrootSelect();
2340 updateImportPathLayoutVisibility();
2341 }
2342
2343 function syncImportFolderSelectToOutputDir() {
2344 const outInp = el('import-output-dir');
2345 const sel = el('import-vault-folder');
2346 if (!outInp || !sel) return;
2347 const p = outInp.value.trim().replace(/\/+$/, '');
2348 if (!p) return;
2349 let best = '__custom__';
2350 let bestLen = -1;
2351 for (const opt of sel.options) {
2352 const v = opt.value;
2353 if (v === '__custom__') continue;
2354 if (p === v || p.startsWith(v + '/')) {
2355 if (v.length > bestLen) {
2356 best = v;
2357 bestLen = v.length;
2358 }
2359 }
2360 }
2361 sel.value = bestLen >= 0 ? best : '__custom__';
2362 }
2363
2364 function defaultImportOutputDir() {
2365 const slugSel = el('import-create-project-slug');
2366 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2367 const subSel = el('import-create-project-subroot');
2368 const sub =
2369 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2370 const subUse = sub || 'inbox';
2371 return 'projects/' + slugSel.value + '/' + subUse;
2372 }
2373 const sel = el('import-vault-folder');
2374 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2375 return folder;
2376 }
2377
2378 function getImportProjectAndOutputDir() {
2379 const outInp = el('import-output-dir');
2380 const slugSel = el('import-create-project-slug');
2381 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2382 if (raw) {
2383 const sug = projectsPathTypoSuggestion(raw);
2384 if (sug) {
2385 return {
2386 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
2387 project: '',
2388 outputDir: undefined,
2389 };
2390 }
2391 }
2392 const outputDir = raw || undefined;
2393 let project = '';
2394 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2395 project = normSlug(slugSel.value);
2396 }
2397 if (!project && outputDir) {
2398 const m = outputDir.match(/^projects\/([^/]+)/);
2399 if (m) project = normSlug(m[1]);
2400 }
2401 return { err: null, project: project || '', outputDir: outputDir || undefined };
2402 }
2403
2404 function updateFullCreateSimilarInlineHint() {
2405 const hint = el('full-path-similar-hint');
2406 const btn = el('btn-full-path-use-similar-project');
2407 const pathInp = el('full-path');
2408 if (!hint || !pathInp) return;
2409 const notePath = pathInp.value.trim();
2410 const slug = projectSlugFromProjectsPath(notePath);
2411 const similar =
2412 slug && (lastHubFacets && lastHubFacets.projects)
2413 ? findSimilarFacetProject(slug, lastHubFacets.projects)
2414 : null;
2415 if (similar && notePath.startsWith('projects/')) {
2416 hint.textContent =
2417 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
2418 hint.className = 'muted small detail-project-hint warn';
2419 hint.classList.remove('hidden');
2420 if (btn) {
2421 btn.classList.remove('hidden');
2422 btn.onclick = () => {
2423 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
2424 pathInp.value = fixed;
2425 syncFolderSelectToPathInput();
2426 syncFullCreatePickersFromPath();
2427 syncFullProjectFromPath();
2428 updateFullPathProjectTypoHint();
2429 updateFullCreateSimilarInlineHint();
2430 };
2431 }
2432 } else {
2433 hint.textContent = '';
2434 hint.className = 'muted small detail-project-hint hidden';
2435 hint.classList.add('hidden');
2436 if (btn) {
2437 btn.classList.add('hidden');
2438 btn.onclick = null;
2439 }
2440 }
2441 }
2442
2443 function scheduleFullCreateSimilarHint() {
2444 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
2445 fullPathSimilarDebounceTimer = window.setTimeout(() => {
2446 fullPathSimilarDebounceTimer = 0;
2447 updateFullCreateSimilarInlineHint();
2448 }, 220);
2449 }
2450
2451 function openFullCreateSimilarModal(notePath, suggestedSlug) {
2452 const modal = el('modal-create-similar-project');
2453 const body = el('modal-create-similar-project-body');
2454 if (!modal || !body) return;
2455 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
2456 fullCreateSimilarModalPendingPath = notePath;
2457 const bad = projectSlugFromProjectsPath(notePath) || '…';
2458 body.textContent =
2459 'Your path starts with projects/' +
2460 bad +
2461 '/ but an existing project slug is «' +
2462 suggestedSlug +
2463 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
2464 modal.classList.remove('hidden');
2465 const focusBtn = el('btn-modal-create-similar-use-existing');
2466 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
2467 }
2468
2469 function closeFullCreateSimilarModal() {
2470 const modal = el('modal-create-similar-project');
2471 if (modal) modal.classList.add('hidden');
2472 fullCreateSimilarModalSuggestedSlug = '';
2473 fullCreateSimilarModalPendingPath = '';
2474 }
2475
2476 /** True when any list filter used by loadNotes / Quick chips is set. */
2477 function listFacetFiltersActive() {
2478 if (filterProject.value) return true;
2479 if (filterTag.value) return true;
2480 if (filterFolder.value) return true;
2481 if (filterNetwork && filterNetwork.value) return true;
2482 if (filterWallet && filterWallet.value) return true;
2483 const fps = el('filter-payment-status');
2484 if (fps && fps.value) return true;
2485 if (filterSince && filterSince.value) return true;
2486 if (filterUntil && filterUntil.value) return true;
2487 if (filterContentScope && filterContentScope.value) return true;
2488 return false;
2489 }
2490
2491 function clearListFacetFilters() {
2492 filterProject.value = '';
2493 filterTag.value = '';
2494 filterFolder.value = '';
2495 if (filterNetwork) filterNetwork.value = '';
2496 if (filterWallet) filterWallet.value = '';
2497 const fps = el('filter-payment-status');
2498 if (fps) fps.value = '';
2499 if (filterSince) filterSince.value = '';
2500 if (filterUntil) filterUntil.value = '';
2501 if (filterContentScope) filterContentScope.value = '';
2502 }
2503
2504 function renderFilterChips(facets) {
2505 filterChipsEl.innerHTML = '';
2506 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2507
2508 const header = document.createElement('div');
2509 header.className = 'filter-chips-header';
2510
2511 const label = document.createElement('span');
2512 label.className = 'toolbar-label';
2513 label.textContent = 'Quick';
2514
2515 const toggle = document.createElement('button');
2516 toggle.type = 'button';
2517 toggle.className = 'filter-chips-toggle';
2518 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2519 toggle.setAttribute('aria-controls', 'filter-chips-panel');
2520 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2521 toggle.setAttribute(
2522 'aria-label',
2523 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2524 );
2525 toggle.innerHTML =
2526 '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
2527 toggle.onclick = () => {
2528 filterChipsExpanded = !filterChipsExpanded;
2529 try {
2530 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
2531 } catch (_) {}
2532 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2533 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2534 toggle.title = filterChipsExpanded ? 'Hide quick filter chips' : 'Show quick filter chips';
2535 toggle.setAttribute(
2536 'aria-label',
2537 filterChipsExpanded ? 'Collapse quick filter chips' : 'Expand quick filter chips',
2538 );
2539 };
2540
2541 header.appendChild(label);
2542 header.appendChild(toggle);
2543 filterChipsEl.appendChild(header);
2544
2545 const panel = document.createElement('div');
2546 panel.id = 'filter-chips-panel';
2547 panel.className = 'filter-chips-panel';
2548 panel.setAttribute('role', 'region');
2549 panel.setAttribute('aria-label', 'Quick filter chips');
2550 filterChipsEl.appendChild(panel);
2551
2552 const allBtn = document.createElement('button');
2553 allBtn.type = 'button';
2554 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
2555 allBtn.textContent = 'All';
2556 allBtn.title =
2557 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
2558 allBtn.onclick = () => {
2559 searchQuery.value = '';
2560 clearListFacetFilters();
2561 switchNotesView('list');
2562 loadNotes();
2563 renderFilterChips(null);
2564 };
2565 panel.appendChild(allBtn);
2566
2567 const apply = (f) => {
2568 if (!f) return;
2569 (f.projects || []).slice(0, 12).forEach((p) => {
2570 const b = document.createElement('button');
2571 b.type = 'button';
2572 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
2573 b.textContent = 'project:' + p;
2574 b.onclick = () => {
2575 searchQuery.value = '';
2576 filterProject.value = p;
2577 filterTag.value = '';
2578 filterFolder.value = '';
2579 switchNotesView('list');
2580 loadNotes();
2581 renderFilterChips(null);
2582 };
2583 panel.appendChild(b);
2584 });
2585 (f.tags || []).slice(0, 10).forEach((t) => {
2586 const b = document.createElement('button');
2587 b.type = 'button';
2588 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
2589 b.textContent = 'tag:' + t;
2590 b.onclick = () => {
2591 searchQuery.value = '';
2592 filterTag.value = t;
2593 filterProject.value = '';
2594 filterFolder.value = '';
2595 switchNotesView('list');
2596 loadNotes();
2597 renderFilterChips(null);
2598 };
2599 panel.appendChild(b);
2600 });
2601 (f.folders || []).slice(0, 12).forEach((folder) => {
2602 const b = document.createElement('button');
2603 b.type = 'button';
2604 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
2605 b.textContent = 'folder:' + folder;
2606 b.onclick = () => {
2607 searchQuery.value = '';
2608 filterFolder.value = folder;
2609 filterProject.value = '';
2610 filterTag.value = '';
2611 switchNotesView('list');
2612 loadNotes();
2613 renderFilterChips(null);
2614 };
2615 panel.appendChild(b);
2616 });
2617 // Phase 12 — network chips
2618 (f.networks || []).slice(0, 8).forEach((net) => {
2619 const b = document.createElement('button');
2620 b.type = 'button';
2621 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
2622 b.textContent = 'net:' + net;
2623 b.onclick = () => {
2624 searchQuery.value = '';
2625 if (filterNetwork) filterNetwork.value = net;
2626 switchNotesView('list');
2627 loadNotes();
2628 renderFilterChips(null);
2629 };
2630 panel.appendChild(b);
2631 });
2632 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
2633 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
2634 const payStatuses = ['pending', 'settled', 'failed'];
2635 payStatuses.forEach((ps) => {
2636 const b = document.createElement('button');
2637 b.type = 'button';
2638 b.className = 'chip-btn chip-blockchain';
2639 b.textContent = 'status:' + ps;
2640 b.onclick = () => {
2641 searchQuery.value = '';
2642 const fpsEl = el('filter-payment-status');
2643 if (fpsEl) fpsEl.value = ps;
2644 switchNotesView('list');
2645 loadNotes();
2646 renderFilterChips(null);
2647 };
2648 panel.appendChild(b);
2649 });
2650 }
2651 };
2652 if (facets) apply(facets);
2653 else fetchFacetsResolved().then(apply).catch(() => {});
2654 }
2655
2656 function getPresets() {
2657 try {
2658 const raw = localStorage.getItem(PRESETS_KEY);
2659 return raw ? JSON.parse(raw) : [];
2660 } catch (_) {
2661 return [];
2662 }
2663 }
2664
2665 function savePreset() {
2666 const name = (presetNameInput.value || '').trim();
2667 if (!name) return;
2668 const presets = getPresets().filter((p) => p.name !== name);
2669 presets.push({
2670 name,
2671 project: filterProject.value,
2672 tag: filterTag.value,
2673 folder: filterFolder.value,
2674 since: filterSince?.value || '',
2675 until: filterUntil?.value || '',
2676 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2677 });
2678 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
2679 presetNameInput.value = '';
2680 renderPresets();
2681 }
2682
2683 function renderPresets() {
2684 presetsListEl.innerHTML = '';
2685 getPresets().forEach((p) => {
2686 const b = document.createElement('button');
2687 b.type = 'button';
2688 b.className = 'preset-pill';
2689 b.textContent = p.name;
2690 b.title = [p.folder && 'folder:' + p.folder, p.project && 'project:' + p.project, p.tag && 'tag:' + p.tag, p.since && 'since:' + p.since, p.until && 'until:' + p.until, p.content_scope && 'content:' + p.content_scope].filter(Boolean).join(' ');
2691 b.onclick = () => {
2692 filterProject.value = p.project || '';
2693 filterTag.value = p.tag || '';
2694 filterFolder.value = p.folder || '';
2695 if (filterSince) filterSince.value = p.since || '';
2696 if (filterUntil) filterUntil.value = p.until || '';
2697 if (filterContentScope) filterContentScope.value = p.content_scope || '';
2698 switchNotesView('list');
2699 loadNotes();
2700 renderFilterChips(null);
2701 };
2702 presetsListEl.appendChild(b);
2703 });
2704 }
2705
2706 el('btn-save-preset').onclick = savePreset;
2707
2708 function renderNoteRow(n) {
2709 const title = n.title || n.path;
2710 const isLog = hubRowIsApprovalLog(n);
2711 const chips = [];
2712 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
2713 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
2714 const meta = [n.date].filter(Boolean).join(' · ');
2715 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
2716 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
2717 return (
2718 '<div class="' +
2719 rowClass +
2720 '" data-path="' +
2721 escapeHtml(n.path) +
2722 '"><span class="row-title">' +
2723 escapeHtml(title) +
2724 badge +
2725 '</span><div class="row-chips">' +
2726 chips.join('') +
2727 '</div>' +
2728 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
2729 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
2730 '</div>'
2731 );
2732 }
2733
2734 function bindNoteClicks(container) {
2735 container.querySelectorAll('.list-item').forEach((item) => {
2736 item.onclick = () => openNote(item.dataset.path);
2737 const delBtn = item.querySelector('.list-item-delete');
2738 if (delBtn) {
2739 delBtn.onclick = async (e) => {
2740 e.stopPropagation();
2741 const path = item.dataset.path;
2742 if (!path) return;
2743 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
2744 try {
2745 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
2746 if (typeof showToast === 'function') showToast('Deleted: ' + path);
2747 hubMarkSemanticIndexStale();
2748 if (currentOpenNote && currentOpenNote.path === path) {
2749 currentOpenNote = null;
2750 resetDetailSectionSourceState();
2751 hideDetailPanelChrome();
2752 }
2753 loadNotes();
2754 loadFacets();
2755 } catch (err) {
2756 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
2757 }
2758 };
2759 }
2760 });
2761 }
2762
2763 function hasActiveNoteListFilters() {
2764 if (filterProject && filterProject.value) return true;
2765 if (filterTag && filterTag.value) return true;
2766 if (filterFolder && filterFolder.value) return true;
2767 if (filterSince && filterSince.value) return true;
2768 if (filterUntil && filterUntil.value) return true;
2769 if (filterContentScope && filterContentScope.value) return true;
2770 if (filterNetwork && filterNetwork.value) return true;
2771 if (filterWallet && filterWallet.value) return true;
2772 const paymentStatusEl = el('filter-payment-status');
2773 if (paymentStatusEl && paymentStatusEl.value) return true;
2774 return false;
2775 }
2776
2777 function readOnboardingDismissedSync() {
2778 try {
2779 const raw = localStorage.getItem('knowtation_onboarding_v1');
2780 if (!raw) return false;
2781 const o = JSON.parse(raw);
2782 return Boolean(o && o.v === 1 && o.status === 'dismissed');
2783 } catch (_) {
2784 return false;
2785 }
2786 }
2787
2788 function isSearchResultsView() {
2789 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
2790 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
2791 }
2792
2793 function updateEmptyVaultStripVisibility() {
2794 const strip = el('hub-empty-vault-strip');
2795 if (!strip) return;
2796 const mainVisible = main && !main.classList.contains('hidden');
2797 const notesTab = document.querySelector('.tabs .tab.active')?.dataset?.tab === 'notes';
2798 const q = searchQuery && String(searchQuery.value).trim();
2799 const show =
2800 Boolean(mainVisible && token) &&
2801 readOnboardingDismissedSync() &&
2802 hubBrowseListEmptyUnfiltered &&
2803 notesTab &&
2804 !q &&
2805 !isSearchResultsView();
2806 strip.classList.toggle('hidden', !show);
2807 }
2808
2809 async function loadNotes() {
2810 const q = new URLSearchParams();
2811 q.set('limit', '100');
2812 if (filterFolder.value) q.set('folder', filterFolder.value);
2813 if (filterProject.value) q.set('project', filterProject.value);
2814 if (filterTag.value) q.set('tag', filterTag.value);
2815 if (filterSince && filterSince.value) q.set('since', filterSince.value);
2816 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
2817 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
2818 // Phase 12 — blockchain filters
2819 const networkVal = filterNetwork ? filterNetwork.value : '';
2820 const walletVal = filterWallet ? filterWallet.value : '';
2821 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
2822 if (networkVal) q.set('network', networkVal);
2823 if (walletVal) q.set('wallet_address', walletVal);
2824 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
2825 notesList.innerHTML = loadingHtml;
2826 notesTotal.textContent = '';
2827 try {
2828 const out = await api('/api/v1/notes?' + q.toString());
2829 let notes = (out.notes || []).map(normalizeHubListItem);
2830 notes = applyVaultListFilters(notes, {
2831 folder: filterFolder.value,
2832 project: filterProject.value,
2833 tag: filterTag.value,
2834 since: filterSince?.value || '',
2835 until: filterUntil?.value || '',
2836 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2837 network: networkVal,
2838 wallet_address: walletVal,
2839 payment_status: paymentStatusVal,
2840 });
2841 notes = applySortedNotesClient(notes);
2842 const totalCount = notes.length;
2843 notes = notes.slice(0, 100);
2844 if (notes.length === 0) {
2845 notesList.innerHTML =
2846 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
2847 const ea = el('empty-add');
2848 if (ea) ea.onclick = () => openCreateModal();
2849 notesTotal.textContent = 'Total: 0';
2850 } else {
2851 notesList.innerHTML = notes.map(renderNoteRow).join('');
2852 notesTotal.textContent = 'Total: ' + totalCount;
2853 bindNoteClicks(notesList);
2854 listSelectedIndex = 0;
2855 updateListSelection();
2856 }
2857 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
2858 updateEmptyVaultStripVisibility();
2859 } catch (e) {
2860 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
2861 notesTotal.textContent = '';
2862 hubBrowseListEmptyUnfiltered = false;
2863 updateEmptyVaultStripVisibility();
2864 }
2865 }
2866
2867 function switchHubMainTab(name) {
2868 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2869 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2870 const tab = document.querySelector('[data-tab="' + name + '"]');
2871 if (tab) tab.classList.add('active');
2872 syncHubListSortUI(name);
2873 setProposalFiltersBarVisible(
2874 name === 'activity' || name === 'suggested' || name === 'problem',
2875 );
2876 refreshNewProposalTabVisibility();
2877 const panel = el(
2878 'tab-' +
2879 (name === 'notes'
2880 ? 'notes'
2881 : name === 'activity'
2882 ? 'activity'
2883 : name === 'suggested'
2884 ? 'suggested'
2885 : 'problem'),
2886 );
2887 if (panel) panel.classList.remove('hidden');
2888 if (name === 'notes') {
2889 loadNotes();
2890 } else {
2891 if (name === 'activity') loadActivity();
2892 if (name === 'suggested' || name === 'problem') loadProposals();
2893 updateEmptyVaultStripVisibility();
2894 }
2895 }
2896
2897 function updateListSelection() {
2898 const container = notesList;
2899 const items = container.querySelectorAll('.list-item');
2900 if (items.length === 0) { listSelectedIndex = 0; return; }
2901 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
2902 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
2903 const sel = items[listSelectedIndex];
2904 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2905 }
2906
2907 btnApplyFilters.onclick = () => {
2908 switchNotesView('list');
2909 loadNotes();
2910 renderFilterChips(null);
2911 };
2912
2913 if (filterContentScope) {
2914 filterContentScope.addEventListener('change', () => {
2915 switchNotesView('list');
2916 loadNotes();
2917 renderFilterChips(null);
2918 });
2919 }
2920
2921 function formatSearchScopeSummary() {
2922 const parts = [];
2923 if (filterProject.value) parts.push('project: ' + filterProject.value);
2924 if (filterTag.value) parts.push('tag: ' + filterTag.value);
2925 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
2926 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
2927 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
2928 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
2929 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
2930 return parts.length ? parts.join(' · ') : '';
2931 }
2932
2933 function semanticMatchStrengthLabel(score) {
2934 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2935 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2936 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
2937 }
2938
2939 function keywordMatchStrengthLabel(score) {
2940 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
2941 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
2942 return 'Keyword match ~' + pct + '% (text overlap)';
2943 }
2944
2945 if (btnClearSearch) {
2946 btnClearSearch.onclick = () => {
2947 searchQuery.value = '';
2948 clearListFacetFilters();
2949 switchNotesView('list');
2950 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
2951 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
2952 const notesTab = document.querySelector('[data-tab="notes"]');
2953 if (notesTab) notesTab.classList.add('active');
2954 const tabNotes = el('tab-notes');
2955 if (tabNotes) tabNotes.classList.remove('hidden');
2956 loadNotes();
2957 renderFilterChips(null);
2958 };
2959 }
2960
2961 function showToast(message, isError = false) {
2962 const toast = document.createElement('div');
2963 toast.className = 'toast' + (isError ? ' toast-err' : '');
2964 toast.textContent = message;
2965 toast.setAttribute('role', 'status');
2966 document.body.appendChild(toast);
2967 requestAnimationFrame(() => toast.classList.add('toast-show'));
2968 setTimeout(() => {
2969 toast.classList.remove('toast-show');
2970 setTimeout(() => toast.remove(), 300);
2971 }, 3000);
2972 }
2973
2974 const proposalFilterApply = el('proposal-filter-apply');
2975 if (proposalFilterApply) {
2976 proposalFilterApply.onclick = () => {
2977 loadProposals();
2978 loadActivity();
2979 };
2980 }
2981 const proposalFilterClear = el('proposal-filter-clear');
2982 if (proposalFilterClear) {
2983 proposalFilterClear.onclick = () => {
2984 const lf = el('proposal-filter-label');
2985 const sf = el('proposal-filter-source');
2986 const pf = el('proposal-filter-path-prefix');
2987 const pe = el('proposal-filter-pending-eval');
2988 const rq = el('proposal-filter-review-queue');
2989 const rs = el('proposal-filter-review-severity');
2990 if (lf) lf.value = '';
2991 if (sf) sf.value = '';
2992 if (pf) pf.value = '';
2993 if (pe) pe.checked = false;
2994 if (rq) rq.value = '';
2995 if (rs) rs.value = '';
2996 loadProposals();
2997 loadActivity();
2998 };
2999 }
3000
3001 const hubListSortEl = hubListSortGetSelect();
3002 if (hubListSortEl) {
3003 hubListSortEl.addEventListener('change', () => {
3004 const tab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3005 try {
3006 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3007 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3008 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3009 }
3010 } catch (_) {}
3011 if (tab === 'notes') loadNotes();
3012 else if (tab === 'activity') loadActivity();
3013 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3014 });
3015 }
3016
3017 if (btnReindex) {
3018 btnReindex.onclick = async () => {
3019 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3020 try {
3021 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3022 // (see api() helper). Bridge may return one of three shapes:
3023 // 200 {ok:true, ...} → sync completed
3024 // 202 {status:'background', ...} → routed to bridge-index-background fn
3025 // 409 {status:'already_running'} → another background job in flight
3026 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3027 if (out && out.status === 'background') {
3028 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3029 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3030 } else if (out && out.status === 'already_running') {
3031 showToast(out.message || 'A background re-index is already running for this vault.');
3032 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3033 } else {
3034 const n = out.notesProcessed ?? 0;
3035 const c = out.chunksIndexed ?? 0;
3036 const skipped = out.chunksSkippedCached ?? 0;
3037 const embedded = out.chunksEmbedded ?? c;
3038 const detail = skipped > 0
3039 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3040 : '';
3041 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3042 hubClearSemanticIndexStale();
3043 loadFacets();
3044 loadNotes();
3045 hubLoadIndexStatus().catch(() => {});
3046 }
3047 } catch (e) {
3048 showToast(e.message || 'Re-index failed', true);
3049 }
3050 });
3051 };
3052 }
3053
3054 /*
3055 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3056 * Reads from `GET /api/v1/index/status` which both sync and background paths
3057 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3058 * background job is in flight so the line flips from
3059 * "Re-indexing in background…" → "Last indexed: just now"
3060 * without the user needing to click anything.
3061 */
3062 let _hubIndexStatusPollTimer = null;
3063 function hubFormatRelativeTime(epochMs) {
3064 if (!Number.isFinite(epochMs)) return '';
3065 const ageMs = Date.now() - epochMs;
3066 if (ageMs < 0) return 'just now';
3067 const sec = Math.round(ageMs / 1000);
3068 if (sec < 45) return 'just now';
3069 const min = Math.round(sec / 60);
3070 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3071 const hr = Math.round(min / 60);
3072 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3073 const days = Math.round(hr / 24);
3074 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3075 }
3076 async function hubLoadIndexStatus(opts) {
3077 opts = opts || {};
3078 const el = document.getElementById('hub-index-status');
3079 if (!el) return;
3080 let status;
3081 try {
3082 status = await api('/api/v1/index/status', { method: 'GET' });
3083 } catch (_) {
3084 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3085 el.textContent = '';
3086 el.classList.remove('hub-index-status-running');
3087 return;
3088 }
3089 if (status && status.inProgress) {
3090 el.textContent = 'Re-indexing in background…';
3091 el.classList.add('hub-index-status-running');
3092 // Keep polling so the line auto-clears when the background job finishes.
3093 // 5-second cadence matches typical embedding batch completion granularity
3094 // and stays well under any sane rate limit.
3095 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3096 _hubIndexStatusPollTimer = setInterval(() => {
3097 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3098 }, 5000);
3099 }
3100 return;
3101 }
3102 // No in-flight job — stop polling if we were.
3103 if (_hubIndexStatusPollTimer != null) {
3104 clearInterval(_hubIndexStatusPollTimer);
3105 _hubIndexStatusPollTimer = null;
3106 }
3107 el.classList.remove('hub-index-status-running');
3108 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3109 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3110 el.textContent = 'Last indexed: ' + rel;
3111 el.title =
3112 'Last successful index: ' +
3113 (status.lastIndexed.lastIndexedAt || '') +
3114 ' · ' +
3115 (status.lastIndexed.chunksIndexed || 0) +
3116 ' chunks · mode: ' +
3117 (status.lastIndexed.mode || 'sync');
3118 } else {
3119 el.textContent = '';
3120 el.title = '';
3121 }
3122 }
3123 // Kick off an initial status load once the user is logged in (the API call
3124 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3125 // that already happens after token validation succeeds.
3126 hubLoadIndexStatus().catch(() => {});
3127
3128 const hubIndexStaleRun = el('hub-index-stale-run');
3129 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3130 if (hubIndexStaleRun && btnReindex) {
3131 hubIndexStaleRun.onclick = () => {
3132 btnReindex.click();
3133 };
3134 }
3135 if (hubIndexStaleDismiss) {
3136 hubIndexStaleDismiss.onclick = () => {
3137 hubClearSemanticIndexStale();
3138 };
3139 }
3140
3141 function proposalFilterQuerySuffix() {
3142 const params = [];
3143 const lab = el('proposal-filter-label');
3144 const src = el('proposal-filter-source');
3145 const pre = el('proposal-filter-path-prefix');
3146 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3147 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3148 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3149 const pe = el('proposal-filter-pending-eval');
3150 if (pe && pe.checked) params.push('evaluation_status=pending');
3151 const rq = el('proposal-filter-review-queue');
3152 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3153 const rs = el('proposal-filter-review-severity');
3154 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3155 return params.length ? '&' + params.join('&') : '';
3156 }
3157
3158 // Discard a proposal directly from the list without opening the detail panel.
3159 async function discardProposalInline(id, itemEl) {
3160 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3161 try {
3162 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3163 if (typeof showToast === 'function') showToast('Proposal discarded.');
3164 const panel = el('detail-panel');
3165 if (panel && !panel.classList.contains('hidden')) {
3166 hideDetailPanelChrome();
3167 }
3168 loadProposals();
3169 loadActivity();
3170 } catch (err) {
3171 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3172 }
3173 }
3174
3175 async function loadProposals() {
3176 const emptySuggested =
3177 '<div class="empty-state empty-state-suggested">' +
3178 '<p><strong>No proposals waiting for review.</strong> Agents and the CLI queue edits here; nothing applies to your live vault until you approve.</p>' +
3179 '<p>Use <strong>New proposal</strong> or open a note and choose <strong>Propose change</strong>, or have an agent or the CLI create one.</p>' +
3180 '<p class="empty-state-suggested-actions"><button type="button" class="btn-secondary" id="empty-suggested-how-to">How proposals work</button></p>' +
3181 '</div>';
3182 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3183 const fq = proposalFilterQuerySuffix();
3184 [
3185 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3186 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3187 ].forEach(({ kind, status, empty: emptyHtml }) => {
3188 const container = el('proposals-' + kind);
3189 if (!container) return;
3190 container.innerHTML = loadingHtml;
3191 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3192 .then((out) => {
3193 let list = out.proposals || [];
3194 list = applySortedProposalsClient(list);
3195 if (list.length === 0) {
3196 container.innerHTML = emptyHtml;
3197 if (kind === 'suggested') {
3198 const how = container.querySelector('#empty-suggested-how-to');
3199 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3200 }
3201 return;
3202 }
3203 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3204 container.innerHTML = list
3205 .map((p) => {
3206 const labelChips = (Array.isArray(p.labels) ? p.labels : [])
3207 .slice(0, 4)
3208 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
3209 .join('');
3210 const srcChip = p.source
3211 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3212 : '';
3213 const qChip = p.review_queue
3214 ? '<span class="proposal-chip">queue:' + escapeHtml(String(p.review_queue)) + '</span>'
3215 : '';
3216 const sevChip =
3217 p.review_severity === 'elevated'
3218 ? '<span class="proposal-chip">elevated</span>'
3219 : p.review_severity === 'standard'
3220 ? '<span class="proposal-chip">standard</span>'
3221 : '';
3222 const extraChips = [labelChips, srcChip, qChip, sevChip].filter(Boolean).join('');
3223 const discardBtn = canDiscard
3224 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3225 : '';
3226 return (
3227 '<div class="list-item" data-id="' +
3228 escapeHtml(p.proposal_id) +
3229 '"><span class="row-title">' +
3230 escapeHtml(p.path) +
3231 '</span><div class="status">' +
3232 escapeHtml(p.status) +
3233 (p.updated_at ? ' · ' + (calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) : '') +
3234 (p.evaluation_status ? ' · eval:' + escapeHtml(String(p.evaluation_status)) : '') +
3235 (extraChips ? ' · ' + extraChips : '') +
3236 '</div>' + discardBtn + '</div>'
3237 );
3238 })
3239 .join('');
3240 container.querySelectorAll('.list-item').forEach((item) => {
3241 item.onclick = () => openProposal(item.dataset.id);
3242 const db = item.querySelector('.list-item-delete');
3243 if (db) {
3244 db.onclick = (e) => {
3245 e.stopPropagation();
3246 discardProposalInline(item.dataset.id, item);
3247 };
3248 }
3249 });
3250 })
3251 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3252 });
3253 }
3254
3255 async function loadActivity() {
3256 const container = el('proposals-activity');
3257 if (!container) return;
3258 container.innerHTML = loadingHtml;
3259 try {
3260 const fq = proposalFilterQuerySuffix();
3261 const out = await api('/api/v1/proposals?limit=100' + fq);
3262 let list = out.proposals || [];
3263 list = applySortedProposalsClient(list);
3264 if (list.length === 0) {
3265 container.innerHTML =
3266 '<div class="empty-state empty-state-activity">' +
3267 '<p>No proposal activity yet.</p>' +
3268 '<p class="muted small">Pending reviews from agents or the CLI appear under the <strong>Suggested</strong> tab first; this tab is the timeline once things move.</p>' +
3269 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Suggested tab</button></p>' +
3270 '</div>';
3271 const go = container.querySelector('#empty-activity-goto-suggested');
3272 if (go) go.onclick = () => switchHubMainTab('suggested');
3273 return;
3274 }
3275 const canDiscard = hubUserCanWriteNotes();
3276 container.innerHTML = list
3277 .map((p) => {
3278 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3279 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3280 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3281 // approved records stay as-is unless the user opens them.
3282 const showDiscard = canDiscard && p.status !== 'approved';
3283 const discardBtn = showDiscard
3284 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3285 : '';
3286 return (
3287 '<div class="list-item activity-item ' +
3288 statusClass +
3289 '" data-id="' +
3290 escapeHtml(p.proposal_id) +
3291 '"><span class="row-title">' +
3292 escapeHtml(p.path) +
3293 '</span><div class="status">' +
3294 escapeHtml(p.status) +
3295 ' · ' +
3296 escapeHtml(date) +
3297 '</div>' + discardBtn + '</div>'
3298 );
3299 })
3300 .join('');
3301 container.querySelectorAll('.list-item').forEach((item) => {
3302 item.onclick = () => openProposal(item.dataset.id);
3303 const db = item.querySelector('.list-item-delete');
3304 if (db) {
3305 db.onclick = (e) => {
3306 e.stopPropagation();
3307 discardProposalInline(item.dataset.id, item);
3308 };
3309 }
3310 });
3311 } catch (e) {
3312 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3313 }
3314 }
3315
3316 async function runVaultSearch() {
3317 const query = searchQuery.value.trim();
3318 if (!query) return;
3319 hubBrowseListEmptyUnfiltered = false;
3320 updateEmptyVaultStripVisibility();
3321 const activeMainTab = document.querySelector('.tabs .tab.active')?.dataset?.tab;
3322 const useKeyword = searchMode && searchMode.value === 'keyword';
3323 if (activeMainTab && activeMainTab !== 'notes') {
3324 showToast(useKeyword ? 'Keyword results are shown under the Notes tab.' : 'Semantic results are shown under the Notes tab.');
3325 }
3326 switchNotesView('list');
3327 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3328 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3329 document.querySelector('[data-tab="notes"]').classList.add('active');
3330 el('tab-notes').classList.remove('hidden');
3331 notesList.innerHTML = loadingHtml;
3332 notesTotal.textContent = '';
3333 const scopeSummary = formatSearchScopeSummary();
3334 const scopeSuffix = scopeSummary
3335 ? ' · scope: ' + scopeSummary
3336 : ' · scope: entire vault (use dropdowns to narrow)';
3337 try {
3338 const body = { query, limit: 20 };
3339 if (useKeyword) body.mode = 'keyword';
3340 if (filterProject.value) body.project = filterProject.value;
3341 if (filterTag.value) body.tag = filterTag.value;
3342 if (filterFolder.value) body.folder = filterFolder.value;
3343 if (filterSince && filterSince.value) body.since = filterSince.value;
3344 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
3345 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
3346 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
3347 const results = out.results || [];
3348 if (results.length === 0) {
3349 notesList.innerHTML = useKeyword
3350 ? '<div class="empty-state">No notes contained this text under the current filters. Try different words, clear filters, or switch to <strong>Meaning</strong> for similarity search.</div>'
3351 : '<div class="empty-state">No notes matched this query under the current filters. Semantic search finds <em>similar meaning</em>, not exact words — try other phrases, clear filters, use <strong>Keyword</strong> for literal text, or use Quick chips + Apply filters for exact tags/projects.</div>';
3352 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
3353 return;
3354 }
3355 notesList.innerHTML = results
3356 .map((r) => {
3357 const chips = [];
3358 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
3359 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3360 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
3361 const pathStr = String(r.path || '').replace(/\\/g, '/');
3362 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
3363 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3364 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3365 return (
3366 '<div class="' +
3367 rowClass +
3368 '" data-path="' +
3369 escapeHtml(r.path) +
3370 '"><span class="row-title">' +
3371 escapeHtml(r.path) +
3372 badge +
3373 '</span><div class="row-chips">' +
3374 chips.join('') +
3375 '</div>' +
3376 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
3377 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
3378 '</div>'
3379 );
3380 })
3381 .join('');
3382 notesTotal.textContent =
3383 results.length +
3384 (useKeyword ? ' keyword' : ' semantic') +
3385 ' result' +
3386 (results.length === 1 ? '' : 's') +
3387 scopeSuffix;
3388 bindNoteClicks(notesList);
3389 listSelectedIndex = 0;
3390 updateListSelection();
3391 } catch (e) {
3392 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3393 notesTotal.textContent = '';
3394 }
3395 }
3396
3397 btnSearch.onclick = () => {
3398 void runVaultSearch();
3399 };
3400
3401 searchQuery.addEventListener('keydown', (e) => {
3402 if (e.key === 'Enter') {
3403 e.preventDefault();
3404 void runVaultSearch();
3405 }
3406 });
3407 searchQuery.addEventListener('input', () => {
3408 updateEmptyVaultStripVisibility();
3409 });
3410
3411 function switchNotesView(view) {
3412 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
3413 el('notes-view-list').classList.toggle('hidden', view !== 'list');
3414 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
3415 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
3416 if (view === 'calendar') renderCalendar();
3417 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
3418 }
3419
3420 document.querySelectorAll('.view-tab').forEach((t) => {
3421 t.onclick = () => switchNotesView(t.dataset.view);
3422 });
3423
3424 function ymd(d) {
3425 const y = d.getFullYear();
3426 const m = String(d.getMonth() + 1).padStart(2, '0');
3427 const day = String(d.getDate()).padStart(2, '0');
3428 return y + '-' + m + '-' + day;
3429 }
3430
3431 async function renderCalendar() {
3432 const grid = el('calendar-grid');
3433 const title = el('cal-title');
3434 const dayList = el('calendar-day-list');
3435 const dayNotes = el('calendar-day-notes');
3436 dayList.classList.add('hidden');
3437 grid.classList.remove('hidden');
3438 el('calendar-nav').classList.remove('hidden');
3439
3440 const y = calendarMonth.getFullYear();
3441 const m = calendarMonth.getMonth();
3442 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
3443
3444 grid.innerHTML = loadingHtml;
3445 const first = new Date(y, m, 1);
3446 const last = new Date(y, m + 1, 0);
3447 const since = ymd(first);
3448 const until = ymd(last);
3449
3450 let notesInMonth = [];
3451 try {
3452 const q = new URLSearchParams({ since, until, limit: '100' });
3453 const out = await api('/api/v1/notes?' + q.toString());
3454 notesInMonth = (out.notes || [])
3455 .map(normalizeHubListItem)
3456 .filter((n) => {
3457 const ds = noteSortOrCalendarDay(n);
3458 return ds >= since && ds <= until;
3459 });
3460 } catch (_) {
3461 notesInMonth = [];
3462 }
3463
3464 const byDay = {};
3465 notesInMonth.forEach((n) => {
3466 const ds = noteSortOrCalendarDay(n);
3467 if (ds >= since && ds <= until) {
3468 byDay[ds] = (byDay[ds] || 0) + 1;
3469 }
3470 });
3471
3472 const startPad = first.getDay();
3473 const daysInMonth = last.getDate();
3474 const cells = [];
3475 const prevLast = new Date(y, m, 0).getDate();
3476 for (let i = 0; i < startPad; i++) {
3477 const d = prevLast - startPad + i + 1;
3478 cells.push({ out: true, day: d, key: null });
3479 }
3480 for (let d = 1; d <= daysInMonth; d++) {
3481 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
3482 }
3483 let nextMonthDay = 1;
3484 while (cells.length % 7 !== 0 || cells.length < 42) {
3485 cells.push({ out: true, day: nextMonthDay++, key: null });
3486 }
3487
3488 const today = ymd(new Date());
3489 grid.innerHTML = cells
3490 .map((c) => {
3491 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
3492 const cnt = byDay[c.key] || 0;
3493 const isToday = c.key === today;
3494 return (
3495 '<div class="cal-cell' +
3496 (isToday ? ' today' : '') +
3497 '" data-day="' +
3498 escapeHtml(c.key) +
3499 '"><span class="cal-day-num">' +
3500 c.day +
3501 '</span>' +
3502 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
3503 '</div>'
3504 );
3505 })
3506 .join('');
3507
3508 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
3509 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
3510 });
3511 }
3512
3513 el('cal-prev').onclick = () => {
3514 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
3515 renderCalendar();
3516 };
3517 el('cal-next').onclick = () => {
3518 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
3519 renderCalendar();
3520 };
3521 el('cal-back').onclick = () => {
3522 el('calendar-day-list').classList.add('hidden');
3523 el('calendar-grid').classList.remove('hidden');
3524 el('calendar-nav').classList.remove('hidden');
3525 };
3526
3527 function showCalendarDay(dayKey, notesInMonth) {
3528 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
3529 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
3530 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
3531 bindNoteClicks(el('calendar-day-notes'));
3532 el('calendar-grid').classList.add('hidden');
3533 el('calendar-nav').classList.add('hidden');
3534 el('calendar-day-list').classList.remove('hidden');
3535 }
3536
3537 async function fetchNotesForDashboard() {
3538 const all = [];
3539 let offset = 0;
3540 const limit = 100;
3541 let total = Infinity;
3542 while (offset < 500 && all.length < total) {
3543 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
3544 total = out.total ?? 0;
3545 const batch = (out.notes || []).map(normalizeHubListItem);
3546 all.push(...batch);
3547 if (batch.length < limit) break;
3548 offset += limit;
3549 }
3550 return { notes: all, total };
3551 }
3552
3553 async function renderDashboard() {
3554 chartInstances.forEach((c) => c.destroy());
3555 chartInstances = [];
3556 const cards = el('dashboard-cards');
3557 const foot = el('dashboard-footnote');
3558 cards.innerHTML = loadingHtml;
3559 foot.textContent = '';
3560
3561 let notes, total;
3562 try {
3563 const r = await fetchNotesForDashboard();
3564 notes = r.notes;
3565 total = r.total;
3566 } catch (e) {
3567 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3568 return;
3569 }
3570
3571 const weekAgo = new Date();
3572 weekAgo.setDate(weekAgo.getDate() - 7);
3573 const weekStr = ymd(weekAgo);
3574 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
3575
3576 const byProject = {};
3577 const byTag = {};
3578 const byWeek = {};
3579 notes.forEach((n) => {
3580 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
3581 (n.tags || []).forEach((t) => {
3582 byTag[t] = (byTag[t] || 0) + 1;
3583 });
3584 const ds = noteSortOrCalendarDay(n);
3585 if (ds) {
3586 const w = ds.slice(0, 7);
3587 byWeek[w] = (byWeek[w] || 0) + 1;
3588 }
3589 });
3590
3591 const topProjects = Object.entries(byProject)
3592 .sort((a, b) => b[1] - a[1])
3593 .slice(0, 8);
3594 const topTags = Object.entries(byTag)
3595 .sort((a, b) => b[1] - a[1])
3596 .slice(0, 8);
3597 const weeks = Object.keys(byWeek).sort();
3598
3599 cards.innerHTML =
3600 '<div class="dash-card"><div class="dash-value">' +
3601 total +
3602 '</div><div class="dash-label">Notes (indexed)</div></div>' +
3603 '<div class="dash-card"><div class="dash-value">' +
3604 thisWeek +
3605 '</div><div class="dash-label">Last 7 days</div></div>' +
3606 '<div class="dash-card"><div class="dash-value">' +
3607 Object.keys(byProject).length +
3608 '</div><div class="dash-label">Projects</div></div>' +
3609 '<div class="dash-card"><div class="dash-value">' +
3610 Object.keys(byTag).length +
3611 '</div><div class="dash-label">Tags</div></div>';
3612
3613 if (notes.length < total) {
3614 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
3615 }
3616
3617 if (typeof Chart === 'undefined') {
3618 foot.textContent += ' Chart.js failed to load.';
3619 return;
3620 }
3621
3622 const commonOpts = {
3623 responsive: true,
3624 maintainAspectRatio: false,
3625 plugins: { legend: { labels: { color: '#a1a1a1' } } },
3626 scales: {
3627 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3628 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
3629 },
3630 };
3631
3632 const ctxP = el('chart-projects').getContext('2d');
3633 chartInstances.push(
3634 new Chart(ctxP, {
3635 type: 'bar',
3636 data: {
3637 labels: topProjects.map((x) => x[0]),
3638 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
3639 },
3640 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
3641 })
3642 );
3643
3644 const ctxT = el('chart-tags').getContext('2d');
3645 chartInstances.push(
3646 new Chart(ctxT, {
3647 type: 'doughnut',
3648 data: {
3649 labels: topTags.map((x) => x[0]),
3650 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
3651 },
3652 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
3653 })
3654 );
3655
3656 const ctxL = el('chart-timeline').getContext('2d');
3657 chartInstances.push(
3658 new Chart(ctxL, {
3659 type: 'line',
3660 data: {
3661 labels: weeks,
3662 datasets: [{ label: 'Notes per month', data: weeks.map((w) => byWeek[w]), borderColor: '#89cff0', backgroundColor: 'rgba(137, 207, 240, 0.1)', fill: true, tension: 0.2 }],
3663 },
3664 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
3665 })
3666 );
3667 }
3668
3669 function resetDuplicateCreateState() {
3670 pendingDuplicateDeleteSource = null;
3671 const ban = el('duplicate-source-banner');
3672 if (ban) ban.classList.add('hidden');
3673 const chk = el('duplicate-delete-after-save');
3674 if (chk) chk.checked = false;
3675 const mt = el('modal-create-title');
3676 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
3677 const fs = el('btn-full-save');
3678 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
3679 }
3680
3681 function openCreateModal() {
3682 resetDuplicateCreateState();
3683 closeCreateProposalModal();
3684 closeFullCreateSimilarModal();
3685 hideDetailPanelChrome();
3686 el('modal-create').classList.remove('hidden');
3687 el('create-msg-quick').textContent = '';
3688 el('create-msg-quick').className = 'create-msg';
3689 el('create-msg-full').textContent = '';
3690 el('create-msg-full').className = 'create-msg';
3691 fullCreateSimilarOverrideOnce = false;
3692 if (token) {
3693 void (async () => {
3694 await refreshFullPathFolderSelect();
3695 if (!lastHubFacets) {
3696 try {
3697 lastHubFacets = await fetchFacetsResolved();
3698 } catch (_) {}
3699 }
3700 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3701 })();
3702 }
3703 }
3704
3705 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
3706 function suggestDuplicateVaultPath(srcPath) {
3707 const t = String(srcPath || '')
3708 .replace(/\\/g, '/')
3709 .trim();
3710 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
3711 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
3712 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
3713 }
3714
3715 function tagsInputFromFrontmatter(tagsVal) {
3716 if (tagsVal == null) return '';
3717 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
3718 return String(tagsVal).trim();
3719 }
3720
3721 /**
3722 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
3723 * Optional checkbox deletes the source path after a successful save (different path only).
3724 */
3725 async function openDuplicateNoteModal() {
3726 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
3727 if (!token) {
3728 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
3729 return;
3730 }
3731 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
3732 closeCreateProposalModal();
3733 closeFullCreateSimilarModal();
3734 el('modal-create').classList.remove('hidden');
3735 el('create-msg-quick').textContent = '';
3736 el('create-msg-quick').className = 'create-msg';
3737 el('create-msg-full').textContent = '';
3738 el('create-msg-full').className = 'create-msg';
3739 fullCreateSimilarOverrideOnce = false;
3740 const mt = el('modal-create-title');
3741 if (mt) mt.textContent = 'Duplicate note';
3742 const fs = el('btn-full-save');
3743 if (fs) fs.textContent = 'Save duplicate';
3744 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
3745 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
3746 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
3747 if (tabFull) tabFull.classList.add('active');
3748 if (tabQuick) tabQuick.classList.remove('active');
3749 el('create-quick').classList.add('hidden');
3750 el('create-full').classList.remove('hidden');
3751 if (token) {
3752 try {
3753 await refreshFullPathFolderSelect();
3754 if (!lastHubFacets) {
3755 try {
3756 lastHubFacets = await fetchFacetsResolved();
3757 } catch (_) {}
3758 }
3759 hydrateFullCreateProjectSlugSelect(lastHubFacets);
3760 } catch (_) {}
3761 }
3762 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
3763 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
3764 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
3765 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
3766 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
3767 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
3768 if (el('full-entity')) {
3769 const ent = fm.entity;
3770 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
3771 }
3772 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
3773 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
3774 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
3775 if (el('full-path')) {
3776 el('full-path').value = sug;
3777 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
3778 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
3779 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
3780 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
3781 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
3782 }
3783 const dsp = el('duplicate-source-path');
3784 if (dsp) dsp.textContent = currentOpenNote.path;
3785 const ban = el('duplicate-source-banner');
3786 if (ban) ban.classList.remove('hidden');
3787 const chk = el('duplicate-delete-after-save');
3788 if (chk) chk.checked = false;
3789 }
3790
3791 function closeCreateModal() {
3792 closeFullCreateSimilarModal();
3793 resetDuplicateCreateState();
3794 el('modal-create').classList.add('hidden');
3795 }
3796 function closeCreateProposalModal() {
3797 const m = el('modal-create-proposal');
3798 if (m) m.classList.add('hidden');
3799 const pathInput = el('proposal-create-path');
3800 if (pathInput) pathInput.readOnly = false;
3801 }
3802 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
3803 function openCreateProposalModal(opts) {
3804 if (!token) {
3805 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
3806 return;
3807 }
3808 if (!hubUserCanWriteNotes()) {
3809 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
3810 return;
3811 }
3812 closeCreateModal();
3813 closeImportModal();
3814 hideDetailPanelChrome();
3815 const modal = el('modal-create-proposal');
3816 const pathInput = el('proposal-create-path');
3817 const hint = el('modal-create-proposal-hint');
3818 const bodyEl = el('proposal-create-body');
3819 const intentEl = el('proposal-create-intent');
3820 const msgEl = el('proposal-create-msg');
3821 if (!modal || !pathInput || !bodyEl || !intentEl) return;
3822 if (opts && opts.fromNote) {
3823 pathInput.readOnly = true;
3824 pathInput.value = opts.path || '';
3825 if (hint)
3826 hint.textContent =
3827 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
3828 } else {
3829 pathInput.readOnly = false;
3830 pathInput.value = (opts && opts.path) || '';
3831 if (hint)
3832 hint.textContent =
3833 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in the Suggested tab.';
3834 }
3835 bodyEl.value = (opts && opts.body) || '';
3836 intentEl.value = (opts && opts.intent) || '';
3837 if (msgEl) {
3838 msgEl.textContent = '';
3839 msgEl.className = 'create-msg';
3840 }
3841 modal.classList.remove('hidden');
3842 }
3843 btnNewNote.onclick = openCreateModal;
3844 el('modal-create-backdrop').onclick = closeCreateModal;
3845 el('modal-create-close').onclick = closeCreateModal;
3846
3847 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
3848 const modalCreateProposalClose = el('modal-create-proposal-close');
3849 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
3850 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
3851
3852 const btnNewProposal = el('btn-new-proposal');
3853 if (btnNewProposal) {
3854 btnNewProposal.onclick = () => openCreateProposalModal({});
3855 }
3856
3857 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
3858 if (btnProposalCreateSubmit) {
3859 btnProposalCreateSubmit.onclick = async () => {
3860 const pathInput = el('proposal-create-path');
3861 const bodyInput = el('proposal-create-body');
3862 const intentInput = el('proposal-create-intent');
3863 const msgEl = el('proposal-create-msg');
3864 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
3865 if (!rawPath) {
3866 if (msgEl) {
3867 msgEl.textContent = 'Path is required.';
3868 msgEl.className = 'create-msg err';
3869 }
3870 return;
3871 }
3872 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
3873 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
3874 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
3875 try {
3876 await api('/api/v1/proposals', {
3877 method: 'POST',
3878 body: JSON.stringify({
3879 path: rawPath,
3880 body,
3881 ...(intent ? { intent } : {}),
3882 source: 'hub_ui',
3883 }),
3884 });
3885 closeCreateProposalModal();
3886 if (typeof showToast === 'function') showToast('Proposal submitted');
3887 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
3888 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3889 const suggestedTab = document.querySelector('[data-tab="suggested"]');
3890 const suggestedPanel = el('tab-suggested');
3891 if (suggestedTab) suggestedTab.classList.add('active');
3892 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
3893 syncHubListSortUI('suggested');
3894 setProposalFiltersBarVisible(true);
3895 refreshNewProposalTabVisibility();
3896 loadProposals();
3897 } catch (e) {
3898 if (msgEl) {
3899 msgEl.textContent = e.message || 'Proposal failed';
3900 msgEl.className = 'create-msg err';
3901 }
3902 }
3903 });
3904 };
3905 }
3906
3907 function syncImportSheetsBlock() {
3908 const sel = el('import-source-type');
3909 const block = el('import-sheets-block');
3910 if (block && sel) block.hidden = sel.value !== 'google-sheets';
3911 }
3912
3913 function openImportModal(preselectSourceType) {
3914 if (!token) {
3915 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
3916 return;
3917 }
3918 closeCreateModal();
3919 closeCreateProposalModal();
3920 hideDetailPanelChrome();
3921 el('modal-import').classList.remove('hidden');
3922 el('import-msg').textContent = '';
3923 if (importFileEl) importFileEl.value = '';
3924 if (importFileFolderEl) importFileFolderEl.value = '';
3925 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
3926 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
3927 setImportBatchAria('');
3928 clearImportDropPending();
3929 const urlIn = el('import-url');
3930 if (urlIn) urlIn.value = '';
3931 const sid = el('import-spreadsheet-id');
3932 const srange = el('import-sheets-range');
3933 if (sid) sid.value = '';
3934 if (srange) srange.value = '';
3935 const importSel = el('import-source-type');
3936 if (importSel && preselectSourceType) {
3937 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
3938 if (hasOption) importSel.value = preselectSourceType;
3939 }
3940 syncImportSheetsBlock();
3941 const outDirEl = el('import-output-dir');
3942 if (outDirEl) outDirEl.value = '';
3943 void (async () => {
3944 await refreshImportVaultFolderSelect();
3945 if (!lastHubFacets) {
3946 try {
3947 lastHubFacets = await fetchFacetsResolved();
3948 } catch (_) {}
3949 }
3950 hydrateImportCreateProjectSlugSelect(lastHubFacets);
3951 const out = el('import-output-dir');
3952 if (out) out.value = defaultImportOutputDir();
3953 syncImportFolderSelectToOutputDir();
3954 syncImportPickersFromOutputDir();
3955 updateImportPathLayoutVisibility();
3956 })();
3957 }
3958 function closeImportModal() {
3959 el('modal-import').classList.add('hidden');
3960 clearImportDropPending();
3961 }
3962 if (btnImport) btnImport.onclick = openImportModal;
3963 el('modal-import-backdrop').onclick = closeImportModal;
3964 el('modal-import-close').onclick = closeImportModal;
3965 const importSourceTypeEl = el('import-source-type');
3966 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
3967
3968 function closeProjectsHelpModal() {
3969 const m = el('modal-projects-help');
3970 if (m) m.classList.add('hidden');
3971 }
3972 function openProjectsHelpModal() {
3973 closeCreateModal();
3974 closeCreateProposalModal();
3975 hideDetailPanelChrome();
3976 const m = el('modal-projects-help');
3977 if (m) m.classList.remove('hidden');
3978 }
3979 const btnProjectsHelp = el('btn-projects-help');
3980 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
3981 const btnFullProjectHelp = el('btn-full-project-help');
3982 if (btnFullProjectHelp) {
3983 btnFullProjectHelp.onclick = () => {
3984 const m = el('modal-projects-help');
3985 if (m) m.classList.remove('hidden');
3986 };
3987 }
3988 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
3989 const modalProjectsHelpClose = el('modal-projects-help-close');
3990 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
3991 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
3992
3993 if (btnImportChooseFolder && importFileFolderEl) {
3994 btnImportChooseFolder.onclick = () => {
3995 importFileFolderEl.click();
3996 };
3997 }
3998 if (importFileFolderEl) {
3999 importFileFolderEl.addEventListener('change', () => {
4000 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4001 clearImportDropPending();
4002 if (importFileEl) importFileEl.value = '';
4003 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4004 }
4005 });
4006 }
4007 if (importFileEl) {
4008 importFileEl.addEventListener('change', () => {
4009 clearImportDropPending();
4010 if (importFileFolderEl) importFileFolderEl.value = '';
4011 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4012 });
4013 }
4014 if (importDropZoneEl) {
4015 let dragOverCount = 0;
4016 const setOver = (on) => {
4017 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4018 else importDropZoneEl.classList.remove('import-drop-zone--over');
4019 };
4020 importDropZoneEl.addEventListener('dragenter', (e) => {
4021 e.preventDefault();
4022 dragOverCount += 1;
4023 setOver(true);
4024 });
4025 importDropZoneEl.addEventListener('dragleave', (e) => {
4026 e.preventDefault();
4027 dragOverCount = Math.max(0, dragOverCount - 1);
4028 if (dragOverCount === 0) setOver(false);
4029 });
4030 importDropZoneEl.addEventListener('dragover', (e) => {
4031 e.preventDefault();
4032 e.stopPropagation();
4033 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4034 });
4035 importDropZoneEl.addEventListener('drop', (e) => {
4036 e.preventDefault();
4037 e.stopPropagation();
4038 dragOverCount = 0;
4039 setOver(false);
4040 const msgEl = el('import-msg');
4041 const p = (async () => {
4042 if (!e.dataTransfer) {
4043 if (msgEl) {
4044 msgEl.textContent = 'Drop did not include any files.';
4045 msgEl.className = 'create-msg err';
4046 }
4047 return;
4048 }
4049 let files;
4050 try {
4051 files = await collectFilesFromDataTransfer(e.dataTransfer);
4052 } catch (dropErr) {
4053 if (msgEl) {
4054 msgEl.textContent =
4055 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4056 msgEl.className = 'create-msg err';
4057 }
4058 return;
4059 }
4060 if (!files || files.length === 0) {
4061 if (msgEl) {
4062 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4063 msgEl.className = 'create-msg err';
4064 }
4065 return;
4066 }
4067 importPendingDropFiles = files;
4068 if (importFileEl) importFileEl.value = '';
4069 if (importFileFolderEl) importFileFolderEl.value = '';
4070 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4071 updateImportDropStatusUi();
4072 if (msgEl) {
4073 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4074 msgEl.className = 'create-msg';
4075 }
4076 })();
4077 p.catch((err) => {
4078 if (el('import-msg')) {
4079 const msg = el('import-msg');
4080 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4081 msg.className = 'create-msg err';
4082 }
4083 });
4084 });
4085 }
4086 if (importBatchCancelBtn) {
4087 importBatchCancelBtn.onclick = () => {
4088 if (importBatchAbort) importBatchAbort.abort();
4089 };
4090 }
4091
4092 /**
4093 * @param {string} postPath
4094 * @param {FormData} formData
4095 * @param {Record<string, string>} importHeaders
4096 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4097 */
4098 async function hubPostImportOnce(postPath, formData, importHeaders) {
4099 let res;
4100 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4101 try {
4102 res = await fetch(postPath, {
4103 method: 'POST',
4104 cache: 'no-store',
4105 headers: importHeaders,
4106 body: formData,
4107 });
4108 break;
4109 } catch (importErr) {
4110 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4111 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4112 await new Promise((r) => setTimeout(r, 3000));
4113 continue;
4114 }
4115 return { ok: false, errText: em, status: 0 };
4116 }
4117 }
4118 const text = await res.text();
4119 let data = {};
4120 try {
4121 data = text ? JSON.parse(text) : {};
4122 } catch (_) {
4123 data = {};
4124 }
4125 if (!res.ok) {
4126 let apiErr = '';
4127 if (data && typeof data === 'object') {
4128 const parts = [data.error, data.message, data.detail].filter(
4129 (x) => x != null && String(x).trim().length > 0,
4130 );
4131 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4132 }
4133 if (!apiErr && text) {
4134 const t = text.trim();
4135 if (t.startsWith('<')) {
4136 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4137 } else {
4138 apiErr = t.slice(0, 280);
4139 }
4140 }
4141 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4142 }
4143 return { ok: true, data };
4144 }
4145
4146 el('btn-import-submit').onclick = async () => {
4147 const importSubmitBtn = el('btn-import-submit');
4148 const sourceType = el('import-source-type').value;
4149 const fileInput = el('import-file');
4150 const urlInput = el('import-url');
4151 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4152 const msgEl = el('import-msg');
4153 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4154 const kz = globalThis.knowtationHubImportZip;
4155
4156 if (!token) {
4157 msgEl.textContent = 'Sign in to import.';
4158 msgEl.className = 'create-msg err';
4159 return;
4160 }
4161 const useUrlImport = urlTrim.length > 0;
4162 if (sourceType === 'url' && !useUrlImport) {
4163 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4164 msgEl.className = 'create-msg err';
4165 return;
4166 }
4167 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4168 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4169 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4170 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4171 const fileArr = usedDrop
4172 ? importPendingDropFiles
4173 : usedFolder
4174 ? Array.from(importFileFolderEl.files)
4175 : fileInput && fileInput.files
4176 ? Array.from(fileInput.files)
4177 : [];
4178 if (sourceType === 'google-sheets' && !useUrlImport) {
4179 if (!sheetId) {
4180 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4181 msgEl.className = 'create-msg err';
4182 return;
4183 }
4184 if (fileArr.length > 0) {
4185 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4186 msgEl.className = 'create-msg err';
4187 return;
4188 }
4189 }
4190 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4191 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4192 msgEl.className = 'create-msg err';
4193 return;
4194 }
4195 if (sourceType === 'notion' && fileArr.length > 1) {
4196 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4197 msgEl.className = 'create-msg err';
4198 return;
4199 }
4200
4201 const dest = getImportProjectAndOutputDir();
4202 if (dest.err) {
4203 msgEl.textContent = dest.err;
4204 msgEl.className = 'create-msg err';
4205 return;
4206 }
4207 const project = dest.project || '';
4208 const outputDir = dest.outputDir;
4209 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4210 const urlModeEl = el('import-url-mode');
4211 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4212 const importPostPath = apiBase + '/api/v1/import';
4213 const urlPostPath = apiBase + '/api/v1/import-url';
4214 const mode =
4215 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4216 ? kz.getHubImportFileMode(sourceType, fileArr)
4217 : 'direct';
4218
4219 if (!useUrlImport && !kz && fileArr.length > 1) {
4220 msgEl.textContent =
4221 'Import helpers (JSZip) did not load. Hard-refresh the page, or import one file at a time, or pre-zip a folder and upload a single .zip.';
4222 msgEl.className = 'create-msg err';
4223 return;
4224 }
4225
4226 if (!useUrlImport && mode === 'client_zip' && !kz) {
4227 msgEl.textContent =
4228 'In-browser ZIP helper did not load (JSZip). Hard-refresh the page and try again, or pre-zip the folder and upload a single .zip.';
4229 msgEl.className = 'create-msg err';
4230 return;
4231 }
4232 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4233 msgEl.textContent =
4234 'Too many files for one batch (max ' +
4235 HUB_IMPORT_MAX_SEQUENTIAL +
4236 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4237 msgEl.className = 'create-msg err';
4238 return;
4239 }
4240
4241 if (useUrlImport) {
4242 const jsonBody = { url: urlTrim, mode: urlMode };
4243 if (project) jsonBody.project = project;
4244 if (outputDir) jsonBody.output_dir = outputDir;
4245 if (tags) jsonBody.tags = tags;
4246 msgEl.textContent = 'Importing…';
4247 msgEl.className = 'create-msg';
4248 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4249 try {
4250 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4251 const importVaultId = getCurrentVaultId();
4252 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4253 let res;
4254 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4255 try {
4256 res = await fetch(urlPostPath, {
4257 method: 'POST',
4258 cache: 'no-store',
4259 headers: importHeaders,
4260 body: JSON.stringify(jsonBody),
4261 });
4262 break;
4263 } catch (importErr) {
4264 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4265 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4266 await new Promise((r) => setTimeout(r, 3000));
4267 continue;
4268 }
4269 throw importErr;
4270 }
4271 }
4272 const text = await res.text();
4273 let data = {};
4274 try {
4275 data = text ? JSON.parse(text) : {};
4276 } catch (_) {
4277 data = {};
4278 }
4279 if (!res.ok) {
4280 let apiErr = '';
4281 if (data && typeof data === 'object') {
4282 const parts = [data.error, data.message, data.detail].filter(
4283 (x) => x != null && String(x).trim().length > 0,
4284 );
4285 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4286 }
4287 if (!apiErr && text) {
4288 const t = text.trim();
4289 if (t.startsWith('<')) {
4290 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4291 } else {
4292 apiErr = t.slice(0, 280);
4293 }
4294 }
4295 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4296 msgEl.className = 'create-msg err';
4297 return;
4298 }
4299 const count = data.count ?? data.imported?.length ?? 0;
4300 if (count === 0) {
4301 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
4302 msgEl.className = 'create-msg warn';
4303 } else {
4304 msgEl.textContent = 'Imported ' + count + ' note(s).';
4305 msgEl.className = 'create-msg ok';
4306 }
4307 if (count > 0) hubMarkSemanticIndexStale();
4308 if (typeof loadNotes === 'function') loadNotes();
4309 if (typeof loadFacets === 'function') loadFacets();
4310 if (typeof showToast === 'function') showToast('Import complete');
4311 setTimeout(() => closeImportModal(), 1500);
4312 } catch (e) {
4313 const raw = e && e.message ? String(e.message) : 'Import failed';
4314 const isNetwork =
4315 raw === 'Failed to fetch' ||
4316 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4317 msgEl.textContent = isNetwork
4318 ? raw +
4319 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
4320 : raw;
4321 msgEl.className = 'create-msg err';
4322 }
4323 });
4324 return;
4325 }
4326
4327 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
4328 const importVaultId = getCurrentVaultId();
4329 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
4330
4331 if (mode === 'sequential') {
4332 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
4333 importBatchAbort = new AbortController();
4334 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
4335 msgEl.className = 'create-msg';
4336 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
4337 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4338 const failures = [];
4339 let totalImported = 0;
4340 let okN = 0;
4341 for (let i = 0; i < fileArr.length; i++) {
4342 if (importBatchAbort && importBatchAbort.signal.aborted) {
4343 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
4344 break;
4345 }
4346 const f = fileArr[i];
4347 try {
4348 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
4349 } catch (limErr) {
4350 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
4351 continue;
4352 }
4353 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
4354 const fd = new FormData();
4355 fd.append('source_type', sourceType);
4356 fd.append('file', f);
4357 if (project) fd.append('project', project);
4358 if (outputDir) fd.append('output_dir', outputDir);
4359 if (tags) fd.append('tags', tags);
4360 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4361 if (r.ok && r.data) {
4362 const c = r.data.count ?? r.data.imported?.length ?? 0;
4363 totalImported += typeof c === 'number' ? c : 0;
4364 okN++;
4365 } else {
4366 failures.push({ name: f.name, err: r.errText || 'error' });
4367 }
4368 }
4369 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4370 importBatchAbort = null;
4371 const fl = failures.length
4372 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
4373 : '.';
4374 msgEl.textContent =
4375 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
4376 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
4377 setImportBatchAria(msgEl.textContent);
4378 if (totalImported > 0) hubMarkSemanticIndexStale();
4379 if (typeof loadNotes === 'function') loadNotes();
4380 if (typeof loadFacets === 'function') loadFacets();
4381 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
4382 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
4383 });
4384 return;
4385 }
4386
4387 msgEl.textContent = 'Importing…';
4388 msgEl.className = 'create-msg';
4389 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4390 try {
4391 if (sourceType === 'google-sheets') {
4392 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
4393 ? el('import-spreadsheet-id').value.trim()
4394 : '';
4395 if (!sid) {
4396 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
4397 msgEl.className = 'create-msg err';
4398 return;
4399 }
4400 const rEl = el('import-sheets-range');
4401 const range = rEl && rEl.value ? rEl.value.trim() : '';
4402 const fd = new FormData();
4403 fd.append('source_type', 'google-sheets');
4404 fd.append('spreadsheet_id', sid);
4405 if (range) fd.append('sheets_range', range);
4406 if (project) fd.append('project', project);
4407 if (outputDir) fd.append('output_dir', outputDir);
4408 if (tags) fd.append('tags', tags);
4409 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4410 if (!r.ok) {
4411 msgEl.textContent = r.errText || 'Import failed';
4412 msgEl.className = 'create-msg err';
4413 return;
4414 }
4415 const data = r.data || {};
4416 const count = data.count ?? data.imported?.length ?? 0;
4417 if (count === 0) {
4418 msgEl.textContent =
4419 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
4420 msgEl.className = 'create-msg warn';
4421 } else {
4422 msgEl.textContent = 'Imported ' + count + ' note(s).';
4423 msgEl.className = 'create-msg ok';
4424 }
4425 if (count > 0) hubMarkSemanticIndexStale();
4426 if (typeof loadNotes === 'function') loadNotes();
4427 if (typeof loadFacets === 'function') loadFacets();
4428 if (typeof showToast === 'function') showToast('Import complete');
4429 setTimeout(() => closeImportModal(), 1500);
4430 return;
4431 }
4432 const dupWarn = [];
4433 const warnFn = (s) => {
4434 dupWarn.push(s);
4435 };
4436 /** @type {FormData} */
4437 let formData;
4438 if (mode === 'client_zip' && kz) {
4439 const blob = await kz.buildImportZipBlob(fileArr, {
4440 signal: null,
4441 warn: warnFn,
4442 });
4443 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
4444 formData = new FormData();
4445 formData.append('source_type', sourceType);
4446 formData.append('file', fileOut);
4447 if (project) formData.append('project', project);
4448 if (outputDir) formData.append('output_dir', outputDir);
4449 if (tags) formData.append('tags', tags);
4450 if (dupWarn.length) {
4451 msgEl.className = 'create-msg';
4452 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
4453 }
4454 } else {
4455 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
4456 try {
4457 kz.assertSingleFileWithinLimit(fileArr[0]);
4458 } catch (e1) {
4459 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
4460 msgEl.className = 'create-msg err';
4461 return;
4462 }
4463 }
4464 formData = new FormData();
4465 formData.append('source_type', sourceType);
4466 formData.append('file', fileArr[0]);
4467 if (project) formData.append('project', project);
4468 if (outputDir) formData.append('output_dir', outputDir);
4469 if (tags) formData.append('tags', tags);
4470 }
4471 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
4472 if (!r.ok) {
4473 msgEl.textContent = r.errText || 'Import failed';
4474 msgEl.className = 'create-msg err';
4475 return;
4476 }
4477 const data = r.data || {};
4478 const count = data.count ?? data.imported?.length ?? 0;
4479 let extra = '';
4480 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
4481 if (count === 0) {
4482 const zeroMsg =
4483 sourceType === 'markdown'
4484 ? 'Imported 0 notes. This ZIP or folder had no Markdown files we could use—only .md / .markdown (any case). Other formats are skipped unless you pick the matching source type (e.g. PDF or DOCX).'
4485 : sourceType === 'pdf'
4486 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
4487 : sourceType === 'docx'
4488 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
4489 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
4490 msgEl.textContent = zeroMsg + extra;
4491 msgEl.className = 'create-msg warn';
4492 } else {
4493 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
4494 msgEl.className = 'create-msg ok';
4495 }
4496 if (count > 0) hubMarkSemanticIndexStale();
4497 if (typeof loadNotes === 'function') loadNotes();
4498 if (typeof loadFacets === 'function') loadFacets();
4499 if (typeof showToast === 'function') showToast('Import complete');
4500 setTimeout(() => closeImportModal(), 1500);
4501 } catch (e) {
4502 const raw = e && e.message ? String(e.message) : 'Import failed';
4503 if (e && e.name === 'AbortError') {
4504 msgEl.textContent = 'Cancelled.';
4505 } else {
4506 const isNetwork =
4507 raw === 'Failed to fetch' ||
4508 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4509 msgEl.textContent = isNetwork
4510 ? raw +
4511 ' — Often: CORS, upload too large for the gateway, or timeout. Video/audio need self-hosted Hub plus OPENAI_API_KEY. On hosted, check Network for POST /api/v1/import.'
4512 : raw;
4513 }
4514 msgEl.className = 'create-msg err';
4515 }
4516 });
4517 };
4518
4519 function openHowToUse(tabId, scrollToId) {
4520 const id = tabId || 'setup';
4521 el('modal-how-to-use').classList.remove('hidden');
4522 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
4523 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
4524 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
4525 if (scrollToId) {
4526 requestAnimationFrame(() => {
4527 const target = document.getElementById(scrollToId);
4528 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4529 });
4530 }
4531 }
4532 function closeHowToUse() {
4533 el('modal-how-to-use').classList.add('hidden');
4534 }
4535 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
4536 const btnLoginHowToUse = el('btn-login-how-to-use');
4537 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
4538 const btnSettingsHelp = el('btn-settings-help');
4539 if (btnSettingsHelp) {
4540 btnSettingsHelp.onclick = () => {
4541 closeSettings();
4542 openHowToUse('knowledge-agents');
4543 };
4544 }
4545 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
4546 el('modal-how-to-use-close').onclick = closeHowToUse;
4547
4548 document.querySelectorAll('.how-to-tab').forEach((tab) => {
4549 tab.addEventListener('click', () => {
4550 const id = tab.dataset.howToTab;
4551 document.querySelectorAll('.how-to-tab').forEach((t) => {
4552 t.classList.toggle('active', t.dataset.howToTab === id);
4553 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
4554 });
4555 document.querySelectorAll('.how-to-panel').forEach((p) => {
4556 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
4557 });
4558 });
4559 });
4560
4561 const modalHowTo = el('modal-how-to-use');
4562 if (modalHowTo) {
4563 modalHowTo.addEventListener('click', (e) => {
4564 const t = e.target;
4565 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
4566 e.preventDefault();
4567 openHowToUse('consolidation');
4568 }
4569 });
4570 }
4571
4572 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
4573 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
4574 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
4575 btnHowToOpenOnboarding.addEventListener('click', () => {
4576 closeHowToUse();
4577 void openOnboardingWizard({ restart: false });
4578 });
4579 }
4580 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
4581 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
4582 btnEmptyStripWizard.dataset.knowtationBound = '1';
4583 btnEmptyStripWizard.addEventListener('click', () => {
4584 void openOnboardingWizard({ restart: true });
4585 });
4586 }
4587 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
4588 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
4589 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
4590 btnEmptyStripGettingStarted.addEventListener('click', () => {
4591 openHowToUse('getting-started');
4592 });
4593 }
4594
4595 function openTokenSavingsHowToFromSettings() {
4596 closeSettings();
4597 openHowToUse('token-savings');
4598 }
4599 const btnConsolToken = el('btn-consol-how-token-savings');
4600 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4601 const btnIntegToken = el('btn-integrations-how-token-savings');
4602 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4603 const btnAgentsToken = el('btn-agents-how-token-savings');
4604 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4605
4606 function openSettings() {
4607 refreshApiBaseFootgunBanner();
4608 closeCreateModal();
4609 el('modal-settings').classList.remove('hidden');
4610 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
4611 document.querySelectorAll('.settings-panel').forEach((p) => {
4612 p.classList.toggle('active', p.id === 'settings-panel-backup');
4613 });
4614 syncAccentUI();
4615 syncThemeUI();
4616 syncColorPaletteUI();
4617 refreshIntegApiStatus();
4618 el('settings-sync-msg').textContent = '';
4619 el('settings-sync-msg').className = 'settings-msg';
4620 el('settings-save-msg').textContent = '';
4621 el('settings-save-msg').className = 'settings-msg';
4622 const policyMsg = el('settings-proposal-policy-msg');
4623 if (policyMsg) {
4624 policyMsg.textContent = '';
4625 policyMsg.className = 'settings-msg';
4626 }
4627 el('settings-mode-display').textContent = 'Loading…';
4628 el('settings-vault-display').textContent = 'Loading…';
4629 el('settings-git-status').textContent = 'Loading…';
4630 const ghStatus = el('settings-github-status');
4631 if (ghStatus) ghStatus.textContent = 'Loading…';
4632 fetchSettingsForBackupModal()
4633 .then((s) => {
4634 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
4635 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
4636 throw new Error(
4637 'Settings API returned an empty or invalid JSON body. In DevTools → Network, click the "settings" request → Response. You should see an object with role, user_id, vault_path_display. If the body is empty, fix the gateway/proxy or API route.',
4638 );
4639 }
4640 applySettingsPayloadToHubChrome(s);
4641 const roleEl = el('settings-role-display');
4642 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
4643 const userIdEl = el('settings-user-id');
4644 if (userIdEl) userIdEl.textContent = s.user_id || '—';
4645 const vaultDisplay = s.vault_path_display || '—';
4646 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
4647 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
4648 el('settings-vault-display').textContent = vaultDisplay;
4649 const configureSection = el('settings-configure-backup-section');
4650 const configureHr = el('settings-hr-configure');
4651 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
4652 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
4653 const vg = s.vault_git || {};
4654 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
4655 const step1 = document.getElementById('setup-step-1');
4656 const step4 = document.getElementById('setup-step-4');
4657 const step1Label = el('setup-step-1-label');
4658 const step1Hint = el('setup-step-1-hint');
4659 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
4660 if (step1Hint) {
4661 step1Hint.textContent = isHosted
4662 ? 'Your notes live in your hosted vault'
4663 : 'Set below under Configure backup';
4664 }
4665 if (step1) {
4666 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
4667 step1.classList.toggle('setup-step-done', done);
4668 const icon = step1.querySelector('.setup-step-icon');
4669 if (icon) icon.textContent = done ? '✓' : '';
4670 }
4671 if (step4) {
4672 const done = !!(vg.enabled && vg.has_remote);
4673 step4.classList.toggle('setup-step-done', done);
4674 const icon = step4.querySelector('.setup-step-icon');
4675 if (icon) icon.textContent = done ? '✓' : '';
4676 }
4677 let gitText = 'Not configured';
4678 if (vg.enabled && vg.has_remote) {
4679 gitText = 'Configured';
4680 if (vg.auto_commit) gitText += ' (auto-commit on)';
4681 if (vg.auto_push) gitText += ', auto-push on';
4682 } else if (vg.enabled) gitText = 'Enabled but no remote set';
4683 el('settings-git-status').textContent = gitText;
4684 const evalReqEl = el('settings-proposal-eval-required');
4685 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
4686 const hintsEl = el('settings-proposal-hints-enabled');
4687 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
4688 const enrichStatusEl = el('settings-proposal-enrich-enabled');
4689 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
4690 const evApEl = el('settings-evaluator-may-approve');
4691 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
4692 const syncBtn = el('btn-settings-sync');
4693 const isAdmin = s.role === 'admin';
4694 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
4695 const saveSetupBtn = el('btn-settings-save');
4696 if (saveSetupBtn) {
4697 saveSetupBtn.disabled = false;
4698 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
4699 }
4700 const teamTab = el('settings-tab-team');
4701 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
4702 const vaultsTab = el('settings-tab-vaults');
4703 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
4704 const policyAdmin = el('settings-proposal-policy-admin');
4705 const storedPolicy = s.proposal_policy_stored || {};
4706 const policyLocks = s.proposal_policy_env_locked || {};
4707 if (policyAdmin) {
4708 policyAdmin.classList.toggle('hidden', !isAdmin);
4709 const cEval = el('settings-policy-eval');
4710 const cHints = el('settings-policy-hints');
4711 const cEnrich = el('settings-policy-enrich');
4712 if (cEval && cHints && cEnrich) {
4713 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
4714 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
4715 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
4716 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
4717 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
4718 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
4719 const lockHint =
4720 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4721 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
4722 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
4723 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
4724 }
4725 }
4726 const connectBtn = el('btn-connect-github');
4727 const ghStatus = el('settings-github-status');
4728 const hostedGhHint = el('settings-hosted-connect-github-hint');
4729 if (s.github_connect_available) {
4730 if (connectBtn) {
4731 connectBtn.classList.remove('hidden');
4732 connectBtn.onclick = () => {
4733 const base = apiBase.replace(/\/$/, '');
4734 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
4735 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
4736 };
4737 }
4738 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
4739 } else {
4740 if (connectBtn) {
4741 connectBtn.classList.add('hidden');
4742 connectBtn.onclick = null;
4743 }
4744 if (ghStatus) ghStatus.textContent = '—';
4745 }
4746 if (hostedGhHint) {
4747 const vd = s.vault_path_display || '';
4748 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
4749 }
4750 const hostedRepoSection = el('settings-hosted-backup-repo-section');
4751 const hostedRepoInput = el('settings-hosted-repo');
4752 if (hostedRepoSection) {
4753 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
4754 }
4755 if (hostedRepoInput && isHosted && s.github_connect_available) {
4756 if (!hostedRepoInput.value.trim()) {
4757 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
4758 }
4759 if (!hostedRepoInput.dataset.knowtationBound) {
4760 hostedRepoInput.dataset.knowtationBound = '1';
4761 hostedRepoInput.addEventListener('input', () => {
4762 const syncBtn = el('btn-settings-sync');
4763 if (!syncBtn || !lastBackupSettingsPayload) return;
4764 const vd = lastBackupSettingsPayload.vault_path_display || '';
4765 const ih = (vd + '').toLowerCase() === 'canister';
4766 if (ih && lastBackupSettingsPayload.github_connect_available) {
4767 const vg = lastBackupSettingsPayload.vault_git || {};
4768 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
4769 }
4770 });
4771 }
4772 }
4773 const ed = s.embedding_display || {};
4774 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
4775 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
4776 const ollamaRow = el('agents-ollama-row');
4777 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
4778 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
4779 applyChatProviderSettings(s);
4780 const apiRow = el('settings-api-base-row');
4781 const apiDisp = el('settings-api-base-display');
4782 if (apiRow && apiDisp) {
4783 if (isLocalHubHostname()) {
4784 apiRow.classList.remove('hidden');
4785 apiDisp.textContent = apiBase;
4786 } else {
4787 apiRow.classList.add('hidden');
4788 }
4789 }
4790 refreshApiBaseFootgunBanner();
4791 void refreshBulkDeletePresetDropdowns();
4792 })
4793 .catch((e) => {
4794 const syncMsg = el('settings-sync-msg');
4795 if (syncMsg) {
4796 const m = e && e.message ? String(e.message) : 'Could not load settings.';
4797 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
4798 syncMsg.className = 'settings-msg err';
4799 }
4800 if (typeof console !== 'undefined' && console.error) {
4801 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
4802 }
4803 const hostedGhHint = el('settings-hosted-connect-github-hint');
4804 if (hostedGhHint) hostedGhHint.classList.add('hidden');
4805 const roleEl = el('settings-role-display');
4806 if (roleEl) roleEl.textContent = '—';
4807 const userIdEl = el('settings-user-id');
4808 if (userIdEl) userIdEl.textContent = '—';
4809 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
4810 el('settings-vault-display').textContent = '—';
4811 el('settings-git-status').textContent = 'Could not load';
4812 const evalReqErr = el('settings-proposal-eval-required');
4813 if (evalReqErr) evalReqErr.textContent = '—';
4814 const hintsErr = el('settings-proposal-hints-enabled');
4815 if (hintsErr) hintsErr.textContent = '—';
4816 const enrichErr = el('settings-proposal-enrich-enabled');
4817 if (enrichErr) enrichErr.textContent = '—';
4818 const evApErr = el('settings-evaluator-may-approve');
4819 if (evApErr) evApErr.textContent = '—';
4820 const configureSection = el('settings-configure-backup-section');
4821 const configureHr = el('settings-hr-configure');
4822 if (configureSection) configureSection.style.display = '';
4823 if (configureHr) configureHr.style.display = '';
4824 const ghStatus = el('settings-github-status');
4825 if (ghStatus) ghStatus.textContent = '—';
4826 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
4827 const apiRowErr = el('settings-api-base-row');
4828 const apiDispErr = el('settings-api-base-display');
4829 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
4830 apiRowErr.classList.remove('hidden');
4831 apiDispErr.textContent = apiBase;
4832 }
4833 refreshApiBaseFootgunBanner();
4834 });
4835 api('/api/v1/setup')
4836 .then((u) => {
4837 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
4838 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
4839 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
4840 })
4841 .catch(() => {});
4842 }
4843 function closeSettings() {
4844 el('modal-settings').classList.add('hidden');
4845 }
4846 function openSettingsBillingTab() {
4847 openSettings();
4848 document.querySelectorAll('.settings-tab').forEach((t) => {
4849 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
4850 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
4851 });
4852 document.querySelectorAll('.settings-panel').forEach((p) => {
4853 p.classList.toggle('active', p.id === 'settings-panel-billing');
4854 });
4855 loadBillingPanel();
4856 }
4857
4858 function openSettingsIntegrationsTab() {
4859 openSettings();
4860 document.querySelectorAll('.settings-tab').forEach((t) => {
4861 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
4862 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
4863 });
4864 document.querySelectorAll('.settings-panel').forEach((p) => {
4865 p.classList.toggle('active', p.id === 'settings-panel-integrations');
4866 });
4867 refreshIntegApiStatus();
4868 applyMuseBridgePanel(lastBackupSettingsPayload);
4869 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
4870 }
4871
4872 if (btnSettings) btnSettings.onclick = openSettings;
4873
4874 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
4875 if (btnSettingsSetupGuide) {
4876 btnSettingsSetupGuide.addEventListener('click', () => {
4877 closeSettings();
4878 void openOnboardingWizard({ restart: true });
4879 });
4880 }
4881
4882 const btnProposalPolicySave = el('btn-proposal-policy-save');
4883 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
4884 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
4885 btnProposalPolicySave.addEventListener('click', async () => {
4886 const msg = el('settings-proposal-policy-msg');
4887 if (msg) {
4888 msg.textContent = '';
4889 msg.className = 'settings-msg';
4890 }
4891 try {
4892 await api('/api/v1/settings/proposal-policy', {
4893 method: 'POST',
4894 body: JSON.stringify({
4895 proposal_evaluation_required: el('settings-policy-eval').checked,
4896 review_hints_enabled: el('settings-policy-hints').checked,
4897 enrich_enabled: el('settings-policy-enrich').checked,
4898 }),
4899 });
4900 if (msg) {
4901 msg.textContent = 'Saved.';
4902 msg.className = 'settings-msg ok';
4903 }
4904 const fresh = await fetchSettingsForBackupModal();
4905 applySettingsPayloadToHubChrome(fresh);
4906 const evalReqEl = el('settings-proposal-eval-required');
4907 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
4908 const hintsEl2 = el('settings-proposal-hints-enabled');
4909 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
4910 const enrichEl2 = el('settings-proposal-enrich-enabled');
4911 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
4912 const st = fresh.proposal_policy_stored || {};
4913 const lk = fresh.proposal_policy_env_locked || {};
4914 const ce = el('settings-policy-eval');
4915 const ch = el('settings-policy-hints');
4916 const cr = el('settings-policy-enrich');
4917 if (ce && ch && cr) {
4918 ce.checked = Boolean(st.proposal_evaluation_required);
4919 ch.checked = Boolean(st.review_hints_enabled);
4920 cr.checked = Boolean(st.enrich_enabled);
4921 ce.disabled = Boolean(lk.proposal_evaluation_required);
4922 ch.disabled = Boolean(lk.review_hints_enabled);
4923 cr.disabled = Boolean(lk.enrich_enabled);
4924 const lockHint =
4925 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
4926 ce.title = lk.proposal_evaluation_required ? lockHint : '';
4927 ch.title = lk.review_hints_enabled ? lockHint : '';
4928 cr.title = lk.enrich_enabled ? lockHint : '';
4929 }
4930 } catch (e) {
4931 if (msg) {
4932 msg.textContent = e && e.message ? String(e.message) : String(e);
4933 msg.className = 'settings-msg err';
4934 }
4935 }
4936 });
4937 }
4938 el('modal-settings-backdrop').onclick = closeSettings;
4939 el('modal-settings-close').onclick = closeSettings;
4940
4941 el('btn-copy-env-agentception').onclick = () => {
4942 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
4943 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
4944 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
4945 const lines = [];
4946 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
4947 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
4948 }
4949 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
4950 const snippet = lines.join('\n');
4951 const msg = el('agents-copy-msg');
4952 if (navigator.clipboard && navigator.clipboard.writeText) {
4953 navigator.clipboard.writeText(snippet).then(() => {
4954 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
4955 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
4956 }).catch(() => {
4957 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
4958 });
4959 } else {
4960 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
4961 }
4962 };
4963
4964 function refreshIntegApiStatus() {
4965 var dot = el('integ-api-status');
4966 if (!dot) return;
4967 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
4968 dot.classList.toggle('active', hasToken);
4969 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
4970 }
4971
4972 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
4973 let activeIntegGuide = null;
4974
4975 function closeIntegGuideModal() {
4976 const modal = el('modal-integ-guide');
4977 if (modal) modal.classList.add('hidden');
4978 activeIntegGuide = null;
4979 }
4980
4981 function openIntegGuideModal(guide) {
4982 const mod = globalThis.HubIntegrationGuides;
4983 if (!mod || !guide) return;
4984 const modal = el('modal-integ-guide');
4985 const iconEl = el('modal-integ-guide-icon');
4986 const nameEl = el('modal-integ-guide-name');
4987 const leadEl = el('modal-integ-guide-lead');
4988 const contentEl = el('modal-integ-guide-content');
4989 const importBtn = el('btn-integ-guide-import');
4990 const teamBtn = el('btn-integ-guide-team');
4991 const msgEl = el('modal-integ-guide-msg');
4992 if (!modal || !contentEl) return;
4993 activeIntegGuide = guide;
4994 if (iconEl) iconEl.textContent = guide.icon || '';
4995 if (nameEl) nameEl.textContent = guide.name || 'Integration';
4996 if (leadEl) {
4997 leadEl.textContent =
4998 guide.kind === 'capture'
4999 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
5000 : guide.desc || 'Import files or exports into your vault.';
5001 }
5002 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5003 if (msgEl) msgEl.textContent = '';
5004 if (importBtn) {
5005 const importSel = el('import-source-type');
5006 const canPreselect =
5007 guide.hubImport &&
5008 guide.sourceType &&
5009 importSel &&
5010 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5011 const showImport =
5012 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5013 importBtn.classList.toggle('hidden', !showImport);
5014 importBtn.textContent =
5015 guide.id === 'hermes'
5016 ? 'Open Import (Markdown)'
5017 : guide.id === 'imports'
5018 ? 'Open Import'
5019 : 'Open Import';
5020 }
5021 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5022 modal.classList.remove('hidden');
5023 }
5024
5025 let integGuideControlsBound = false;
5026
5027 function bindIntegrationGuideModalControlsOnce() {
5028 if (integGuideControlsBound) return;
5029 integGuideControlsBound = true;
5030 const backdrop = el('modal-integ-guide-backdrop');
5031 const closeBtn = el('modal-integ-guide-close');
5032 const importBtn = el('btn-integ-guide-import');
5033 const teamBtn = el('btn-integ-guide-team');
5034 const contentEl = el('modal-integ-guide-content');
5035 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5036 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5037 if (contentEl) {
5038 contentEl.addEventListener('click', (ev) => {
5039 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5040 if (!btn) return;
5041 const text = btn.getAttribute('data-copy') || '';
5042 const msgEl = el('modal-integ-guide-msg');
5043 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5044 navigator.clipboard.writeText(text).then(() => {
5045 if (msgEl) {
5046 msgEl.textContent = 'Copied.';
5047 msgEl.className = 'settings-msg ok';
5048 }
5049 setTimeout(() => {
5050 if (msgEl) msgEl.textContent = '';
5051 }, 2000);
5052 }).catch(() => {
5053 if (msgEl) {
5054 msgEl.textContent = 'Copy failed';
5055 msgEl.className = 'settings-msg err';
5056 }
5057 });
5058 } else if (msgEl) {
5059 msgEl.textContent = 'Clipboard not available';
5060 msgEl.className = 'settings-msg err';
5061 }
5062 });
5063 }
5064 if (importBtn) {
5065 importBtn.onclick = () => {
5066 const guide = activeIntegGuide;
5067 closeIntegGuideModal();
5068 closeSettings();
5069 const preselect =
5070 guide && guide.id === 'hermes'
5071 ? 'markdown'
5072 : guide && guide.sourceType
5073 ? guide.sourceType
5074 : undefined;
5075 openImportModal(preselect);
5076 };
5077 }
5078 if (teamBtn) {
5079 teamBtn.onclick = () => {
5080 closeIntegGuideModal();
5081 openSettings();
5082 document.querySelectorAll('.settings-tab').forEach((t) => {
5083 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5084 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5085 });
5086 document.querySelectorAll('.settings-panel').forEach((p) => {
5087 p.classList.toggle('active', p.id === 'settings-panel-team');
5088 });
5089 };
5090 }
5091 document.addEventListener('click', (ev) => {
5092 const tile =
5093 ev.target instanceof Element
5094 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5095 : null;
5096 if (!tile) return;
5097 const mod = globalThis.HubIntegrationGuides;
5098 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5099 if (typeof showToast === 'function') {
5100 showToast('Integration details still loading — try again in a moment.', true);
5101 }
5102 scheduleIntegrationGuidesInit(0);
5103 return;
5104 }
5105 const id = tile.getAttribute('data-integ-id');
5106 const guide = id ? mod.getIntegrationGuide(id) : null;
5107 if (guide) {
5108 ev.preventDefault();
5109 openIntegGuideModal(guide);
5110 }
5111 });
5112 }
5113
5114 function scheduleIntegrationGuidesInit(attempt) {
5115 bindIntegrationGuideModalControlsOnce();
5116 if (globalThis.HubIntegrationGuides) return;
5117 if (attempt >= 80) return;
5118 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5119 }
5120
5121 scheduleIntegrationGuidesInit(0);
5122
5123 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5124 if (btnCopyMcpPrime) {
5125 btnCopyMcpPrime.onclick = () => {
5126 const base = String(apiBase || '').replace(/\/$/, '');
5127 const vaultId = getCurrentVaultId() || 'default';
5128 const msg = el('integrations-hub-api-copy-msg');
5129 const payload = {
5130 schema: 'knowtation.hub_copy_prime/v1',
5131 mcp_read_resource_uri: 'knowtation://hosted/prime',
5132 instructions:
5133 '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 ' +
5134 INTEGRATION_DOC_URL,
5135 KNOWTATION_HUB_URL: base,
5136 KNOWTATION_HUB_VAULT_ID: vaultId,
5137 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5138 };
5139 const snippet = JSON.stringify(payload, null, 2);
5140 if (navigator.clipboard && navigator.clipboard.writeText) {
5141 navigator.clipboard.writeText(snippet).then(() => {
5142 if (msg) {
5143 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5144 msg.className = 'settings-msg';
5145 }
5146 setTimeout(() => {
5147 if (msg) msg.textContent = '';
5148 }, 2800);
5149 }).catch(() => {
5150 if (msg) {
5151 msg.textContent = 'Copy failed';
5152 msg.className = 'settings-msg err';
5153 }
5154 });
5155 } else if (msg) {
5156 msg.textContent = 'Clipboard not available';
5157 msg.className = 'settings-msg err';
5158 }
5159 };
5160 }
5161
5162 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5163 if (btnCopyHubApiEnv) {
5164 btnCopyHubApiEnv.onclick = () => {
5165 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5166 const vaultId = getCurrentVaultId() || 'default';
5167 const base = String(apiBase || '').replace(/\/$/, '');
5168 const msg = el('integrations-hub-api-copy-msg');
5169 if (!hubTok) {
5170 if (msg) {
5171 msg.textContent = 'Sign in first, then copy again.';
5172 msg.className = 'settings-msg err';
5173 }
5174 return;
5175 }
5176 const copyLines = [
5177 'KNOWTATION_HUB_URL=' + base,
5178 'KNOWTATION_HUB_TOKEN=' + hubTok,
5179 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5180 ];
5181 if (mcpPublicUrl !== '') {
5182 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5183 }
5184 copyLines.push('');
5185 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5186 copyLines.push(
5187 '# Example curl (append these headers to any Hub REST call): ' +
5188 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5189 '-H "Content-Type: application/json" ' +
5190 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5191 );
5192 const snippet = copyLines.join('\n');
5193 if (navigator.clipboard && navigator.clipboard.writeText) {
5194 navigator.clipboard.writeText(snippet).then(() => {
5195 if (msg) {
5196 msg.textContent = 'Copied session access token (expires — not for always-on agents).';
5197 msg.className = 'settings-msg';
5198 }
5199 refreshIntegApiStatus();
5200 setTimeout(() => {
5201 if (msg) msg.textContent = '';
5202 }, 3500);
5203 }).catch(() => {
5204 if (msg) {
5205 msg.textContent = 'Copy failed';
5206 msg.className = 'settings-msg err';
5207 }
5208 });
5209 } else if (msg) {
5210 msg.textContent = 'Clipboard not available';
5211 msg.className = 'settings-msg err';
5212 }
5213 };
5214 }
5215
5216 /** Settings → Integrations → Connect cloud agent (RFC 8628 device approval). */
5217 /** Device auth mounts on the persistent MCP host — not Netlify api.knowtation.store. */
5218 function deviceAuthBase() {
5219 if (mcpPublicUrl) {
5220 try {
5221 const u = new URL(mcpPublicUrl);
5222 return u.origin;
5223 } catch (_) { /* fall through */ }
5224 }
5225 return String(apiBase || '').replace(/\/$/, '');
5226 }
5227
5228 function setDeviceConnectMsg(text, isErr) {
5229 const msg = el('device-connect-msg');
5230 if (!msg) return;
5231 msg.textContent = text || '';
5232 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5233 }
5234
5235 async function refreshDevicePendingList() {
5236 const list = el('device-pending-list');
5237 if (!list) return;
5238 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5239 if (!hubTok) {
5240 list.innerHTML = '<li>Sign in to see pending agent codes.</li>';
5241 return;
5242 }
5243 try {
5244 const res = await fetch(deviceAuthBase() + '/api/v1/auth/device/pending', {
5245 headers: { Authorization: 'Bearer ' + hubTok },
5246 credentials: 'omit',
5247 });
5248 if (!res.ok) {
5249 list.innerHTML = '<li>Pending list unavailable on this host (device auth mounts on the persistent MCP gateway).</li>';
5250 return;
5251 }
5252 const data = await res.json();
5253 const pending = Array.isArray(data.pending) ? data.pending : [];
5254 if (pending.length === 0) {
5255 list.innerHTML = '<li>No pending cloud-agent codes.</li>';
5256 return;
5257 }
5258 list.innerHTML = pending
5259 .map(function (p) {
5260 const code = String(p.userCode || '').replace(/[<>&]/g, '');
5261 const name = String(p.clientName || p.clientId || 'agent').replace(/[<>&]/g, '');
5262 return '<li><strong>' + code + '</strong> — ' + name + '</li>';
5263 })
5264 .join('');
5265 } catch (_) {
5266 list.innerHTML = '<li>Could not load pending codes.</li>';
5267 }
5268 }
5269
5270 async function postDeviceApproveOrDeny(path) {
5271 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5272 const input = el('device-user-code-input');
5273 const userCode = input ? String(input.value || '').trim() : '';
5274 if (!hubTok) {
5275 setDeviceConnectMsg('Sign in first.', true);
5276 return;
5277 }
5278 if (!userCode) {
5279 setDeviceConnectMsg('Enter the user code shown by your agent.', true);
5280 return;
5281 }
5282 try {
5283 const res = await fetch(deviceAuthBase() + path, {
5284 method: 'POST',
5285 headers: {
5286 Authorization: 'Bearer ' + hubTok,
5287 'Content-Type': 'application/json',
5288 },
5289 credentials: 'omit',
5290 body: JSON.stringify({
5291 user_code: userCode,
5292 vault_id: getCurrentVaultId() || 'default',
5293 }),
5294 });
5295 const data = await res.json().catch(function () { return {}; });
5296 if (!res.ok) {
5297 setDeviceConnectMsg(data.error || ('Request failed (' + res.status + ')'), true);
5298 return;
5299 }
5300 setDeviceConnectMsg(path.indexOf('deny') >= 0 ? 'Denied.' : 'Approved — agent can finish polling.', false);
5301 if (input) input.value = '';
5302 refreshDevicePendingList();
5303 } catch (_) {
5304 setDeviceConnectMsg('Network error talking to device auth endpoint.', true);
5305 }
5306 }
5307
5308 const btnDeviceApprove = el('btn-device-approve');
5309 if (btnDeviceApprove) {
5310 btnDeviceApprove.onclick = function () {
5311 postDeviceApproveOrDeny('/api/v1/auth/device/approve');
5312 };
5313 }
5314 const btnDeviceDeny = el('btn-device-deny');
5315 if (btnDeviceDeny) {
5316 btnDeviceDeny.onclick = function () {
5317 postDeviceApproveOrDeny('/api/v1/auth/device/deny');
5318 };
5319 }
5320 const btnDeviceRefreshPending = el('btn-device-refresh-pending');
5321 if (btnDeviceRefreshPending) {
5322 btnDeviceRefreshPending.onclick = function () {
5323 refreshDevicePendingList();
5324 };
5325 }
5326 const btnCopyCloudSetupPack = el('btn-copy-cloud-setup-pack');
5327 if (btnCopyCloudSetupPack) {
5328 btnCopyCloudSetupPack.onclick = function () {
5329 const pack =
5330 '# Knowtation cloud agent setup (NO SECRETS)\n' +
5331 '# MCP URL: https://mcp.knowtation.store/mcp\n' +
5332 '# Prefer: Hub Settings → Integrations → Connect cloud agent (device code)\n' +
5333 '# Interim (Hostinger Hermes): desktop mcp-remote OAuth → copy ~/.mcp-auth/mcp-remote-* to agent HOME\n' +
5334 '# → Hermes stdio: npx -y mcp-remote https://mcp.knowtation.store/mcp\n' +
5335 '# DO NOT: paste Hub session JWT into always-on .env\n' +
5336 '# DO NOT: use api.knowtation.store/mcp or Netlify /mcp\n' +
5337 '# Full guide: docs/AGENT-INTEGRATION.md (Always-on cloud agents)\n';
5338 if (navigator.clipboard && navigator.clipboard.writeText) {
5339 navigator.clipboard.writeText(pack).then(function () {
5340 setDeviceConnectMsg('Copied non-secret setup pack.', false);
5341 }).catch(function () {
5342 setDeviceConnectMsg('Copy failed', true);
5343 });
5344 } else {
5345 setDeviceConnectMsg('Clipboard not available', true);
5346 }
5347 };
5348 }
5349 try {
5350 var _ucParams = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
5351 var _uc = _ucParams ? _ucParams.get('user_code') : null;
5352 if (!_uc && typeof location !== 'undefined' && location.hash && location.hash.indexOf('user_code=') >= 0) {
5353 var _hq = location.hash.split('?')[1] || '';
5354 _uc = new URLSearchParams(_hq).get('user_code');
5355 }
5356 if (_uc && el('device-user-code-input')) {
5357 el('device-user-code-input').value = String(_uc).toUpperCase();
5358 }
5359 } catch (_) { /* ignore */ }
5360 refreshDevicePendingList();
5361
5362 const btnSettingsMuseSave = el('btn-settings-muse-save');
5363 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
5364 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
5365 btnSettingsMuseSave.addEventListener('click', async () => {
5366 const msg = el('settings-muse-msg');
5367 if (msg) {
5368 msg.textContent = '';
5369 msg.className = 'settings-msg';
5370 }
5371 const input = el('settings-muse-url');
5372 const url = input ? String(input.value || '').trim() : '';
5373 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
5374 try {
5375 await api('/api/v1/settings/muse', {
5376 method: 'POST',
5377 body: JSON.stringify({ url }),
5378 });
5379 if (msg) {
5380 msg.textContent = 'Saved.';
5381 msg.className = 'settings-msg ok';
5382 }
5383 const s = await api('/api/v1/settings');
5384 applySettingsPayloadToHubChrome(s);
5385 } catch (e) {
5386 if (msg) {
5387 msg.textContent =
5388 e && e.code === 'ENV_CONFLICT'
5389 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
5390 : (e && e.message) || 'Save failed';
5391 msg.className = 'settings-msg err';
5392 }
5393 }
5394 });
5395 });
5396 }
5397
5398 document.querySelectorAll('.settings-tab').forEach((tab) => {
5399 tab.addEventListener('click', () => {
5400 const id = tab.dataset.settingsTab;
5401 document.querySelectorAll('.settings-tab').forEach((t) => {
5402 t.classList.toggle('active', t.dataset.settingsTab === id);
5403 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
5404 });
5405 document.querySelectorAll('.settings-panel').forEach((p) => {
5406 p.classList.toggle('active', p.id === 'settings-panel-' + id);
5407 });
5408 if (id === 'team') {
5409 loadTeamRolesList();
5410 loadInvitesList();
5411 }
5412 if (id === 'integrations') {
5413 refreshDevicePendingList();
5414 }
5415 if (id === 'vaults') loadVaultsPanel();
5416 if (id === 'billing') loadBillingPanel();
5417 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
5418 if (id === 'consolidation') loadConsolidationSettings();
5419 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
5420 });
5421 });
5422
5423 function formatTokenCount(n) {
5424 if (n == null || !Number.isFinite(Number(n))) return '—';
5425 return Number(n).toLocaleString();
5426 }
5427
5428 function formatTokenCountShort(n) {
5429 if (n == null || !Number.isFinite(Number(n))) return '—';
5430 const v = Number(n);
5431 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
5432 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
5433 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
5434 return String(v);
5435 }
5436
5437 /**
5438 * Update the token usage progress bar.
5439 * @param {number} used - tokens used this period
5440 * @param {number|null} included - tokens included (null = unlimited)
5441 */
5442 function updateUsageBar(fillId, used, included) {
5443 const fill = el(fillId);
5444 if (!fill) return;
5445 if (included == null) {
5446 fill.style.width = '15%';
5447 fill.className = 'billing-usage-bar-fill';
5448 return;
5449 }
5450 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
5451 fill.style.width = pct + '%';
5452 fill.className =
5453 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
5454 }
5455
5456 const TIER_LABELS = {
5457 free: 'Free',
5458 plus: 'Plus',
5459 growth: 'Growth',
5460 pro: 'Pro',
5461 beta: 'Beta',
5462 starter: 'Plus',
5463 team: 'Team',
5464 };
5465
5466 const TIER_CSS_CLASSES = {
5467 free: 'tier-free',
5468 plus: 'tier-plus',
5469 growth: 'tier-growth',
5470 pro: 'tier-pro',
5471 beta: 'tier-beta',
5472 starter: 'tier-plus',
5473 team: 'tier-pro',
5474 };
5475
5476 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
5477
5478 const TIER_PLAN_DATA = [
5479 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
5480 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
5481 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
5482 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
5483 ];
5484
5485 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
5486 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
5487
5488 /**
5489 * Render the plan comparison grid into #billing-plan-grid.
5490 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
5491 */
5492 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
5493 const grid = el('billing-plan-grid');
5494 if (!grid) return;
5495
5496 const normalized =
5497 currentTier === 'starter' ? 'plus'
5498 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
5499 : currentTier;
5500 const currentRank = TIER_ORDER.indexOf(normalized);
5501
5502 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
5503 const rank = TIER_ORDER.indexOf(tier);
5504 const isCurrent = rank === currentRank;
5505 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
5506
5507 let ctaHtml = '';
5508 if (isCurrent) {
5509 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
5510 } else if (isUpgrade) {
5511 const label = hasSub
5512 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
5513 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
5514 ctaHtml =
5515 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
5516 tier + '">' + label + '</button>';
5517 }
5518
5519 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
5520 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
5521
5522 return (
5523 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
5524 '<div class="billing-plan-card-header">' +
5525 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
5526 '<span class="billing-plan-card-price">' + price + '</span>' +
5527 '</div>' +
5528 '<ul class="billing-plan-card-features">' +
5529 '<li>' + searches + '</li>' +
5530 '<li>' + indexJobs + '</li>' +
5531 '<li>' + notes + '</li>' +
5532 consolLine +
5533 packLine +
5534 '</ul>' +
5535 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
5536 '</div>'
5537 );
5538 });
5539
5540 grid.innerHTML = cards.join('');
5541
5542 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
5543 btn.addEventListener('click', async () => {
5544 const tier = btn.dataset.tier;
5545 setButtonBusy(btn, true, 'Redirecting\u2026');
5546 try {
5547 await redirectToCheckout({ tier });
5548 } catch (e) {
5549 setButtonBusy(btn, false);
5550 const msg = el('billing-panel-msg');
5551 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
5552 }
5553 });
5554 });
5555 }
5556
5557 /**
5558 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
5559 * @param {{ price_id?: string, tier?: string }} opts
5560 */
5561 async function redirectToCheckout(opts) {
5562 const resp = await api('/api/v1/billing/checkout', {
5563 method: 'POST',
5564 headers: { 'Content-Type': 'application/json' },
5565 body: JSON.stringify({
5566 ...opts,
5567 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
5568 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
5569 }),
5570 });
5571 if (resp && resp.url) {
5572 window.location.href = resp.url;
5573 }
5574 }
5575
5576 /**
5577 * Redirect to Stripe Customer Portal.
5578 */
5579 async function redirectToPortal() {
5580 const resp = await api('/api/v1/billing/portal', {
5581 method: 'POST',
5582 headers: { 'Content-Type': 'application/json' },
5583 body: JSON.stringify({
5584 return_url: window.location.origin + window.location.pathname + '?open=billing',
5585 }),
5586 });
5587 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
5588 if (!url) {
5589 throw new Error(
5590 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
5591 );
5592 }
5593 window.location.assign(url);
5594 }
5595
5596 async function loadBillingPanel() {
5597 const msg = el('billing-panel-msg');
5598 const tierEl = el('billing-tier');
5599 const searchesUsedEl = el('billing-searches-used');
5600 const searchesIncEl = el('billing-searches-included');
5601 const indexJobsUsedEl = el('billing-index-jobs-used');
5602 const indexJobsIncEl = el('billing-index-jobs-included');
5603 const packEl = el('billing-pack-balance');
5604 const packRow = el('billing-pack-balance-row');
5605 const periodEl = el('billing-period');
5606 const renewalEl = el('billing-renewal');
5607 const credEl = el('billing-credits-used');
5608 const credRow = el('billing-credits-row');
5609 const polEl = el('billing-indexing-policy');
5610 const noteCap = el('billing-note-cap');
5611 const refreshBtn = el('btn-billing-refresh');
5612 const upgradeBtn = el('btn-billing-upgrade');
5613 const manageBtn = el('btn-billing-manage');
5614 const packSection = el('billing-pack-section');
5615 if (!tierEl || !searchesUsedEl) return;
5616 if (msg) msg.textContent = '';
5617 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
5618
5619 const setDash = () => {
5620 tierEl.textContent = '—';
5621 tierEl.className = 'billing-plan-badge tier-beta';
5622 if (searchesUsedEl) searchesUsedEl.textContent = '—';
5623 if (searchesIncEl) searchesIncEl.textContent = '—';
5624 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
5625 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
5626 if (packEl) packEl.textContent = '0';
5627 if (packRow) packRow.style.display = 'none';
5628 if (periodEl) periodEl.textContent = '—';
5629 if (renewalEl) renewalEl.textContent = '';
5630 if (credEl) credEl.textContent = '—';
5631 if (credRow) credRow.style.display = 'none';
5632 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
5633 if (noteCap) noteCap.textContent = '—';
5634 if (packSection) packSection.style.display = 'none';
5635 if (upgradeBtn) upgradeBtn.style.display = 'none';
5636 if (manageBtn) manageBtn.style.display = 'none';
5637 updateUsageBar('billing-searches-bar-fill', 0, 0);
5638 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
5639 updateUsageBar('billing-consol-bar-fill', 0, 0);
5640 const consolUsedReset = el('billing-consol-used');
5641 const consolIncReset = el('billing-consol-included');
5642 if (consolUsedReset) consolUsedReset.textContent = '—';
5643 if (consolIncReset) consolIncReset.textContent = '—';
5644 renderBillingPlanGrid('beta', false, false);
5645 };
5646
5647 if (!token) {
5648 setDash();
5649 if (msg) msg.textContent = 'Sign in to view billing usage.';
5650 if (refreshBtn) setButtonBusy(refreshBtn, false);
5651 return;
5652 }
5653
5654 try {
5655 const d = await api('/api/v1/billing/summary');
5656 const tier = d.tier != null ? String(d.tier) : 'beta';
5657
5658 // Plan badge
5659 tierEl.textContent = TIER_LABELS[tier] || tier;
5660 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
5661
5662 // Renewal date
5663 if (renewalEl) {
5664 const pe = d.period_end;
5665 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
5666 }
5667
5668 // Plan comparison grid
5669 const hasSub = Boolean(d.has_active_subscription);
5670 const isFreeTier = tier === 'free' || tier === 'beta';
5671 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
5672
5673 // Legacy upgrade button stays hidden (grid handles upgrades now)
5674 if (upgradeBtn) upgradeBtn.style.display = 'none';
5675 // Manage button: visible for active subscribers to reach the Stripe portal
5676 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
5677
5678 // Searches usage bar
5679 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
5680 const searchesInc = d.monthly_searches_included ?? null;
5681 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
5682 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
5683 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
5684
5685 // Index jobs usage bar
5686 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
5687 const indexJobsInc = d.monthly_index_jobs_included ?? null;
5688 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
5689 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
5690 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
5691
5692 // Consolidation jobs usage bar
5693 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
5694 const consolInc = d.monthly_consolidation_jobs_included ?? null;
5695 const consolUsedEl = el('billing-consol-used');
5696 const consolIncEl = el('billing-consol-included');
5697 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
5698 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
5699 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
5700
5701 // Pack balance
5702 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
5703 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
5704 if (packEl) {
5705 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
5706 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
5707 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
5708 let packText = formatTokenCountShort(packBal) +
5709 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
5710 if (packConsolPasses > 0) {
5711 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
5712 }
5713 packEl.textContent = packText;
5714 }
5715 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
5716
5717 // Period
5718 if (periodEl) {
5719 const ps = d.period_start;
5720 const pe = d.period_end;
5721 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
5722 }
5723
5724 // Note cap
5725 if (noteCap) {
5726 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
5727 }
5728
5729 // Legacy credits row (only show if non-zero)
5730 const mu = Number(d.monthly_used_cents) || 0;
5731 const mi = Number(d.monthly_included_effective_cents) || 0;
5732 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
5733 if (credEl && (mu > 0 || mi > 0)) {
5734 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
5735 }
5736
5737 // Token policy
5738 if (polEl) {
5739 const pol = d.indexing_tokens_policy;
5740 if (pol && String(pol).trim()) {
5741 polEl.textContent = String(pol).trim();
5742 polEl.style.display = '';
5743 } else {
5744 polEl.style.display = 'none';
5745 }
5746 }
5747
5748 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
5749 if (packSection) {
5750 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
5751 packSection.style.display = showPacks ? '' : 'none';
5752 }
5753
5754 if (msg) {
5755 msg.textContent = '';
5756 msg.className = 'settings-intro small muted';
5757 }
5758 } catch (e) {
5759 setDash();
5760 const m = e && e.message ? String(e.message) : String(e);
5761 if (msg) {
5762 msg.textContent =
5763 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
5764 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
5765 : m;
5766 msg.className = 'settings-intro small err';
5767 }
5768 }
5769 if (refreshBtn) setButtonBusy(refreshBtn, false);
5770 }
5771
5772 const btnBillingRefresh = el('btn-billing-refresh');
5773 if (btnBillingRefresh) {
5774 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
5775 }
5776
5777 const btnBillingUpgrade = el('btn-billing-upgrade');
5778 if (btnBillingUpgrade) {
5779 btnBillingUpgrade.addEventListener('click', async () => {
5780 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
5781 try {
5782 await redirectToCheckout({ tier: 'plus' });
5783 } catch (e) {
5784 setButtonBusy(btnBillingUpgrade, false);
5785 const packMsg = el('billing-panel-msg');
5786 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
5787 }
5788 });
5789 }
5790
5791 const btnBillingManage = el('btn-billing-manage');
5792 if (btnBillingManage) {
5793 btnBillingManage.addEventListener('click', async () => {
5794 const panelMsg = el('billing-panel-msg');
5795 if (panelMsg) {
5796 panelMsg.textContent = '';
5797 panelMsg.className = 'settings-intro small muted';
5798 }
5799 setButtonBusy(btnBillingManage, true, 'Redirecting…');
5800 try {
5801 await redirectToPortal();
5802 } catch (e) {
5803 setButtonBusy(btnBillingManage, false);
5804 const errText = e?.message || 'Could not open billing portal.';
5805 if (panelMsg) {
5806 panelMsg.textContent = errText;
5807 panelMsg.className = 'settings-intro small err';
5808 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
5809 }
5810 }
5811 });
5812 }
5813
5814 // Token pack purchase buttons
5815 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
5816 btn.addEventListener('click', async () => {
5817 const pack = btn.dataset.pack;
5818 const packMsgEl = el('billing-pack-msg');
5819 setButtonBusy(btn, true, 'Redirecting…');
5820 if (packMsgEl) packMsgEl.textContent = '';
5821 try {
5822 await redirectToCheckout({ pack_size: pack });
5823 } catch (e) {
5824 setButtonBusy(btn, false);
5825 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
5826 }
5827 });
5828 });
5829
5830 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
5831 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
5832 const arr = Array.isArray(vaults) ? vaults : [];
5833 if (arr.length === 0) {
5834 return isHosted
5835 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
5836 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
5837 }
5838 const items = arr
5839 .map((v) => {
5840 if (!v || v.id == null) return '';
5841 const id = escapeHtml(String(v.id).trim());
5842 const lab =
5843 v.label != null && String(v.label).trim()
5844 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
5845 : '';
5846 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
5847 const pathHtml = pathRaw
5848 ? escapeHtml(pathRaw)
5849 : '<span class="muted">—</span>';
5850 return (
5851 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
5852 id +
5853 '</code>' +
5854 lab +
5855 '</div><div class="vaults-summary-path muted small">' +
5856 pathHtml +
5857 '</div></li>'
5858 );
5859 })
5860 .filter(Boolean)
5861 .join('');
5862 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
5863 }
5864
5865 function collectVaultIdsForAccessForm(vaults, settingsRes) {
5866 const set = new Set(['default']);
5867 const allowed =
5868 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
5869 allowed.forEach((id) => {
5870 if (id != null && String(id).trim()) set.add(String(id).trim());
5871 });
5872 (vaults || []).forEach((v) => {
5873 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
5874 });
5875 return Array.from(set).sort((a, b) => {
5876 if (a === 'default') return -1;
5877 if (b === 'default') return 1;
5878 return a.localeCompare(b);
5879 });
5880 }
5881
5882 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
5883 if (!selectEl) return;
5884 const uids = new Set();
5885 (roleIds || []).forEach((id) => {
5886 if (id != null && String(id).trim()) uids.add(String(id).trim());
5887 });
5888 if (currentUserId != null && String(currentUserId).trim()) {
5889 uids.add(String(currentUserId).trim());
5890 }
5891 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
5892 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
5893 sorted.forEach((uid) => {
5894 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
5895 });
5896 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
5897 selectEl.innerHTML = html;
5898 }
5899
5900 function renderAccessVaultCheckboxes(vaultIds) {
5901 const wrap = el('access-form-vault-checkboxes');
5902 if (!wrap) return;
5903 if (!vaultIds.length) {
5904 wrap.innerHTML =
5905 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
5906 return;
5907 }
5908 wrap.innerHTML = vaultIds
5909 .map((id) => {
5910 const idAttr = escapeHtml(id);
5911 return (
5912 '<label><input type="checkbox" name="hub-access-vault" value="' +
5913 idAttr +
5914 '"> <code>' +
5915 idAttr +
5916 '</code></label>'
5917 );
5918 })
5919 .join('');
5920 }
5921
5922 function parseVaultAccessFromTextarea() {
5923 const accessText = el('vault-access-json');
5924 try {
5925 const access = JSON.parse((accessText && accessText.value) || '{}');
5926 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
5927 } catch (_) {
5928 return {};
5929 }
5930 }
5931
5932 function refreshAccessRulesSummary(access) {
5933 const wrap = el('access-rules-summary');
5934 if (!wrap) return;
5935 if (typeof access !== 'object' || access === null) access = {};
5936 const keys = Object.keys(access);
5937 if (keys.length === 0) {
5938 wrap.innerHTML =
5939 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
5940 return;
5941 }
5942 wrap.innerHTML = keys
5943 .sort((a, b) => a.localeCompare(b))
5944 .map((uid) => {
5945 const arr = access[uid];
5946 const vaults =
5947 Array.isArray(arr) && arr.length
5948 ? arr.map((x) => escapeHtml(String(x))).join(', ')
5949 : '<span class="muted">(invalid)</span>';
5950 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
5951 })
5952 .join('');
5953 }
5954
5955 function accessFormToggleOtherInput() {
5956 const sel = el('access-form-user-select');
5957 const wrap = el('access-form-user-other-wrap');
5958 const other = el('access-form-user-other');
5959 if (!sel || !wrap) return;
5960 const show = sel.value === '__other__';
5961 wrap.classList.toggle('hidden', !show);
5962 if (!show && other) other.value = '';
5963 }
5964
5965 function accessFormSyncCheckboxesFromAccessJson() {
5966 const sel = el('access-form-user-select');
5967 const other = el('access-form-user-other');
5968 if (!sel) return;
5969 let uid = '';
5970 if (sel.value === '__other__') {
5971 uid = ((other && other.value) || '').trim();
5972 } else {
5973 uid = (sel.value || '').trim();
5974 }
5975 const access = parseVaultAccessFromTextarea();
5976 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
5977 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
5978 cb.checked = allowed.indexOf(cb.value) !== -1;
5979 });
5980 }
5981
5982 function getAccessFormResolvedUserId() {
5983 const sel = el('access-form-user-select');
5984 const other = el('access-form-user-other');
5985 if (!sel) return '';
5986 if (sel.value === '__other__') return ((other && other.value) || '').trim();
5987 return (sel.value || '').trim();
5988 }
5989
5990 const accessUserSel = el('access-form-user-select');
5991 if (accessUserSel) {
5992 accessUserSel.addEventListener('change', () => {
5993 accessFormToggleOtherInput();
5994 accessFormSyncCheckboxesFromAccessJson();
5995 });
5996 }
5997 const accessUserOther = el('access-form-user-other');
5998 if (accessUserOther) {
5999 accessUserOther.addEventListener('input', () => {
6000 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
6001 accessFormSyncCheckboxesFromAccessJson();
6002 }
6003 });
6004 }
6005 const scopeUserSelInit = el('scope-form-user-select');
6006 if (scopeUserSelInit) {
6007 scopeUserSelInit.addEventListener('change', () => {
6008 const inp = el('scope-form-user-id');
6009 if (scopeUserSelInit.value === '__other__') {
6010 if (inp) inp.focus();
6011 } else if (scopeUserSelInit.value && inp) {
6012 inp.value = scopeUserSelInit.value;
6013 }
6014 });
6015 }
6016
6017 function populateVaultListExistingSelect(vaults) {
6018 const sel = el('vault-list-form-existing');
6019 if (!sel) return;
6020 let html = '<option value="">New vault</option>';
6021 (vaults || []).forEach((v) => {
6022 if (v && v.id != null && String(v.id).trim()) {
6023 const id = String(v.id).trim();
6024 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
6025 }
6026 });
6027 sel.innerHTML = html;
6028 }
6029
6030 function parseVaultsJsonArrayFromTextarea() {
6031 const ta = el('vaults-json');
6032 try {
6033 const arr = JSON.parse((ta && ta.value) || '[]');
6034 return Array.isArray(arr) ? arr : [];
6035 } catch (_) {
6036 return null;
6037 }
6038 }
6039
6040 function fillVaultListFormFromExisting() {
6041 const sel = el('vault-list-form-existing');
6042 const idInp = el('vault-list-form-id');
6043 const pathInp = el('vault-list-form-path');
6044 const labelInp = el('vault-list-form-label');
6045 if (!sel) return;
6046 if (!sel.value) {
6047 if (idInp) {
6048 idInp.value = '';
6049 idInp.readOnly = false;
6050 }
6051 if (pathInp) pathInp.value = '';
6052 if (labelInp) labelInp.value = '';
6053 return;
6054 }
6055 const vaults = parseVaultsJsonArrayFromTextarea();
6056 if (!vaults) return;
6057 const v = vaults.find((x) => x && String(x.id) === sel.value);
6058 if (v) {
6059 if (idInp) {
6060 idInp.value = String(v.id);
6061 idInp.readOnly = true;
6062 }
6063 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
6064 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
6065 }
6066 }
6067
6068 function toggleVaultsInfoPanel(panelId) {
6069 const panel = el(panelId);
6070 const modal = el('modal-settings');
6071 if (!panel || !modal) return;
6072 const wasHidden = panel.classList.contains('hidden');
6073 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
6074 if (wasHidden) panel.classList.remove('hidden');
6075 }
6076
6077 const modalSettingsForVaultsInfo = el('modal-settings');
6078 if (modalSettingsForVaultsInfo) {
6079 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
6080 const infoBtn = e.target.closest('.btn-settings-info');
6081 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
6082 e.stopPropagation();
6083 const tid = infoBtn.getAttribute('data-settings-info-target');
6084 if (tid) toggleVaultsInfoPanel(tid);
6085 return;
6086 }
6087 if (
6088 !e.target.closest('.settings-info-panel') &&
6089 !e.target.closest('.btn-settings-info')
6090 ) {
6091 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
6092 p.classList.add('hidden');
6093 });
6094 }
6095 });
6096 }
6097
6098 const vaultListExistingSel = el('vault-list-form-existing');
6099 if (vaultListExistingSel) {
6100 vaultListExistingSel.addEventListener('change', () => {
6101 fillVaultListFormFromExisting();
6102 const msg = el('vault-list-form-msg');
6103 if (msg) msg.textContent = '';
6104 });
6105 }
6106
6107 const btnVaultListFormApply = el('btn-vault-list-form-apply');
6108 if (btnVaultListFormApply) {
6109 btnVaultListFormApply.onclick = () => {
6110 const msg = el('vault-list-form-msg');
6111 const ta = el('vaults-json');
6112 const idInp = el('vault-list-form-id');
6113 const pathInp = el('vault-list-form-path');
6114 const labelInp = el('vault-list-form-label');
6115 const vaults = parseVaultsJsonArrayFromTextarea();
6116 if (!vaults) {
6117 if (msg) {
6118 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
6119 msg.className = 'settings-msg err';
6120 }
6121 return;
6122 }
6123 const id = ((idInp && idInp.value) || '').trim();
6124 const path = ((pathInp && pathInp.value) || '').trim();
6125 const label = ((labelInp && labelInp.value) || '').trim();
6126 if (!id || !path) {
6127 if (msg) {
6128 msg.textContent = 'Enter vault id and folder path.';
6129 msg.className = 'settings-msg err';
6130 }
6131 return;
6132 }
6133 const entry = { id, path };
6134 if (label) entry.label = label;
6135 const idx = vaults.findIndex((x) => x && String(x.id) === id);
6136 if (idx >= 0) {
6137 vaults[idx] = Object.assign({}, vaults[idx], entry);
6138 } else {
6139 if (idInp && idInp.readOnly) {
6140 if (msg) {
6141 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
6142 msg.className = 'settings-msg err';
6143 }
6144 return;
6145 }
6146 vaults.push(entry);
6147 }
6148 if (ta) ta.value = JSON.stringify(vaults, null, 2);
6149 populateVaultListExistingSelect(vaults);
6150 const sel = el('vault-list-form-existing');
6151 if (sel) sel.value = '';
6152 fillVaultListFormFromExisting();
6153 const lc = el('vaults-list-container');
6154 if (lc && !isHostedHubFromSettings()) {
6155 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6156 }
6157 if (msg) {
6158 msg.textContent = 'Updated. Click Save vault list to persist.';
6159 msg.className = 'settings-msg ok';
6160 }
6161 };
6162 }
6163
6164 async function loadVaultsPanel() {
6165 const listContainer = el('vaults-list-container');
6166 const serverView = el('vaults-server-view');
6167 const vaultsJson = el('vaults-json');
6168 const accessText = el('vault-access-json');
6169 const scopeText = el('scope-json');
6170 const helpHostedBlock = el('vaults-help-hosted-block');
6171 const helpSelfBlock = el('vaults-help-self-block');
6172 const selfHostedEditors = el('vaults-self-hosted-editors');
6173 const yamlOnly = el('vaults-hub-yaml-only');
6174 const hostedCreate = el('vaults-hosted-create');
6175 const workspacePanel = el('vaults-hosted-workspace');
6176 const workspaceInput = el('workspace-owner-input');
6177 const workspaceMsg = el('workspace-save-msg');
6178 if (listContainer) listContainer.textContent = 'Loading…';
6179 if (serverView) serverView.textContent = 'Loading…';
6180 try {
6181 const settingsRes = await api('/api/v1/settings');
6182 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6183 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6184 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6185 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6186 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6187 const ownerFromSettings =
6188 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6189 ? String(settingsRes.workspace_owner_id).trim()
6190 : '';
6191 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
6192 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
6193 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
6194 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
6195 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
6196 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
6197 const hostedCreateMsg = el('vaults-hosted-create-msg');
6198 if (hostedCreateMsg && isHosted) {
6199 hostedCreateMsg.textContent = '';
6200 hostedCreateMsg.className = 'settings-msg';
6201 }
6202 if (workspaceMsg) {
6203 workspaceMsg.textContent = '';
6204 workspaceMsg.className = 'settings-msg';
6205 }
6206
6207 /** @type {{ vaults?: unknown[] }} */
6208 let vRes = { vaults: [] };
6209 try {
6210 vRes = await api('/api/v1/vaults');
6211 } catch (_) {
6212 vRes = { vaults: [] };
6213 }
6214 /** @type {{ access?: Record<string, unknown> }} */
6215 let aRes = { access: {} };
6216 try {
6217 aRes = await api('/api/v1/vault-access');
6218 } catch (_) {
6219 aRes = { access: {} };
6220 }
6221 /** @type {{ scope?: Record<string, unknown> }} */
6222 let sRes = { scope: {} };
6223 try {
6224 sRes = await api('/api/v1/scope');
6225 } catch (_) {
6226 sRes = { scope: {} };
6227 }
6228
6229 if (isHosted && workspaceInput) {
6230 try {
6231 const w = await api('/api/v1/workspace');
6232 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
6233 } catch (e) {
6234 workspaceInput.value = '';
6235 if (workspaceMsg) {
6236 workspaceMsg.textContent =
6237 (e && e.message) ||
6238 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
6239 workspaceMsg.className = 'settings-msg err';
6240 }
6241 }
6242 } else if (workspaceInput && !isHosted) {
6243 workspaceInput.value = '';
6244 }
6245 const vaults = vRes.vaults || [];
6246 if (serverView) {
6247 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
6248 const allowed = settingsRes.allowed_vault_ids;
6249 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
6250 if (isHosted) {
6251 serverView.innerHTML =
6252 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6253 escapeHtml(uid) +
6254 '</code> · <strong>Vaults:</strong> <code>' +
6255 escapeHtml(allowedStr) +
6256 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
6257 } else {
6258 const dataDir =
6259 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
6260 serverView.innerHTML =
6261 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6262 escapeHtml(uid) +
6263 '</code> · <strong>Allowed vaults:</strong> <code>' +
6264 escapeHtml(allowedStr) +
6265 '</code> · <strong>Data:</strong> <code>' +
6266 dataDir +
6267 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
6268 }
6269 }
6270 if (listContainer) {
6271 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
6272 }
6273 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
6274 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
6275 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
6276
6277 const vaultListJsonDetails = el('vault-list-json-details');
6278 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
6279 const vaultAccessDetails = el('vault-access-json-details');
6280 if (vaultAccessDetails) vaultAccessDetails.open = false;
6281 const scopeJsonDetails = el('scope-json-details');
6282 if (scopeJsonDetails) scopeJsonDetails.open = false;
6283
6284 let roleIds = [];
6285 try {
6286 const ro = await api('/api/v1/roles');
6287 roleIds = Object.keys(ro.roles || {});
6288 } catch (_) {
6289 roleIds = [];
6290 }
6291 populateHostedTeamUserSelect(
6292 el('access-form-user-select'),
6293 roleIds,
6294 settingsRes.user_id,
6295 '— Choose a person —',
6296 );
6297 populateHostedTeamUserSelect(
6298 el('scope-form-user-select'),
6299 roleIds,
6300 settingsRes.user_id,
6301 '— Choose or type User ID below —',
6302 );
6303 const asel = el('access-form-user-select');
6304 if (asel) asel.value = '';
6305 const ssel = el('scope-form-user-select');
6306 if (ssel) ssel.value = '';
6307 accessFormToggleOtherInput();
6308 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
6309 renderAccessVaultCheckboxes(vaultIdsForForm);
6310 accessFormSyncCheckboxesFromAccessJson();
6311 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6312
6313 const scopeVaultSelect = el('scope-form-vault-id');
6314 if (scopeVaultSelect) {
6315 scopeVaultSelect.innerHTML =
6316 vaults.length === 0
6317 ? '<option value="default">default</option>'
6318 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
6319 }
6320
6321 if (!isHosted) {
6322 populateVaultListExistingSelect(vaults);
6323 const vSel = el('vault-list-form-existing');
6324 if (vSel) vSel.value = '';
6325 fillVaultListFormFromExisting();
6326 }
6327 } catch (e) {
6328 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
6329 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
6330 }
6331 }
6332
6333 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
6334 function sanitizeNewHostedVaultId(raw) {
6335 const t = String(raw || '').trim();
6336 if (!t) return { error: 'Enter a vault id.' };
6337 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
6338 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
6339 s = s.slice(0, 64);
6340 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
6341 if (s === 'default') {
6342 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
6343 }
6344 return { id: s };
6345 }
6346
6347 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
6348 if (btnHostedVaultCreate) {
6349 btnHostedVaultCreate.onclick = async () => {
6350 const msgEl = el('vaults-hosted-create-msg');
6351 const inp = el('vaults-hosted-new-id');
6352 const setCreateVaultMsg = (text, isErr) => {
6353 if (!msgEl) return;
6354 msgEl.textContent = text;
6355 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6356 };
6357 if (!isHostedHubFromSettings()) {
6358 setCreateVaultMsg('This action is only available on hosted Hub.', true);
6359 return;
6360 }
6361 if (!hubUserCanWriteNotes()) {
6362 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
6363 return;
6364 }
6365 const ws = lastBackupSettingsPayload;
6366 const ownerId =
6367 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
6368 ? String(ws.workspace_owner_id).trim()
6369 : '';
6370 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
6371 if (ownerId && me && me !== ownerId) {
6372 setCreateVaultMsg(
6373 '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.',
6374 true,
6375 );
6376 return;
6377 }
6378 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
6379 if (parsed.error) {
6380 setCreateVaultMsg(parsed.error, true);
6381 return;
6382 }
6383 const { id } = parsed;
6384 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
6385 setCreateVaultMsg('');
6386 try {
6387 const fresh = await api('/api/v1/settings');
6388 const allowed = fresh.allowed_vault_ids || [];
6389 if (Array.isArray(allowed) && allowed.includes(id)) {
6390 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the header to switch to it.', true);
6391 return;
6392 }
6393 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
6394 await api('/api/v1/notes', {
6395 method: 'POST',
6396 headers: { 'X-Vault-Id': id },
6397 body: JSON.stringify({
6398 path,
6399 body:
6400 'This note was created when you added the "' +
6401 id +
6402 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
6403 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
6404 }),
6405 });
6406 hubMarkSemanticIndexStaleForVault(id);
6407 const s = await api('/api/v1/settings');
6408 lastBackupSettingsPayload = s;
6409 if (s.role) window.__hubUserRole = String(s.role);
6410 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6411 applyHostedUiFromSettings(s);
6412 setCurrentVaultId(id);
6413 const sel = el('vault-switcher');
6414 if (sel) sel.value = id;
6415 loadFacets();
6416 loadNotes();
6417 loadProposals();
6418 await loadVaultsPanel();
6419 if (inp) inp.value = '';
6420 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the header to switch.', false);
6421 } catch (e) {
6422 setCreateVaultMsg(e.message || 'Could not create vault', true);
6423 }
6424 });
6425 };
6426 }
6427
6428 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
6429 if (btnSettingsDeleteVault) {
6430 btnSettingsDeleteVault.onclick = async () => {
6431 const msgEl = el('settings-delete-vault-msg');
6432 const setVaultDelMsg = (text, isErr) => {
6433 if (!msgEl) return;
6434 msgEl.textContent = text;
6435 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6436 };
6437 if (!hubUserMayDeleteVault()) {
6438 setVaultDelMsg('You are not allowed to delete vaults.', true);
6439 return;
6440 }
6441 const sel = el('settings-delete-vault-select');
6442 const vaultId = (sel && sel.value) || '';
6443 const vaultIdTrim = String(vaultId).trim();
6444 if (!vaultIdTrim) {
6445 setVaultDelMsg('Choose a vault to delete.', true);
6446 return;
6447 }
6448 if (vaultIdTrim === 'default') {
6449 setVaultDelMsg('The default vault cannot be deleted.', true);
6450 return;
6451 }
6452 const confirmEl = el('settings-delete-vault-confirm');
6453 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
6454 if (confirmVal !== 'DELETE VAULT') {
6455 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
6456 return;
6457 }
6458 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
6459 setVaultDelMsg('', false);
6460 try {
6461 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
6462 method: 'DELETE',
6463 headers: { 'X-Vault-Id': vaultIdTrim },
6464 });
6465 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
6466 if (wasCurrent) {
6467 setCurrentVaultId('default');
6468 const vSel = el('vault-switcher');
6469 if (vSel) vSel.value = 'default';
6470 }
6471 const s = await api('/api/v1/settings');
6472 lastBackupSettingsPayload = s;
6473 if (s.role) window.__hubUserRole = String(s.role);
6474 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6475 applyHostedUiFromSettings(s);
6476 refreshDeleteProjectPanelVisibility();
6477 loadFacets();
6478 loadNotes();
6479 loadProposals();
6480 await loadVaultsPanel();
6481 if (confirmEl) confirmEl.value = '';
6482 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
6483 } catch (e) {
6484 setVaultDelMsg(e.message || 'Could not delete vault', true);
6485 }
6486 });
6487 };
6488 }
6489
6490 const btnScopeFormApply = el('btn-scope-form-apply');
6491 if (btnScopeFormApply) {
6492 btnScopeFormApply.onclick = () => {
6493 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
6494 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
6495 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
6496 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
6497 const msg = el('scope-form-msg');
6498 if (!userId) {
6499 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
6500 return;
6501 }
6502 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
6503 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
6504 const scopeText = el('scope-json');
6505 let scope = {};
6506 if (scopeText && scopeText.value) {
6507 try {
6508 scope = JSON.parse(scopeText.value);
6509 if (typeof scope !== 'object' || scope === null) scope = {};
6510 } catch (_) { scope = {}; }
6511 }
6512 if (!scope[userId]) scope[userId] = {};
6513 scope[userId][vaultId] = { projects, folders };
6514 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
6515 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
6516 };
6517 }
6518
6519 function isHostedHubFromSettings() {
6520 const s = lastBackupSettingsPayload;
6521 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
6522 }
6523
6524 const BULK_PRESET_EMPTY = '';
6525 const BULK_PRESET_CUSTOM = '__custom__';
6526
6527 function fillBulkPresetSelect(sel, items, includeCustom) {
6528 if (!sel) return;
6529 const preserve = sel.value;
6530 sel.innerHTML = '';
6531 const head = document.createElement('option');
6532 head.value = BULK_PRESET_EMPTY;
6533 head.textContent = '— Select or type below —';
6534 sel.appendChild(head);
6535 for (const item of items) {
6536 if (item == null || item === '') continue;
6537 const o = document.createElement('option');
6538 o.value = item;
6539 o.textContent = item;
6540 sel.appendChild(o);
6541 }
6542 if (includeCustom) {
6543 const c = document.createElement('option');
6544 c.value = BULK_PRESET_CUSTOM;
6545 c.textContent = 'Custom (type below)';
6546 sel.appendChild(c);
6547 }
6548 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
6549 else sel.value = BULK_PRESET_EMPTY;
6550 }
6551
6552 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
6553 if (!selectEl || !inputEl) return;
6554 const p = (inputEl.value || '').trim();
6555 if (!p) {
6556 selectEl.value = BULK_PRESET_EMPTY;
6557 return;
6558 }
6559 let best = BULK_PRESET_CUSTOM;
6560 let bestLen = -1;
6561 for (const opt of selectEl.options) {
6562 const v = opt.value;
6563 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
6564 if (p === v || p.startsWith(v + '/')) {
6565 if (v.length > bestLen) {
6566 best = v;
6567 bestLen = v.length;
6568 }
6569 }
6570 }
6571 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
6572 }
6573
6574 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
6575 if (!selectEl || !inputEl) return;
6576 const p = (inputEl.value || '').trim();
6577 if (!p) {
6578 selectEl.value = BULK_PRESET_EMPTY;
6579 return;
6580 }
6581 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
6582 else selectEl.value = BULK_PRESET_CUSTOM;
6583 }
6584
6585 function wireBulkPathPresetPair(selectEl, inputEl) {
6586 if (!selectEl || !inputEl) return;
6587 selectEl.addEventListener('change', () => {
6588 const v = selectEl.value;
6589 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6590 });
6591 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
6592 }
6593
6594 function wireBulkSlugPresetPair(selectEl, inputEl) {
6595 if (!selectEl || !inputEl) return;
6596 selectEl.addEventListener('change', () => {
6597 const v = selectEl.value;
6598 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6599 });
6600 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
6601 }
6602
6603 let bulkPresetDropdownsToken = 0;
6604 async function refreshBulkDeletePresetDropdowns() {
6605 if (!token) return;
6606 const pathSelect = el('settings-bulk-path-prefix-preset');
6607 const delProjSelect = el('settings-bulk-delete-project-preset');
6608 const renameFromSelect = el('settings-bulk-rename-from-preset');
6609 const pathInput = el('settings-delete-prefix');
6610 const delProjInput = el('settings-delete-project-slug');
6611 const renameFromInput = el('settings-rename-project-from');
6612 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
6613 const my = ++bulkPresetDropdownsToken;
6614 let diskFolders = [];
6615 let facets = { projects: [], folders: [] };
6616 try {
6617 const [vf, fc] = await Promise.all([
6618 api('/api/v1/vault/folders'),
6619 api('/api/v1/notes/facets'),
6620 ]);
6621 if (my !== bulkPresetDropdownsToken) return;
6622 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
6623 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
6624 } catch (_) {
6625 if (my !== bulkPresetDropdownsToken) return;
6626 }
6627 const pathSet = new Set();
6628 for (const f of diskFolders) {
6629 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6630 }
6631 for (const f of facets.folders || []) {
6632 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6633 }
6634 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
6635 const pathPrefixes = ['inbox', ...rest];
6636 const projects = [
6637 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
6638 ].sort((a, b) => a.localeCompare(b));
6639
6640 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
6641 fillBulkPresetSelect(delProjSelect, projects, true);
6642 fillBulkPresetSelect(renameFromSelect, projects, true);
6643
6644 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
6645 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
6646 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
6647 }
6648
6649 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
6650 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
6651 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
6652
6653 const btnDeletePrefix = el('btn-settings-delete-prefix');
6654 if (btnDeletePrefix) {
6655 btnDeletePrefix.onclick = async () => {
6656 const msg = el('settings-delete-prefix-msg');
6657 const prefixEl = el('settings-delete-prefix');
6658 const confirmEl = el('settings-delete-confirm');
6659 if (!hubUserCanWriteNotes()) {
6660 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6661 return;
6662 }
6663 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
6664 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6665 if (!raw) {
6666 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
6667 return;
6668 }
6669 if (conf !== 'DELETE') {
6670 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6671 return;
6672 }
6673 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
6674 try {
6675 const out = await api('/api/v1/notes/delete-by-prefix', {
6676 method: 'POST',
6677 headers: { 'Content-Type': 'application/json' },
6678 body: JSON.stringify({ path_prefix: raw }),
6679 });
6680 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6681 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6682 if (confirmEl) confirmEl.value = '';
6683 if (msg) {
6684 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6685 msg.className = 'settings-msg ok';
6686 }
6687 if (typeof showToast === 'function') {
6688 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
6689 }
6690 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6691 loadNotes();
6692 loadFacets();
6693 if (typeof loadProposals === 'function') loadProposals();
6694 void refreshBulkDeletePresetDropdowns();
6695 } catch (e) {
6696 const m = e && e.message ? String(e.message) : String(e);
6697 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6698 }
6699 });
6700 };
6701 }
6702
6703 const btnDeleteByProject = el('btn-settings-delete-by-project');
6704 if (btnDeleteByProject) {
6705 btnDeleteByProject.onclick = async () => {
6706 const msg = el('settings-delete-by-project-msg');
6707 const slugEl = el('settings-delete-project-slug');
6708 const confirmEl = el('settings-delete-project-confirm');
6709 if (!hubUserCanWriteNotes()) {
6710 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6711 return;
6712 }
6713 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
6714 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6715 if (!slug) {
6716 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
6717 return;
6718 }
6719 if (conf !== 'DELETE') {
6720 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6721 return;
6722 }
6723 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
6724 try {
6725 const out = await api('/api/v1/notes/delete-by-project', {
6726 method: 'POST',
6727 headers: { 'Content-Type': 'application/json' },
6728 body: JSON.stringify({ project: slug }),
6729 });
6730 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6731 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6732 if (confirmEl) confirmEl.value = '';
6733 if (msg) {
6734 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6735 msg.className = 'settings-msg ok';
6736 }
6737 if (typeof showToast === 'function') {
6738 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
6739 }
6740 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6741 loadNotes();
6742 loadFacets();
6743 if (typeof loadProposals === 'function') loadProposals();
6744 void refreshBulkDeletePresetDropdowns();
6745 } catch (e) {
6746 const m = e && e.message ? String(e.message) : String(e);
6747 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6748 }
6749 });
6750 };
6751 }
6752
6753 const btnRenameProject = el('btn-settings-rename-project');
6754 if (btnRenameProject) {
6755 btnRenameProject.onclick = async () => {
6756 const msg = el('settings-rename-project-msg');
6757 const fromEl = el('settings-rename-project-from');
6758 const toEl = el('settings-rename-project-to');
6759 const confirmEl = el('settings-rename-project-confirm');
6760 if (!hubUserCanWriteNotes()) {
6761 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
6762 return;
6763 }
6764 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
6765 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
6766 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6767 if (!from || !to) {
6768 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
6769 return;
6770 }
6771 if (conf !== 'RENAME') {
6772 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
6773 return;
6774 }
6775 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
6776 try {
6777 const out = await api('/api/v1/notes/rename-project', {
6778 method: 'POST',
6779 headers: { 'Content-Type': 'application/json' },
6780 body: JSON.stringify({ from, to }),
6781 });
6782 const n = out && typeof out.updated === 'number' ? out.updated : 0;
6783 if (confirmEl) confirmEl.value = '';
6784 if (msg) {
6785 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
6786 msg.className = 'settings-msg ok';
6787 }
6788 if (typeof showToast === 'function') {
6789 showToast('Renamed project on ' + n + ' note(s).', false);
6790 }
6791 if (n > 0) hubMarkSemanticIndexStale();
6792 loadNotes();
6793 loadFacets();
6794 void refreshBulkDeletePresetDropdowns();
6795 } catch (e) {
6796 const m = e && e.message ? String(e.message) : String(e);
6797 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6798 }
6799 });
6800 };
6801 }
6802
6803 const btnVaultsSave = el('btn-vaults-save');
6804 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
6805 const msg = el('vaults-save-msg');
6806 if (isHostedHubFromSettings()) {
6807 if (msg) {
6808 msg.textContent =
6809 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
6810 msg.className = 'settings-msg err';
6811 }
6812 return;
6813 }
6814 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
6815 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
6816 try {
6817 const vaults = JSON.parse(raw);
6818 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
6819 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
6820 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6821 try {
6822 const s = await api('/api/v1/settings');
6823 applySettingsPayloadToHubChrome(s);
6824 } catch (_) {}
6825 loadVaultsPanel();
6826 } catch (e) {
6827 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6828 }
6829 });
6830 };
6831 function validateVaultAccess(access) {
6832 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
6833 for (const [uid, arr] of Object.entries(access)) {
6834 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
6835 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
6836 }
6837 return null;
6838 }
6839 function validateScope(scope) {
6840 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
6841 for (const [userId, perVault] of Object.entries(scope)) {
6842 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
6843 for (const [vaultId, entry] of Object.entries(perVault)) {
6844 if (typeof entry !== 'object' || entry === null) continue;
6845 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
6846 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
6847 }
6848 }
6849 return null;
6850 }
6851 const btnVaultAccessSave = el('btn-vault-access-save');
6852 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
6853 const msg = el('vault-access-save-msg');
6854 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
6855 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
6856 try {
6857 const access = JSON.parse(raw);
6858 const err = validateVaultAccess(access);
6859 if (err) throw new Error(err);
6860 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
6861 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6862 try {
6863 const s = await api('/api/v1/settings');
6864 applySettingsPayloadToHubChrome(s);
6865 } catch (_) {}
6866 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6867 } catch (e) {
6868 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6869 }
6870 });
6871 };
6872
6873 const btnAccessFormApply = el('btn-access-form-apply');
6874 if (btnAccessFormApply) {
6875 btnAccessFormApply.onclick = () => {
6876 const msg = el('access-form-msg');
6877 const uid = getAccessFormResolvedUserId();
6878 if (!uid) {
6879 if (msg) {
6880 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
6881 msg.className = 'settings-msg err';
6882 }
6883 return;
6884 }
6885 const checked = Array.from(
6886 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
6887 ).map((c) => c.value);
6888 if (checked.length === 0) {
6889 if (msg) {
6890 msg.textContent = 'Tick at least one vault.';
6891 msg.className = 'settings-msg err';
6892 }
6893 return;
6894 }
6895 const access = parseVaultAccessFromTextarea();
6896 access[uid] = checked;
6897 const ta = el('vault-access-json');
6898 if (ta) ta.value = JSON.stringify(access, null, 2);
6899 refreshAccessRulesSummary(access);
6900 if (msg) {
6901 msg.textContent =
6902 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
6903 msg.className = 'settings-msg ok';
6904 }
6905 };
6906 }
6907
6908 const btnAccessFormRemove = el('btn-access-form-remove-user');
6909 if (btnAccessFormRemove) {
6910 btnAccessFormRemove.onclick = () => {
6911 const msg = el('access-form-msg');
6912 const uid = getAccessFormResolvedUserId();
6913 if (!uid) {
6914 if (msg) {
6915 msg.textContent = 'Choose a person to remove.';
6916 msg.className = 'settings-msg err';
6917 }
6918 return;
6919 }
6920 const access = parseVaultAccessFromTextarea();
6921 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
6922 if (msg) {
6923 msg.textContent = 'No rule for that user.';
6924 msg.className = 'settings-msg err';
6925 }
6926 return;
6927 }
6928 delete access[uid];
6929 const ta = el('vault-access-json');
6930 if (ta) ta.value = JSON.stringify(access, null, 2);
6931 refreshAccessRulesSummary(access);
6932 accessFormSyncCheckboxesFromAccessJson();
6933 if (msg) {
6934 msg.textContent =
6935 'Removed from draft rules only. Click Save vault access below to persist (required).';
6936 msg.className = 'settings-msg ok';
6937 }
6938 };
6939 }
6940
6941 const btnScopeSave = el('btn-scope-save');
6942 if (btnScopeSave) btnScopeSave.onclick = async () => {
6943 const msg = el('scope-save-msg');
6944 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
6945 const raw = (el('scope-json') && el('scope-json').value) || '{}';
6946 try {
6947 const scope = JSON.parse(raw);
6948 const err = validateScope(scope);
6949 if (err) throw new Error(err);
6950 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
6951 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6952 } catch (e) {
6953 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6954 }
6955 });
6956 };
6957
6958 const btnWorkspaceUseMe = el('btn-workspace-use-me');
6959 if (btnWorkspaceUseMe) {
6960 btnWorkspaceUseMe.onclick = async () => {
6961 const input = el('workspace-owner-input');
6962 const msg = el('workspace-save-msg');
6963 let uid =
6964 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
6965 ? String(lastBackupSettingsPayload.user_id)
6966 : '';
6967 if (!uid) {
6968 try {
6969 const s = await api('/api/v1/settings');
6970 lastBackupSettingsPayload = s;
6971 uid = s.user_id != null ? String(s.user_id) : '';
6972 } catch (e) {
6973 if (msg) {
6974 msg.textContent = e.message || 'Could not load your User ID.';
6975 msg.className = 'settings-msg err';
6976 }
6977 return;
6978 }
6979 }
6980 if (input) input.value = uid;
6981 if (msg) {
6982 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
6983 msg.className = 'settings-msg ok';
6984 }
6985 };
6986 }
6987
6988 const btnWorkspaceSave = el('btn-workspace-save');
6989 if (btnWorkspaceSave) {
6990 btnWorkspaceSave.onclick = async () => {
6991 const msg = el('workspace-save-msg');
6992 const input = el('workspace-owner-input');
6993 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
6994 try {
6995 const raw = (input && input.value) || '';
6996 const trimmed = raw.trim();
6997 const owner_user_id = trimmed === '' ? null : trimmed;
6998 await api('/api/v1/workspace', {
6999 method: 'POST',
7000 body: JSON.stringify({ owner_user_id }),
7001 });
7002 if (msg) {
7003 msg.textContent = 'Saved.';
7004 msg.className = 'settings-msg ok';
7005 }
7006 } catch (e) {
7007 if (msg) {
7008 msg.textContent = e.message || 'Save failed';
7009 msg.className = 'settings-msg err';
7010 }
7011 }
7012 });
7013 };
7014 }
7015
7016 const btnWorkspaceClear = el('btn-workspace-clear');
7017 if (btnWorkspaceClear) {
7018 btnWorkspaceClear.onclick = async () => {
7019 const msg = el('workspace-save-msg');
7020 const input = el('workspace-owner-input');
7021 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
7022 try {
7023 await api('/api/v1/workspace', {
7024 method: 'POST',
7025 body: JSON.stringify({ owner_user_id: null }),
7026 });
7027 if (input) input.value = '';
7028 if (msg) {
7029 msg.textContent = 'Cleared — each person uses their own cloud space.';
7030 msg.className = 'settings-msg ok';
7031 }
7032 } catch (e) {
7033 if (msg) {
7034 msg.textContent = e.message || 'Clear failed';
7035 msg.className = 'settings-msg err';
7036 }
7037 }
7038 });
7039 };
7040 }
7041
7042 async function loadInvitesList() {
7043 const listEl = el('invites-pending-list');
7044 if (!listEl) return;
7045 listEl.textContent = 'Loading…';
7046 try {
7047 const out = await api('/api/v1/invites');
7048 const invites = out.invites || [];
7049 if (invites.length === 0) {
7050 listEl.textContent = 'No pending invites. Create a link above.';
7051 } else {
7052 listEl.innerHTML = invites.map((inv) => {
7053 const tokenShort = inv.token.slice(0, 12) + '…';
7054 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
7055 return '<div class="team-role-row invite-row">' +
7056 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
7057 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
7058 '</div>';
7059 }).join('');
7060 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
7061 btn.onclick = async () => {
7062 const t = btn.dataset.token;
7063 if (!t) return;
7064 try {
7065 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
7066 loadInvitesList();
7067 } catch (e) {
7068 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
7069 }
7070 };
7071 });
7072 }
7073 } catch (e) {
7074 listEl.textContent = 'Could not load: ' + (e.message || '');
7075 }
7076 }
7077
7078 const btnInviteCreate = el('btn-invite-create');
7079 const inviteLinkBlock = el('invite-link-block');
7080 const inviteLinkUrl = el('invite-link-url');
7081 const inviteCreateMsg = el('invite-create-msg');
7082 if (btnInviteCreate) {
7083 btnInviteCreate.onclick = async () => {
7084 const roleSelect = el('invite-role');
7085 const role = (roleSelect && roleSelect.value) || 'editor';
7086 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
7087 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
7088 try {
7089 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
7090 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
7091 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
7092 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
7093 loadInvitesList();
7094 } catch (e) {
7095 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
7096 }
7097 });
7098 };
7099 }
7100 const btnInviteCopy = el('btn-invite-copy');
7101 if (btnInviteCopy && inviteLinkUrl) {
7102 btnInviteCopy.onclick = () => {
7103 inviteLinkUrl.select();
7104 if (navigator.clipboard && navigator.clipboard.writeText) {
7105 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
7106 if (typeof showToast === 'function') showToast('Link copied.');
7107 }).catch(() => {});
7108 }
7109 };
7110 }
7111
7112 function syncTeamAddEvaluatorMayApproveVisibility() {
7113 const wrap = el('team-add-evaluator-may-approve-wrap');
7114 const sel = el('team-role');
7115 if (!wrap || !sel) return;
7116 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
7117 }
7118 const teamRoleSelect = el('team-role');
7119 if (teamRoleSelect) {
7120 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
7121 syncTeamAddEvaluatorMayApproveVisibility();
7122 }
7123
7124 async function loadTeamRolesList() {
7125 const listEl = el('team-roles-list');
7126 if (!listEl) return;
7127 listEl.textContent = 'Loading…';
7128 try {
7129 const out = await api('/api/v1/roles');
7130 const roles = out.roles || {};
7131 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
7132 const entries = Object.entries(roles);
7133 listEl.innerHTML = '';
7134 if (entries.length === 0) {
7135 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
7136 return;
7137 }
7138 for (const [uid, role] of entries) {
7139 const row = document.createElement('div');
7140 row.className = 'team-role-row team-role-row-flex';
7141 const label = document.createElement('span');
7142 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
7143 row.appendChild(label);
7144 if (role === 'evaluator') {
7145 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
7146 const chk = document.createElement('input');
7147 chk.type = 'checkbox';
7148 chk.title = 'May approve proposals';
7149 chk.checked = Boolean(mayMap[uid]);
7150 chk.addEventListener('change', async () => {
7151 chk.disabled = true;
7152 try {
7153 await api('/api/v1/roles/evaluator-may-approve', {
7154 method: 'POST',
7155 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7156 });
7157 } catch (err) {
7158 chk.checked = !chk.checked;
7159 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7160 } finally {
7161 chk.disabled = false;
7162 }
7163 });
7164 const lab = document.createElement('label');
7165 lab.className = 'team-evaluator-approve-inline';
7166 lab.appendChild(chk);
7167 const sp = document.createElement('span');
7168 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7169 lab.appendChild(sp);
7170 row.appendChild(lab);
7171 }
7172 listEl.appendChild(row);
7173 }
7174 } catch (e) {
7175 listEl.textContent = 'Could not load: ' + (e.message || '');
7176 }
7177 }
7178
7179 const btnTeamUserUseMe = el('btn-team-user-use-me');
7180 if (btnTeamUserUseMe) {
7181 btnTeamUserUseMe.onclick = async () => {
7182 const userIdInput = el('team-user-id');
7183 const msgEl = el('team-save-msg');
7184 let uid =
7185 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7186 ? String(lastBackupSettingsPayload.user_id)
7187 : '';
7188 if (!uid) {
7189 try {
7190 const s = await api('/api/v1/settings');
7191 lastBackupSettingsPayload = s;
7192 uid = s.user_id != null ? String(s.user_id) : '';
7193 } catch (e) {
7194 if (msgEl) {
7195 msgEl.textContent = e.message || 'Could not load your User ID.';
7196 msgEl.className = 'settings-msg err';
7197 }
7198 return;
7199 }
7200 }
7201 if (userIdInput) userIdInput.value = uid;
7202 if (msgEl) {
7203 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
7204 msgEl.className = 'settings-msg';
7205 }
7206 };
7207 }
7208
7209 const btnTeamSave = el('btn-team-save');
7210 if (btnTeamSave) {
7211 btnTeamSave.onclick = async () => {
7212 const userIdInput = el('team-user-id');
7213 const roleSelect = el('team-role');
7214 const msgEl = el('team-save-msg');
7215 const userId = (userIdInput && userIdInput.value || '').trim();
7216 const role = (roleSelect && roleSelect.value) || 'editor';
7217 if (!userId) {
7218 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
7219 return;
7220 }
7221 if (msgEl) msgEl.textContent = '';
7222 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
7223 try {
7224 const body = { user_id: userId, role };
7225 if (role === 'evaluator') {
7226 const cb = el('team-add-evaluator-may-approve');
7227 body.evaluator_may_approve = Boolean(cb && cb.checked);
7228 }
7229 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
7230 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
7231 userIdInput.value = '';
7232 loadTeamRolesList();
7233 } catch (e) {
7234 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
7235 }
7236 });
7237 };
7238 }
7239
7240 const currentAccent = () => {
7241 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
7242 if (inline) return inline;
7243 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
7244 return fromCss || DEFAULT_ACCENT;
7245 };
7246 function accentStringToHex6(str) {
7247 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
7248 const t = str.trim();
7249 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
7250 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
7251 const a = t.slice(1);
7252 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
7253 }
7254 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
7255 if (m) {
7256 return (
7257 '#' +
7258 [1, 2, 3]
7259 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
7260 .join('')
7261 ).toLowerCase();
7262 }
7263 return DEFAULT_ACCENT;
7264 }
7265 function updateAccentCustomHexLabel(hex6) {
7266 const out = el('accent-custom-hex');
7267 if (out && hex6) out.textContent = String(hex6).toUpperCase();
7268 }
7269 function setAccentRuntimeOnly(hex) {
7270 if (!hex) return;
7271 document.documentElement.style.setProperty('--accent', hex);
7272 updateAccentCustomHexLabel(accentStringToHex6(hex));
7273 }
7274 let accentIroPicker = null;
7275 let accentIroSuppressChange = false;
7276 function ensureAccentIroPicker() {
7277 if (accentIroPicker) return accentIroPicker;
7278 const mount = el('accent-iro-root');
7279 const Iro = typeof window !== 'undefined' && window.iro;
7280 if (!mount || !Iro || !Iro.ColorPicker) return null;
7281 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
7282 const br = (brRaw && brRaw.trim()) || '';
7283 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
7284 accentIroPicker = new Iro.ColorPicker(mount, {
7285 width: 280,
7286 color: accentStringToHex6(currentAccent()),
7287 borderWidth: 1,
7288 borderColor,
7289 layout: [
7290 { component: Iro.ui.Box, options: {} },
7291 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
7292 ],
7293 });
7294 accentIroPicker.on('color:change', (color) => {
7295 if (accentIroSuppressChange) return;
7296 setAccentRuntimeOnly(color.hexString);
7297 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
7298 });
7299 accentIroPicker.on('input:end', () => {
7300 if (accentIroSuppressChange) return;
7301 const h = accentIroPicker.color.hexString;
7302 if (h) applyAccent(h);
7303 });
7304 return accentIroPicker;
7305 }
7306 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
7307 function setAccentPickerColor(picker, hexNorm) {
7308 if (!picker || !hexNorm) return;
7309 const col = picker.color;
7310 if (col && typeof col.set === 'function') {
7311 col.set(hexNorm);
7312 return;
7313 }
7314 if (typeof picker.setColor === 'function') {
7315 try {
7316 picker.setColor(hexNorm, { silent: true });
7317 } catch (_) {
7318 picker.setColor(hexNorm);
7319 }
7320 }
7321 }
7322 function paintAccentSwatches() {
7323 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7324 const hex = btn.dataset.accent;
7325 if (hex) btn.style.backgroundColor = hex;
7326 });
7327 }
7328 paintAccentSwatches();
7329 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7330 btn.addEventListener('click', () => {
7331 const hex = btn.dataset.accent;
7332 if (hex) {
7333 applyAccent(hex);
7334 const norm = accentStringToHex6(hex);
7335 document.querySelectorAll('.accent-swatch').forEach((b) => {
7336 const bh = b.dataset.accent;
7337 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7338 });
7339 ensureAccentIroPicker();
7340 if (accentIroPicker) {
7341 accentIroSuppressChange = true;
7342 try {
7343 setAccentPickerColor(accentIroPicker, norm);
7344 } finally {
7345 accentIroSuppressChange = false;
7346 }
7347 }
7348 updateAccentCustomHexLabel(norm);
7349 }
7350 });
7351 });
7352 ensureAccentIroPicker();
7353 function syncAccentUI() {
7354 const norm = accentStringToHex6(currentAccent());
7355 document.querySelectorAll('.accent-swatch').forEach((b) => {
7356 const bh = b.dataset.accent;
7357 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7358 });
7359 ensureAccentIroPicker();
7360 if (accentIroPicker) {
7361 accentIroSuppressChange = true;
7362 try {
7363 setAccentPickerColor(accentIroPicker, norm);
7364 } finally {
7365 accentIroSuppressChange = false;
7366 }
7367 }
7368 updateAccentCustomHexLabel(norm);
7369 }
7370 function currentTheme() {
7371 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
7372 }
7373 function syncThemeUI() {
7374 const theme = currentTheme();
7375 document.querySelectorAll('.theme-btn').forEach((btn) => {
7376 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
7377 });
7378 }
7379 function syncColorPaletteUI() {
7380 const p = currentColorPalette();
7381 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
7382 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
7383 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
7384 });
7385 }
7386 const dashboardThemeGrid = el('dashboard-theme-grid');
7387 if (dashboardThemeGrid) {
7388 dashboardThemeGrid.addEventListener('click', (ev) => {
7389 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
7390 if (!card || !dashboardThemeGrid.contains(card)) return;
7391 const pid = card.dataset.palette;
7392 if (pid == null) return;
7393 applyColorPalette(pid);
7394 syncColorPaletteUI();
7395 });
7396 }
7397 document.querySelectorAll('.theme-btn').forEach((btn) => {
7398 btn.addEventListener('click', () => {
7399 const theme = btn.dataset.theme;
7400 if (theme) {
7401 applyTheme(theme);
7402 syncThemeUI();
7403 }
7404 });
7405 });
7406 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
7407 if (scrollDashColorsBtn) {
7408 scrollDashColorsBtn.addEventListener('click', () => {
7409 const target = el('settings-dashboard-color-theme');
7410 if (target && target.scrollIntoView) {
7411 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7412 }
7413 });
7414 }
7415
7416 el('btn-settings-sync').onclick = async () => {
7417 const syncBtn = el('btn-settings-sync');
7418 const msg = el('settings-sync-msg');
7419 msg.textContent = 'Syncing…';
7420 msg.className = 'settings-msg';
7421 const s = lastBackupSettingsPayload;
7422 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
7423 const hostedPath = isHosted && s.github_connect_available;
7424 let opts = { method: 'POST' };
7425 if (hostedPath) {
7426 const slug =
7427 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
7428 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
7429 normalizeGithubRepoSlug(s.repo);
7430 if (!slug) {
7431 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
7432 msg.className = 'settings-msg err';
7433 return;
7434 }
7435 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
7436 opts.body = JSON.stringify({ repo: slug });
7437 }
7438 setButtonBusy(syncBtn, true, 'Backing up…');
7439 try {
7440 const result = await api('/api/v1/vault/sync', opts);
7441 msg.textContent = result.message || 'Done.';
7442 const initBtnOk = el('btn-vault-git-init');
7443 if (initBtnOk) initBtnOk.classList.add('hidden');
7444 if (hostedPath && s) {
7445 const refreshed = await api('/api/v1/settings');
7446 lastBackupSettingsPayload = refreshed;
7447 const vg = refreshed.vault_git || {};
7448 let gitText = 'Not configured';
7449 if (vg.enabled && vg.has_remote) {
7450 gitText = 'Configured';
7451 if (vg.auto_commit) gitText += ' (auto-commit on)';
7452 if (vg.auto_push) gitText += ', auto-push on';
7453 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7454 el('settings-git-status').textContent = gitText;
7455 const step4 = document.getElementById('setup-step-4');
7456 if (step4) {
7457 const done = !!(vg.enabled && vg.has_remote);
7458 step4.classList.toggle('setup-step-done', done);
7459 const icon = step4.querySelector('.setup-step-icon');
7460 if (icon) icon.textContent = done ? '✓' : '';
7461 }
7462 }
7463 } catch (e) {
7464 msg.textContent = e.message || 'Sync failed';
7465 msg.className = 'settings-msg err';
7466 const initBtn = el('btn-vault-git-init');
7467 if (initBtn) {
7468 const st = lastBackupSettingsPayload;
7469 const hosted =
7470 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
7471 const needInit =
7472 e.code === 'GIT_NOT_INITIALIZED' ||
7473 /not a Git repository/i.test(e.message || '');
7474 initBtn.classList.toggle('hidden', hosted || !needInit);
7475 }
7476 } finally {
7477 setButtonBusy(syncBtn, false);
7478 const st = lastBackupSettingsPayload;
7479 if (syncBtn && st) {
7480 const vg = st.vault_git || {};
7481 const vd = st.vault_path_display || '';
7482 const ih = (vd + '').toLowerCase() === 'canister';
7483 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
7484 }
7485 }
7486 };
7487 const btnVaultGitInit = el('btn-vault-git-init');
7488 if (btnVaultGitInit) {
7489 btnVaultGitInit.onclick = async () => {
7490 const msg = el('settings-sync-msg');
7491 msg.textContent = 'Initializing Git…';
7492 msg.className = 'settings-msg';
7493 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
7494 try {
7495 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
7496 msg.textContent = out.message || 'Git initialized. Try Back up now.';
7497 msg.className = 'settings-msg ok';
7498 btnVaultGitInit.classList.add('hidden');
7499 } catch (e) {
7500 msg.textContent = e.message || 'Git init failed';
7501 msg.className = 'settings-msg err';
7502 }
7503 });
7504 };
7505 }
7506 const saveSetupBtn = el('btn-settings-save');
7507 if (saveSetupBtn) {
7508 saveSetupBtn.onclick = async () => {
7509 const msg = el('settings-save-msg');
7510 if (msg) {
7511 msg.textContent = 'Saving…';
7512 msg.className = 'settings-msg';
7513 }
7514 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
7515 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
7516 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
7517 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
7518 try {
7519 await api('/api/v1/setup', {
7520 method: 'POST',
7521 body: JSON.stringify({
7522 vault_path: vault_path || undefined,
7523 vault_git: { enabled, remote: remote || undefined },
7524 }),
7525 });
7526 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.' : '');
7527 if (msg) {
7528 msg.textContent = successText;
7529 msg.className = 'settings-msg ok';
7530 }
7531 if (typeof showToast === 'function') showToast('Setup saved.');
7532 api('/api/v1/settings').then((s) => {
7533 const vd = s.vault_path_display || '—';
7534 const isHostedNow = (vd + '').toLowerCase() === 'canister';
7535 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
7536 el('settings-vault-display').textContent = vd;
7537 const configureSection = el('settings-configure-backup-section');
7538 const configureHr = el('settings-hr-configure');
7539 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
7540 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
7541 const vg = s.vault_git || {};
7542 let gitText = 'Not configured';
7543 if (vg.enabled && vg.has_remote) {
7544 gitText = 'Configured';
7545 if (vg.auto_commit) gitText += ' (auto-commit on)';
7546 if (vg.auto_push) gitText += ', auto-push on';
7547 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7548 el('settings-git-status').textContent = gitText;
7549 const syncBtn = el('btn-settings-sync');
7550 const isAdmin = s.role === 'admin';
7551 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
7552 if (msg) {
7553 msg.textContent = successText;
7554 msg.className = 'settings-msg ok';
7555 }
7556 }).catch(() => {});
7557 } catch (e) {
7558 const errMsg = e.message || 'Save failed';
7559 if (msg) {
7560 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
7561 ? 'Only admins can save setup. Your role is shown under Status above.'
7562 : errMsg;
7563 msg.className = 'settings-msg err';
7564 }
7565 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
7566 }
7567 });
7568 };
7569 }
7570
7571 function defaultFullPath() {
7572 const sel = el('full-path-folder');
7573 const folder =
7574 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7575 return folder + '/note-' + Date.now() + '.md';
7576 }
7577
7578 let fullPathFolderLoadToken = 0;
7579 async function refreshFullPathFolderSelect() {
7580 const sel = el('full-path-folder');
7581 if (!sel || !token) return;
7582 const my = ++fullPathFolderLoadToken;
7583 let folders = ['inbox'];
7584 try {
7585 const data = await api('/api/v1/vault/folders');
7586 if (my !== fullPathFolderLoadToken) return;
7587 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7588 } catch (_) {
7589 if (my !== fullPathFolderLoadToken) return;
7590 }
7591 lastVaultFoldersForCreate = folders.slice();
7592 const preserve = sel.value;
7593 sel.innerHTML = '';
7594 for (const f of folders) {
7595 const o = document.createElement('option');
7596 o.value = f;
7597 o.textContent = f;
7598 sel.appendChild(o);
7599 }
7600 const custom = document.createElement('option');
7601 custom.value = '__custom__';
7602 custom.textContent = 'Custom (type path below)';
7603 sel.appendChild(custom);
7604 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7605 else sel.value = folders[0] || 'inbox';
7606 refreshFullCreateSubrootSelect();
7607 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
7608 }
7609
7610 let importVaultFolderLoadToken = 0;
7611 async function refreshImportVaultFolderSelect() {
7612 const sel = el('import-vault-folder');
7613 if (!sel || !token) return;
7614 const my = ++importVaultFolderLoadToken;
7615 let folders = ['inbox'];
7616 try {
7617 const data = await api('/api/v1/vault/folders');
7618 if (my !== importVaultFolderLoadToken) return;
7619 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7620 } catch (_) {
7621 if (my !== importVaultFolderLoadToken) return;
7622 }
7623 lastVaultFoldersForCreate = folders.slice();
7624 const preserve = sel.value;
7625 sel.innerHTML = '';
7626 for (const f of folders) {
7627 const o = document.createElement('option');
7628 o.value = f;
7629 o.textContent = f;
7630 sel.appendChild(o);
7631 }
7632 const custom = document.createElement('option');
7633 custom.value = '__custom__';
7634 custom.textContent = 'Custom (type path below)';
7635 sel.appendChild(custom);
7636 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7637 else sel.value = folders[0] || 'inbox';
7638 refreshImportCreateSubrootSelect();
7639 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
7640 }
7641
7642 function syncFolderSelectToPathInput() {
7643 const pathInput = el('full-path');
7644 const sel = el('full-path-folder');
7645 if (!pathInput || !sel) return;
7646 const p = pathInput.value.trim();
7647 if (!p) return;
7648 let best = '__custom__';
7649 let bestLen = -1;
7650 for (const opt of sel.options) {
7651 const v = opt.value;
7652 if (v === '__custom__') continue;
7653 if (p === v || p.startsWith(v + '/')) {
7654 if (v.length > bestLen) {
7655 best = v;
7656 bestLen = v.length;
7657 }
7658 }
7659 }
7660 sel.value = bestLen >= 0 ? best : '__custom__';
7661 }
7662
7663 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
7664 function syncFullProjectFromPath() {
7665 const pi = el('full-path');
7666 const fp = el('full-project');
7667 if (!pi || !fp) return;
7668 const slug = projectSlugFromProjectsPath(pi.value.trim());
7669 if (slug) {
7670 fp.value = slug;
7671 fp.readOnly = true;
7672 fp.title = 'Derived from vault path projects/' + slug + '/';
7673 } else {
7674 fp.readOnly = false;
7675 fp.removeAttribute('title');
7676 }
7677 updateFullPathProjectTypoHint();
7678 }
7679
7680 function updateFullPathProjectTypoHint() {
7681 const pi = el('full-path');
7682 const hint = el('full-path-project-typo-hint');
7683 const fixBtn = el('btn-full-path-fix-typo');
7684 if (!pi || !hint) return;
7685 const raw = pi.value.trim();
7686 const sug = projectsPathTypoSuggestion(raw);
7687 if (sug) {
7688 hint.textContent =
7689 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
7690 hint.className = 'muted small detail-project-hint warn';
7691 hint.classList.remove('hidden');
7692 if (fixBtn) {
7693 fixBtn.classList.remove('hidden');
7694 fixBtn.onclick = () => {
7695 pi.value = sug;
7696 syncFolderSelectToPathInput();
7697 syncFullCreatePickersFromPath();
7698 syncFullProjectFromPath();
7699 scheduleFullCreateSimilarHint();
7700 };
7701 }
7702 } else {
7703 hint.textContent = '';
7704 hint.className = 'muted small detail-project-hint hidden';
7705 hint.classList.add('hidden');
7706 if (fixBtn) {
7707 fixBtn.classList.add('hidden');
7708 fixBtn.onclick = null;
7709 }
7710 }
7711 }
7712
7713 const fullPathFolderEl = () => el('full-path-folder');
7714 const fullPathInputEl = () => el('full-path');
7715 if (fullPathFolderEl() && fullPathInputEl()) {
7716 fullPathFolderEl().addEventListener('change', () => {
7717 const sel = fullPathFolderEl();
7718 if (!sel || sel.value === '__custom__') return;
7719 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
7720 syncFullCreatePickersFromPath();
7721 syncFullProjectFromPath();
7722 updateFullPathProjectTypoHint();
7723 scheduleFullCreateSimilarHint();
7724 });
7725 fullPathInputEl().addEventListener('input', () => {
7726 syncFolderSelectToPathInput();
7727 syncFullCreatePickersFromPath();
7728 syncFullProjectFromPath();
7729 updateFullPathProjectTypoHint();
7730 scheduleFullCreateSimilarHint();
7731 });
7732 fullPathInputEl().addEventListener('change', () => {
7733 syncFullCreatePickersFromPath();
7734 updateFullCreateSimilarInlineHint();
7735 });
7736 }
7737
7738 const fullCreateProjectSlugEl = el('full-create-project-slug');
7739 const fullCreateProjectSubEl = el('full-create-project-subroot');
7740 if (fullCreateProjectSlugEl) {
7741 fullCreateProjectSlugEl.addEventListener('change', () => {
7742 refreshFullCreateSubrootSelect();
7743 updateFullCreatePathLayoutVisibility();
7744 const v = fullCreateProjectSlugEl.value;
7745 const pi = el('full-path');
7746 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
7747 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
7748 syncFolderSelectToPathInput();
7749 syncFullProjectFromPath();
7750 updateFullPathProjectTypoHint();
7751 scheduleFullCreateSimilarHint();
7752 });
7753 }
7754 if (fullCreateProjectSubEl) {
7755 fullCreateProjectSubEl.addEventListener('change', () => {
7756 composeFullPathFromCreatePickers();
7757 syncFolderSelectToPathInput();
7758 syncFullProjectFromPath();
7759 updateFullPathProjectTypoHint();
7760 scheduleFullCreateSimilarHint();
7761 });
7762 }
7763
7764 const importCreateProjectSlugEl = el('import-create-project-slug');
7765 const importCreateProjectSubEl = el('import-create-project-subroot');
7766 const importVaultFolderEl = el('import-vault-folder');
7767 const importOutputDirEl = el('import-output-dir');
7768 if (importVaultFolderEl) {
7769 importVaultFolderEl.addEventListener('change', () => {
7770 const sel = importVaultFolderEl;
7771 const out = el('import-output-dir');
7772 if (!sel || !out || sel.value === '__custom__') return;
7773 out.value = sel.value;
7774 syncImportPickersFromOutputDir();
7775 });
7776 }
7777 if (importOutputDirEl) {
7778 importOutputDirEl.addEventListener('input', () => {
7779 syncImportFolderSelectToOutputDir();
7780 syncImportPickersFromOutputDir();
7781 });
7782 }
7783 if (importCreateProjectSlugEl) {
7784 importCreateProjectSlugEl.addEventListener('change', () => {
7785 refreshImportCreateSubrootSelect();
7786 updateImportPathLayoutVisibility();
7787 const v = importCreateProjectSlugEl.value;
7788 const out = el('import-output-dir');
7789 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
7790 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
7791 const sel = el('import-vault-folder');
7792 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7793 }
7794 syncImportFolderSelectToOutputDir();
7795 syncImportPickersFromOutputDir();
7796 });
7797 }
7798 if (importCreateProjectSubEl) {
7799 importCreateProjectSubEl.addEventListener('change', () => {
7800 composeImportOutputDirFromPickers();
7801 syncImportFolderSelectToOutputDir();
7802 syncImportPickersFromOutputDir();
7803 });
7804 }
7805
7806 document.querySelectorAll('.modal-tab').forEach((t) => {
7807 t.onclick = () => {
7808 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
7809 t.classList.add('active');
7810 const tab = t.dataset.createTab;
7811 el('create-quick').classList.toggle('hidden', tab !== 'quick');
7812 el('create-full').classList.toggle('hidden', tab !== 'full');
7813 if (tab === 'full') {
7814 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
7815 void (async () => {
7816 await refreshFullPathFolderSelect();
7817 if (!lastHubFacets) {
7818 try {
7819 lastHubFacets = await fetchFacetsResolved();
7820 } catch (_) {}
7821 }
7822 hydrateFullCreateProjectSlugSelect(lastHubFacets);
7823 const pi = el('full-path');
7824 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
7825 else syncFolderSelectToPathInput();
7826 syncFullCreatePickersFromPath();
7827 syncFullProjectFromPath();
7828 updateFullPathProjectTypoHint();
7829 updateFullCreateSimilarInlineHint();
7830 })();
7831 }
7832 };
7833 });
7834
7835 el('btn-quick-save').onclick = async () => {
7836 const quickBtn = el('btn-quick-save');
7837 const body = el('quick-body').value.trim();
7838 const msg = el('create-msg-quick');
7839 if (!body) {
7840 msg.textContent = 'Enter some text.';
7841 msg.className = 'create-msg err';
7842 return;
7843 }
7844 const projectRaw = el('quick-project').value.trim();
7845 const pslug = normSlug(projectRaw);
7846 const today = ymd(new Date());
7847 const slug = 'hub_' + Date.now();
7848 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
7849 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
7850 await withButtonBusy(quickBtn, 'Saving…', async () => {
7851 try {
7852 await api('/api/v1/notes', {
7853 method: 'POST',
7854 body: stringifyNotePostPayload(path, body, {
7855 source: 'hub',
7856 date: today,
7857 title,
7858 ...(pslug && { project: pslug }),
7859 }),
7860 });
7861 hubMarkSemanticIndexStale();
7862 msg.textContent = 'Saved: ' + path;
7863 msg.className = 'create-msg ok';
7864 el('quick-body').value = '';
7865 loadFacets();
7866 loadNotes();
7867 closeCreateModal();
7868 } catch (e) {
7869 msg.textContent = e.message;
7870 msg.className = 'create-msg err';
7871 }
7872 });
7873 };
7874
7875 async function submitFullCreateNote() {
7876 const fullBtn = el('btn-full-save');
7877 const notePath = el('full-path').value.trim();
7878 const pathProjFull = projectSlugFromProjectsPath(notePath);
7879 const msg = el('create-msg-full');
7880 if (!notePath) {
7881 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
7882 msg.className = 'create-msg err';
7883 return;
7884 }
7885 const pathTypoSug = projectsPathTypoSuggestion(notePath);
7886 if (pathTypoSug) {
7887 msg.textContent =
7888 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
7889 pathTypoSug;
7890 msg.className = 'create-msg err';
7891 return;
7892 }
7893 if (!notePath.endsWith('.md')) {
7894 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
7895 msg.className = 'create-msg err';
7896 return;
7897 }
7898 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
7899 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
7900 const dest = notePath.replace(/\\/g, '/');
7901 if (src === dest) {
7902 msg.textContent =
7903 'When duplicating, pick a different path than the original (same path would overwrite the original).';
7904 msg.className = 'create-msg err';
7905 return;
7906 }
7907 }
7908 const slugFromPath = projectSlugFromProjectsPath(notePath);
7909 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
7910 const similarGuess =
7911 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
7912 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
7913 : null;
7914 if (similarGuess) {
7915 openFullCreateSimilarModal(notePath, similarGuess);
7916 return;
7917 }
7918 fullCreateSimilarOverrideOnce = false;
7919 const title = el('full-title').value.trim();
7920 const body = el('full-body').value;
7921 const project = pathProjFull || el('full-project').value.trim();
7922 const tags = el('full-tags').value.trim();
7923 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
7924 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
7925 const entityRaw = el('full-entity') && el('full-entity').value.trim();
7926 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
7927 const episode = el('full-episode') && el('full-episode').value.trim();
7928 const followsRaw = el('full-follows') && el('full-follows').value.trim();
7929 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
7930 const fm = {
7931 date: dateVal,
7932 ...(title && { title }),
7933 ...(project && { project }),
7934 ...(tags && { tags }),
7935 ...(causalChain && { causal_chain_id: causalChain }),
7936 ...(entity && entity.length && { entity }),
7937 ...(episode && { episode_id: episode }),
7938 ...(follows && { follows }),
7939 };
7940 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
7941 await withButtonBusy(fullBtn, savingLabel, async () => {
7942 try {
7943 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
7944 hubMarkSemanticIndexStale();
7945 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
7946 msg.className = 'create-msg ok';
7947 const dupSrc = pendingDuplicateDeleteSource;
7948 const delChk = el('duplicate-delete-after-save');
7949 const shouldDeleteOriginal =
7950 dupSrc &&
7951 dupSrc.path &&
7952 delChk &&
7953 delChk.checked &&
7954 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
7955 if (shouldDeleteOriginal) {
7956 try {
7957 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
7958 if (typeof showToast === 'function') showToast('Original note deleted');
7959 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
7960 const bcb = el('btn-detail-copy-body');
7961 if (bcb) bcb.classList.add('hidden');
7962 } catch (delErr) {
7963 if (typeof showToast === 'function') {
7964 showToast(
7965 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
7966 true,
7967 );
7968 }
7969 }
7970 }
7971 void refreshFullPathFolderSelect().then(() => {
7972 el('full-path').value = defaultFullPath();
7973 syncFolderSelectToPathInput();
7974 syncFullCreatePickersFromPath();
7975 syncFullProjectFromPath();
7976 updateFullCreateSimilarInlineHint();
7977 });
7978 el('full-title').value = '';
7979 el('full-body').value = '';
7980 el('full-project').value = '';
7981 el('full-tags').value = '';
7982 if (el('full-date')) el('full-date').value = '';
7983 if (el('full-causal-chain')) el('full-causal-chain').value = '';
7984 if (el('full-entity')) el('full-entity').value = '';
7985 if (el('full-episode')) el('full-episode').value = '';
7986 if (el('full-follows')) el('full-follows').value = '';
7987 loadFacets();
7988 loadNotes();
7989 closeCreateModal();
7990 } catch (e) {
7991 msg.textContent = e.message;
7992 msg.className = 'create-msg err';
7993 }
7994 });
7995 }
7996
7997 el('btn-full-save').onclick = () => {
7998 void submitFullCreateNote();
7999 };
8000
8001 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
8002 const modalSimilarClose = el('modal-create-similar-project-close');
8003 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
8004 const btnSimilarKeep = el('btn-modal-create-similar-keep');
8005 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
8006 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
8007 if (btnSimilarUseExisting) {
8008 btnSimilarUseExisting.onclick = () => {
8009 const path = fullCreateSimilarModalPendingPath;
8010 const slug = fullCreateSimilarModalSuggestedSlug;
8011 closeFullCreateSimilarModal();
8012 if (path && slug) {
8013 const pi = el('full-path');
8014 if (pi) {
8015 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
8016 syncFolderSelectToPathInput();
8017 syncFullCreatePickersFromPath();
8018 syncFullProjectFromPath();
8019 updateFullPathProjectTypoHint();
8020 updateFullCreateSimilarInlineHint();
8021 }
8022 }
8023 fullCreateSimilarOverrideOnce = false;
8024 void submitFullCreateNote();
8025 };
8026 }
8027 if (btnSimilarKeep) {
8028 btnSimilarKeep.onclick = () => {
8029 closeFullCreateSimilarModal();
8030 fullCreateSimilarOverrideOnce = true;
8031 void submitFullCreateNote();
8032 };
8033 }
8034
8035 function formatDetailReadBody(body, fm) {
8036 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8037 const keys = Object.keys(o);
8038 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
8039 if (keys.length === 0 && hubUserCanWriteNotes()) {
8040 text +=
8041 '\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).';
8042 }
8043 return text;
8044 }
8045
8046 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
8047 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
8048
8049 function videoExtToMime(url) {
8050 try {
8051 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
8052 return VIDEO_MIME_MAP[ext] || 'video/mp4';
8053 } catch (_) {
8054 var clean = url.split('?')[0].split('#')[0];
8055 var ext2 = clean.split('.').pop().toLowerCase();
8056 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
8057 }
8058 }
8059
8060 /**
8061 * Ensure standalone video URL lines are surrounded by blank lines in the raw
8062 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
8063 * mode joins adjacent lines (e.g. a video URL followed immediately by image
8064 * markdown) into a single <p>, which prevents the video-URL regex from matching.
8065 */
8066 function isolateVideoUrlLines(md) {
8067 // Match any line whose entire content is a bare https video URL.
8068 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
8069 // and after so marked always puts the URL in its own paragraph.
8070 return md.replace(
8071 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
8072 '\n$1$2\n'
8073 );
8074 }
8075
8076 /**
8077 * Replace bare video URLs (on their own line) with <video> elements.
8078 * Handles two forms that marked produces for a bare URL on its own paragraph:
8079 * 1. GFM autolink: <p><a href="URL">URL</a></p>
8080 * 2. Plain text: <p>URL</p>
8081 * Runs before DOMPurify so the sanitiser validates the output.
8082 */
8083 function expandVideoUrls(html) {
8084 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
8085
8086 // GFM autolink form: <p><a href="URL">...</a></p>
8087 var result = html.replace(
8088 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
8089 function (match, url) {
8090 if (!VIDEO_EXT_PAT.test(url)) return match;
8091 var mime = videoExtToMime(url);
8092 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8093 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8094 'Your browser does not support embedded video.</video>';
8095 }
8096 );
8097
8098 // Plain text form: <p>URL</p>
8099 result = result.replace(
8100 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
8101 function (match, url) {
8102 if (!VIDEO_EXT_PAT.test(url)) return match;
8103 var mime = videoExtToMime(url);
8104 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8105 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8106 'Your browser does not support embedded video.</video>';
8107 }
8108 );
8109
8110 return result;
8111 }
8112
8113 var SANITIZE_OPTS_NOTE = {
8114 ADD_TAGS: ['details', 'summary', 'video', 'source'],
8115 ADD_ATTR: ['controls', 'preload', 'type'],
8116 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
8117 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
8118 };
8119
8120 /**
8121 * Render markdown text as sanitised HTML.
8122 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
8123 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
8124 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
8125 */
8126 var _imageProxyToken = null;
8127 var _imageProxyTokenExp = 0;
8128
8129 async function getImageProxyToken() {
8130 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
8131 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8132 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8133 if (!jwt) return '';
8134 try {
8135 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
8136 headers: { authorization: 'Bearer ' + jwt },
8137 });
8138 if (!res.ok) return '';
8139 var data = await res.json();
8140 _imageProxyToken = data.token || '';
8141 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
8142 return _imageProxyToken;
8143 } catch (_) { return ''; }
8144 }
8145
8146 /**
8147 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
8148 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
8149 * Falls back to no rewrite if no cached image token is available yet.
8150 */
8151 function rewriteGitHubImageUrls(html) {
8152 var tok = _imageProxyToken || '';
8153 if (!tok) {
8154 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8155 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8156 }
8157 if (!tok) return html;
8158 var encodedTok = encodeURIComponent(tok);
8159 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8160 return html.replace(
8161 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8162 function (match, pre, rest) {
8163 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8164 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8165 }
8166 );
8167 }
8168
8169 function renderNoteMarkdownHtml(md) {
8170 try {
8171 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8172 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8173 var withVideo = expandVideoUrls(raw);
8174 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8175 return rewriteGitHubImageUrls(sanitised);
8176 }
8177 } catch (_) { /* fall through */ }
8178 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8179 }
8180
8181 /**
8182 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8183 */
8184 function buildNoteReadHtml(body, fm) {
8185 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8186 const keys = Object.keys(o);
8187 const bodyHtml = renderNoteMarkdownHtml(body || '');
8188 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8189 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8190 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
8191 : '';
8192 return (
8193 bodyHtml +
8194 '<details class="note-meta-block">' +
8195 '<summary>Metadata</summary>' +
8196 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
8197 emptyNote +
8198 '</details>'
8199 );
8200 }
8201
8202 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
8203 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
8204 'absolute_path',
8205 'body',
8206 'body_length',
8207 'byte_offset',
8208 'byte_offsets',
8209 'frontmatter',
8210 'line_range',
8211 'line_ranges',
8212 'mcp_resource_uri',
8213 'provider_payload',
8214 'raw_canister_payload',
8215 'resource_uri',
8216 'section_body',
8217 'section_body_length',
8218 'snippet',
8219 'snippets',
8220 ]);
8221
8222 function normalizeSectionSourcePathForUi(path) {
8223 const value = String(path || '').trim();
8224 if (!value) return '';
8225 if (value.includes('\\') || value.includes('\0')) return '';
8226 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
8227 if (value.split('/').some((part) => part === '..')) return '';
8228 return value;
8229 }
8230
8231 function sectionSourceEndpointForPath(path) {
8232 return '/api/v1/section-source?path=' + encodeURIComponent(path);
8233 }
8234
8235 function sectionSourcePayloadHasForbiddenKeys(value) {
8236 if (!value || typeof value !== 'object') return false;
8237 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
8238 for (const [key, child] of Object.entries(value)) {
8239 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
8240 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
8241 }
8242 return false;
8243 }
8244
8245 function normalizeSectionSourceForRender(data) {
8246 if (!data || typeof data !== 'object' || Array.isArray(data)) {
8247 throw new Error('INVALID_SECTION_SOURCE');
8248 }
8249 if (sectionSourcePayloadHasForbiddenKeys(data)) {
8250 throw new Error('INVALID_SECTION_SOURCE');
8251 }
8252 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
8253 throw new Error('INVALID_SECTION_SOURCE');
8254 }
8255 return {
8256 schema: SECTION_SOURCE_SCHEMA,
8257 path: String(data.path || ''),
8258 title: String(data.title || ''),
8259 truncated: data.truncated === true,
8260 sections: data.sections.map((section) => {
8261 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
8262 const normalized = {
8263 section_id: String(item.section_id || ''),
8264 heading_id: String(item.heading_id || ''),
8265 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
8266 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
8267 heading_text: String(item.heading_text || ''),
8268 child_section_ids: Array.isArray(item.child_section_ids)
8269 ? item.child_section_ids.map((childId) => String(childId))
8270 : [],
8271 body_available: item.body_available === true,
8272 body_returned: item.body_returned === true,
8273 snippet_returned: item.snippet_returned === true,
8274 };
8275 if (normalized.body_returned || normalized.snippet_returned) {
8276 throw new Error('INVALID_SECTION_SOURCE');
8277 }
8278 return normalized;
8279 }),
8280 };
8281 }
8282
8283 function resetDetailSectionSourceState() {
8284 hubSectionSourceSeq += 1;
8285 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
8286 }
8287
8288 function setSectionSourcePanelState(panel, state, message) {
8289 panel.className = 'section-source-panel section-source-panel-' + state;
8290 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
8291 panel.setAttribute('aria-label', 'Body-free section list');
8292 panel.setAttribute('aria-live', 'polite');
8293 panel.replaceChildren();
8294 const text = document.createElement('p');
8295 text.className = 'section-source-state';
8296 text.textContent = message;
8297 panel.appendChild(text);
8298 }
8299
8300 function sectionSourceErrorMessage(error) {
8301 const code = error && error.code ? String(error.code) : '';
8302 const message = error && error.message ? String(error.message) : '';
8303 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
8304 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
8305 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
8306 if (message === 'Unauthorized') return 'Sign in to view sections.';
8307 return 'Sections are unavailable right now.';
8308 }
8309
8310 function appendSectionSourceDebugRow(list, labelText, valueText) {
8311 const label = document.createElement('dt');
8312 label.textContent = labelText;
8313 const value = document.createElement('dd');
8314 value.textContent = valueText;
8315 list.append(label, value);
8316 }
8317
8318 function renderSectionSourceData(panel, source) {
8319 panel.className = 'section-source-panel';
8320 panel.setAttribute('role', 'region');
8321 panel.setAttribute('aria-label', 'Body-free section list');
8322 panel.setAttribute('aria-live', 'polite');
8323 panel.replaceChildren();
8324
8325 const header = document.createElement('div');
8326 header.className = 'section-source-header';
8327 const title = document.createElement('h3');
8328 title.textContent = 'Sections';
8329 const meta = document.createElement('p');
8330 meta.className = 'muted small';
8331 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
8332 header.append(title, meta);
8333 panel.appendChild(header);
8334
8335 if (source.truncated) {
8336 const truncated = document.createElement('p');
8337 truncated.className = 'section-source-state section-source-truncated';
8338 truncated.textContent = 'Section list is capped for display.';
8339 panel.appendChild(truncated);
8340 }
8341
8342 if (source.sections.length === 0) {
8343 const empty = document.createElement('p');
8344 empty.className = 'section-source-state';
8345 empty.textContent = 'No headings are available for this note.';
8346 panel.appendChild(empty);
8347 return;
8348 }
8349
8350 const list = document.createElement('ol');
8351 list.className = 'section-source-list';
8352 for (const section of source.sections) {
8353 const item = document.createElement('li');
8354 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
8355
8356 const heading = document.createElement('p');
8357 heading.className = 'section-source-heading';
8358 const levelBadge = document.createElement('span');
8359 levelBadge.className = 'section-source-level-label';
8360 levelBadge.textContent = 'H' + section.level;
8361 const headingText = document.createElement('span');
8362 headingText.className = 'section-source-heading-text';
8363 headingText.textContent = section.heading_text || '(Untitled section)';
8364 heading.append(levelBadge, headingText);
8365 item.appendChild(heading);
8366
8367 const detail = document.createElement('p');
8368 detail.className = 'section-source-detail muted small';
8369 detail.textContent = 'Heading level: H' + section.level;
8370 item.appendChild(detail);
8371
8372 const pathLine = document.createElement('p');
8373 pathLine.className = 'section-source-path muted small';
8374 pathLine.textContent =
8375 'Heading path: ' +
8376 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
8377 item.appendChild(pathLine);
8378
8379 const childLine = document.createElement('p');
8380 childLine.className = 'section-source-children muted small';
8381 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
8382 item.appendChild(childLine);
8383
8384 const debugDetails = document.createElement('details');
8385 debugDetails.className = 'section-source-debug muted small';
8386 const debugSummary = document.createElement('summary');
8387 debugSummary.textContent = 'IDs';
8388 const debugList = document.createElement('dl');
8389 debugList.className = 'section-source-debug-list';
8390 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
8391 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
8392 appendSectionSourceDebugRow(
8393 debugList,
8394 'Child IDs',
8395 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
8396 );
8397 debugDetails.append(debugSummary, debugList);
8398 item.appendChild(debugDetails);
8399
8400 list.appendChild(item);
8401 }
8402 panel.appendChild(list);
8403 }
8404
8405 async function loadSectionSourceForCurrentNote(actionsEl, button) {
8406 let panel = actionsEl.querySelector('[data-section-source-panel]');
8407 if (!panel) {
8408 panel = document.createElement('div');
8409 panel.dataset.sectionSourcePanel = 'true';
8410 actionsEl.appendChild(panel);
8411 }
8412 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
8413 if (!path) {
8414 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
8415 return;
8416 }
8417 const seq = ++hubSectionSourceSeq;
8418 const openPath = currentOpenNote.path;
8419 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
8420 if (button) {
8421 button.disabled = true;
8422 button.setAttribute('aria-expanded', 'true');
8423 }
8424 try {
8425 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
8426 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8427 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
8428 } catch (error) {
8429 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8430 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
8431 } finally {
8432 if (button && currentOpenNote && currentOpenNote.path === openPath) {
8433 button.disabled = false;
8434 }
8435 }
8436 }
8437
8438 function toggleSectionSourcePanel(actionsEl, button) {
8439 const panel = actionsEl.querySelector('[data-section-source-panel]');
8440 if (panel) {
8441 hubSectionSourceSeq += 1;
8442 panel.remove();
8443 if (button) button.setAttribute('aria-expanded', 'false');
8444 return;
8445 }
8446 void loadSectionSourceForCurrentNote(actionsEl, button);
8447 }
8448
8449 function createSectionSourceButton(actionsEl) {
8450 const sectionBtn = document.createElement('button');
8451 sectionBtn.type = 'button';
8452 sectionBtn.textContent = 'Sections';
8453 sectionBtn.className = 'btn-section-source';
8454 sectionBtn.setAttribute('aria-expanded', 'false');
8455 sectionBtn.setAttribute('aria-controls', 'detail-actions');
8456 sectionBtn.title = 'Show body-free section headings for this note';
8457 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
8458 return sectionBtn;
8459 }
8460
8461 function switchNoteToReadMode() {
8462 if (!currentOpenNote) return;
8463 resetDetailSectionSourceState();
8464 teardownDetailEditBodyLayout();
8465 const bodyEl = el('detail-body');
8466 const actionsEl = el('detail-actions');
8467 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
8468 bodyEl.className = 'note-rendered-body';
8469 actionsEl.innerHTML = '';
8470 attachNoteDetailReadActions(actionsEl);
8471 const bcbRead = el('btn-detail-copy-body');
8472 if (bcbRead) bcbRead.classList.remove('hidden');
8473 }
8474
8475 async function deleteOpenNote() {
8476 if (!currentOpenNote) return;
8477 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
8478 const p = currentOpenNote.path;
8479 try {
8480 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
8481 if (typeof showToast === 'function') showToast('Note deleted');
8482 hubMarkSemanticIndexStale();
8483 currentOpenNote = null;
8484 currentNotePathForCopy = '';
8485 resetDetailSectionSourceState();
8486 teardownDetailEditBodyLayout();
8487 hideDetailPanelChrome();
8488 el('btn-copy-path').classList.add('hidden');
8489 const bcbDel = el('btn-detail-copy-body');
8490 if (bcbDel) bcbDel.classList.add('hidden');
8491 loadNotes();
8492 loadFacets();
8493 } catch (e) {
8494 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
8495 }
8496 }
8497
8498 function attachNoteDetailReadActions(actionsEl) {
8499 const exportBtn = document.createElement('button');
8500 exportBtn.type = 'button';
8501 exportBtn.textContent = 'Export';
8502 exportBtn.onclick = () => exportCurrentNote('md');
8503 const sectionBtn = createSectionSourceButton(actionsEl);
8504
8505 if (hubUserCanWriteNotes()) {
8506 const editBtn = document.createElement('button');
8507 editBtn.type = 'button';
8508 editBtn.textContent = 'Edit';
8509 editBtn.onclick = () => switchNoteToEditMode();
8510 const dupBtn = document.createElement('button');
8511 dupBtn.type = 'button';
8512 dupBtn.textContent = 'Duplicate…';
8513 dupBtn.title =
8514 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
8515 dupBtn.onclick = () => {
8516 void openDuplicateNoteModal();
8517 };
8518 const proposeBtn = document.createElement('button');
8519 proposeBtn.type = 'button';
8520 proposeBtn.textContent = 'Propose change';
8521 proposeBtn.onclick = () => {
8522 if (!currentOpenNote) return;
8523 openCreateProposalModal({
8524 path: currentOpenNote.path,
8525 body: currentOpenNote.body || '',
8526 fromNote: true,
8527 });
8528 };
8529 const delBtn = document.createElement('button');
8530 delBtn.type = 'button';
8531 delBtn.textContent = 'Delete';
8532 delBtn.onclick = () => deleteOpenNote();
8533 if (hubHasMultipleVaultsForCopy()) {
8534 const copyVaultBtn = document.createElement('button');
8535 copyVaultBtn.type = 'button';
8536 copyVaultBtn.textContent = 'Copy to vault…';
8537 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
8538 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
8539 } else {
8540 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
8541 }
8542 return;
8543 }
8544
8545 if (hubUserMayProposeFromNote()) {
8546 const proposeBtn = document.createElement('button');
8547 proposeBtn.type = 'button';
8548 proposeBtn.textContent = 'Propose change';
8549 proposeBtn.onclick = () => {
8550 if (!currentOpenNote) return;
8551 openCreateProposalModal({
8552 path: currentOpenNote.path,
8553 body: currentOpenNote.body || '',
8554 fromNote: true,
8555 });
8556 };
8557 actionsEl.appendChild(proposeBtn);
8558 }
8559 actionsEl.appendChild(sectionBtn);
8560 if (hubUserCanExportNote()) {
8561 actionsEl.appendChild(exportBtn);
8562 }
8563 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
8564 const hint = document.createElement('p');
8565 hint.className = 'muted small';
8566 hint.style.marginTop = '0.5rem';
8567 hint.textContent =
8568 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
8569 actionsEl.appendChild(hint);
8570 }
8571 }
8572
8573 function openCopyNoteToVaultModal() {
8574 if (!currentOpenNote || !token) return;
8575 if (!hubHasMultipleVaultsForCopy()) {
8576 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
8577 return;
8578 }
8579 const existing = document.getElementById('modal-copy-note-vault');
8580 if (existing) existing.remove();
8581 const s = lastBackupSettingsPayload;
8582 const allowed = new Set((s.allowed_vault_ids || []).map(String));
8583 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
8584 const fromId = String(getCurrentVaultId() || 'default');
8585 const targets = vaultList.filter((v) => String(v.id) !== fromId);
8586 if (targets.length === 0) {
8587 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
8588 return;
8589 }
8590 const wrap = document.createElement('div');
8591 wrap.id = 'modal-copy-note-vault';
8592 wrap.className = 'modal';
8593 wrap.setAttribute('role', 'dialog');
8594 wrap.setAttribute('aria-modal', 'true');
8595 wrap.setAttribute('aria-label', 'Copy note to another vault');
8596 const backdrop = document.createElement('div');
8597 backdrop.className = 'modal-backdrop';
8598 const card = document.createElement('div');
8599 card.className = 'modal-card';
8600 card.style.maxWidth = '480px';
8601 const header = document.createElement('div');
8602 header.className = 'modal-header';
8603 const h2 = document.createElement('h2');
8604 h2.textContent = 'Copy to vault';
8605 const btnClose = document.createElement('button');
8606 btnClose.type = 'button';
8607 btnClose.className = 'modal-close';
8608 btnClose.textContent = '×';
8609 btnClose.setAttribute('aria-label', 'Close');
8610 header.appendChild(h2);
8611 header.appendChild(btnClose);
8612 const body = document.createElement('div');
8613 body.style.padding = '1rem 1.25rem';
8614 const lbl = document.createElement('label');
8615 lbl.className = 'detail-field-label';
8616 lbl.textContent = 'Target vault';
8617 lbl.setAttribute('for', 'copy-note-vault-select');
8618 const sel = document.createElement('select');
8619 sel.id = 'copy-note-vault-select';
8620 sel.className = 'vault-switcher-select';
8621 sel.style.width = '100%';
8622 sel.style.marginTop = '0.35rem';
8623 for (const v of targets) {
8624 const id = String(v.id);
8625 const opt = document.createElement('option');
8626 opt.value = id;
8627 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
8628 sel.appendChild(opt);
8629 }
8630 const moveRow = document.createElement('label');
8631 moveRow.style.display = 'flex';
8632 moveRow.style.alignItems = 'center';
8633 moveRow.style.gap = '0.5rem';
8634 moveRow.style.marginTop = '1rem';
8635 moveRow.style.cursor = 'pointer';
8636 const moveChk = document.createElement('input');
8637 moveChk.type = 'checkbox';
8638 moveChk.id = 'copy-note-delete-source';
8639 const moveSpan = document.createElement('span');
8640 moveSpan.textContent = 'Delete from this vault (move)';
8641 moveRow.appendChild(moveChk);
8642 moveRow.appendChild(moveSpan);
8643 const hint = document.createElement('p');
8644 hint.className = 'muted small';
8645 hint.style.marginTop = '0.75rem';
8646 hint.style.fontSize = '0.85rem';
8647 hint.textContent =
8648 '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).';
8649 const actions = document.createElement('div');
8650 actions.style.display = 'flex';
8651 actions.style.justifyContent = 'flex-end';
8652 actions.style.gap = '0.5rem';
8653 actions.style.marginTop = '1.25rem';
8654 const btnCancel = document.createElement('button');
8655 btnCancel.type = 'button';
8656 btnCancel.className = 'btn-secondary';
8657 btnCancel.textContent = 'Cancel';
8658 const btnGo = document.createElement('button');
8659 btnGo.type = 'button';
8660 btnGo.className = 'btn-primary';
8661 btnGo.textContent = 'Copy';
8662 actions.appendChild(btnCancel);
8663 actions.appendChild(btnGo);
8664 body.appendChild(lbl);
8665 body.appendChild(sel);
8666 body.appendChild(moveRow);
8667 body.appendChild(hint);
8668 body.appendChild(actions);
8669 card.appendChild(header);
8670 card.appendChild(body);
8671 wrap.appendChild(backdrop);
8672 wrap.appendChild(card);
8673 function close() {
8674 wrap.remove();
8675 }
8676 backdrop.onclick = close;
8677 btnClose.onclick = close;
8678 btnCancel.onclick = close;
8679 btnGo.onclick = async () => {
8680 const toId = sel.value;
8681 if (!toId || !currentOpenNote) return;
8682 await withButtonBusy(btnGo, 'Copying…', async () => {
8683 try {
8684 const res = await api('/api/v1/notes/copy', {
8685 method: 'POST',
8686 body: JSON.stringify({
8687 from_vault_id: fromId,
8688 to_vault_id: toId,
8689 path: currentOpenNote.path,
8690 delete_source: moveChk.checked,
8691 }),
8692 });
8693 hubMarkSemanticIndexStaleForVault(toId);
8694 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
8695 close();
8696 if (typeof showToast === 'function') {
8697 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
8698 }
8699 if (res.moved) {
8700 currentOpenNote = null;
8701 currentNotePathForCopy = '';
8702 resetDetailSectionSourceState();
8703 hideDetailPanelChrome();
8704 const bcp = el('btn-copy-path');
8705 if (bcp) bcp.classList.add('hidden');
8706 loadNotes();
8707 loadFacets();
8708 }
8709 } catch (e) {
8710 if (typeof showToast === 'function') showToast(e.message || String(e), true);
8711 }
8712 });
8713 };
8714 document.body.appendChild(wrap);
8715 }
8716
8717 async function exportCurrentNote(format) {
8718 if (!currentOpenNote) return;
8719 try {
8720 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
8721 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
8722 const a = document.createElement('a');
8723 a.href = URL.createObjectURL(blob);
8724 a.download = res.filename || 'export.md';
8725 a.click();
8726 URL.revokeObjectURL(a.href);
8727 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
8728 } catch (e) {
8729 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
8730 }
8731 }
8732
8733 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
8734 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
8735 var MEDIA_URL_SAFE = /^https?:\/\//i;
8736
8737 function teardownDetailEditBodyLayout() {
8738 if (detailEditBodyLayoutAbort) {
8739 detailEditBodyLayoutAbort.abort();
8740 detailEditBodyLayoutAbort = null;
8741 }
8742 }
8743
8744 function detailEditBodyMaxTextareaPx() {
8745 var wrap = el('detail-edit-body-wrap');
8746 var ta = el('detail-edit-body');
8747 if (!wrap || !ta) return 400;
8748 var toolbar = el('media-toolbar');
8749 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8750 var tb = toolbar ? toolbar.offsetHeight : 0;
8751 var gh = grip ? grip.offsetHeight : 0;
8752 var slack = 10;
8753 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
8754 var fallback = Math.round(window.innerHeight * 0.28);
8755 var wr = wrap.getBoundingClientRect();
8756 var next = wrap.nextElementSibling;
8757 var slice = 0;
8758 if (next && next.nodeType === 1) {
8759 var nr = next.getBoundingClientRect();
8760 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
8761 } else {
8762 var body = el('detail-body');
8763 if (body) {
8764 var br = body.getBoundingClientRect();
8765 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
8766 }
8767 }
8768 if (!Number.isFinite(slice) || slice < 120) {
8769 slice = fallback;
8770 }
8771 return Math.max(160, Math.min(hard, slice));
8772 }
8773
8774 function sizeDetailEditBodyToFill() {
8775 var ta = el('detail-edit-body');
8776 if (!ta) return;
8777 ta.style.removeProperty('height');
8778 }
8779
8780 function wireDetailEditBodyLayout() {
8781 teardownDetailEditBodyLayout();
8782 var ta = el('detail-edit-body');
8783 var wrap = el('detail-edit-body-wrap');
8784 if (!ta || !wrap) return;
8785 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8786 if (!grip) {
8787 grip = document.createElement('div');
8788 grip.className = 'detail-edit-body-resize-handle';
8789 grip.setAttribute('role', 'separator');
8790 grip.setAttribute('aria-orientation', 'horizontal');
8791 grip.setAttribute('aria-label', 'Resize editor height');
8792 var next = ta.nextSibling;
8793 if (next && next.id === 'media-toolbar') {
8794 wrap.insertBefore(grip, next);
8795 } else {
8796 wrap.appendChild(grip);
8797 }
8798 }
8799 if (grip.dataset.wired !== '1') {
8800 grip.dataset.wired = '1';
8801 function startDrag(clientY) {
8802 var startY = clientY;
8803 var startH = ta.offsetHeight;
8804 document.body.style.userSelect = 'none';
8805 function onMove(e2) {
8806 if (e2.touches && e2.cancelable) e2.preventDefault();
8807 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
8808 var dy = y - startY;
8809 var cap = detailEditBodyMaxTextareaPx();
8810 var nh = Math.max(160, Math.min(cap, startH + dy));
8811 ta.style.height = nh + 'px';
8812 }
8813 function onUp() {
8814 document.body.style.userSelect = '';
8815 document.removeEventListener('mousemove', onMove);
8816 document.removeEventListener('mouseup', onUp);
8817 document.removeEventListener('touchmove', onMove);
8818 document.removeEventListener('touchend', onUp);
8819 }
8820 document.addEventListener('mousemove', onMove);
8821 document.addEventListener('mouseup', onUp);
8822 document.addEventListener('touchmove', onMove, { passive: false });
8823 document.addEventListener('touchend', onUp);
8824 }
8825 grip.addEventListener('mousedown', function (e) {
8826 e.preventDefault();
8827 startDrag(e.clientY);
8828 });
8829 grip.addEventListener('touchstart', function (e) {
8830 if (!e.touches || !e.touches[0]) return;
8831 e.preventDefault();
8832 startDrag(e.touches[0].clientY);
8833 }, { passive: false });
8834 }
8835 window.requestAnimationFrame(function () {
8836 sizeDetailEditBodyToFill();
8837 });
8838 detailEditBodyLayoutAbort = new AbortController();
8839 window.addEventListener(
8840 'resize',
8841 function () {
8842 if (!el('detail-edit-body-wrap')) return;
8843 sizeDetailEditBodyToFill();
8844 },
8845 { signal: detailEditBodyLayoutAbort.signal }
8846 );
8847 }
8848
8849 function attachMediaToolbar() {
8850 var textarea = el('detail-edit-body');
8851 if (!textarea) return;
8852 var existing = document.getElementById('media-toolbar');
8853 if (existing) existing.remove();
8854
8855 var toolbar = document.createElement('div');
8856 toolbar.id = 'media-toolbar';
8857 toolbar.className = 'media-toolbar';
8858
8859 var insertBtn = document.createElement('button');
8860 insertBtn.type = 'button';
8861 insertBtn.textContent = 'Insert Media URL';
8862 insertBtn.className = 'btn-small';
8863 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.';
8864 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
8865 toolbar.appendChild(insertBtn);
8866
8867 var s = lastBackupSettingsPayload;
8868 if (s && s.github_connected && hubUserCanWriteNotes()) {
8869 var uploadBtn = document.createElement('button');
8870 uploadBtn.type = 'button';
8871 uploadBtn.textContent = 'Upload Image';
8872 uploadBtn.className = 'btn-small';
8873 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.';
8874 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
8875 toolbar.appendChild(uploadBtn);
8876 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
8877 var connectHint = document.createElement('span');
8878 connectHint.className = 'media-toolbar-hint';
8879 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
8880 connectHint.textContent = 'Connect GitHub to upload images';
8881 toolbar.appendChild(connectHint);
8882 }
8883
8884 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
8885 }
8886
8887 function toggleMediaUrlDialog(toolbar, textarea) {
8888 var existing = document.getElementById('media-url-dialog');
8889 if (existing) { existing.remove(); return; }
8890
8891 var dialog = document.createElement('div');
8892 dialog.id = 'media-url-dialog';
8893 dialog.className = 'media-url-dialog';
8894
8895 var input = document.createElement('input');
8896 input.type = 'text';
8897 input.placeholder = 'Paste image or video URL (https://...)';
8898 input.className = 'media-url-input';
8899
8900 var preview = document.createElement('div');
8901 preview.className = 'media-preview';
8902
8903 var actions = document.createElement('div');
8904 actions.className = 'media-url-actions';
8905
8906 var doInsert = document.createElement('button');
8907 doInsert.type = 'button';
8908 doInsert.textContent = 'Insert';
8909 doInsert.className = 'btn-primary btn-small';
8910 doInsert.disabled = true;
8911
8912 var doCancel = document.createElement('button');
8913 doCancel.type = 'button';
8914 doCancel.textContent = 'Cancel';
8915 doCancel.className = 'btn-small';
8916 doCancel.onclick = function () { dialog.remove(); };
8917
8918 actions.appendChild(doInsert);
8919 actions.appendChild(doCancel);
8920
8921 var detectedType = null;
8922
8923 function onUrlChange() {
8924 var url = input.value.trim();
8925 preview.innerHTML = '';
8926 doInsert.disabled = true;
8927 detectedType = null;
8928 if (!url || !MEDIA_URL_SAFE.test(url)) return;
8929 if (MEDIA_IMAGE_EXTS.test(url)) {
8930 detectedType = 'image';
8931 var img = document.createElement('img');
8932 img.src = url;
8933 img.style.maxHeight = '200px';
8934 img.style.maxWidth = '100%';
8935 img.crossOrigin = 'anonymous';
8936 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8937 preview.appendChild(img);
8938 doInsert.disabled = false;
8939 } else if (MEDIA_VIDEO_EXTS.test(url)) {
8940 detectedType = 'video';
8941 var vid = document.createElement('video');
8942 vid.controls = true;
8943 vid.preload = 'metadata';
8944 vid.style.maxHeight = '200px';
8945 vid.style.maxWidth = '100%';
8946 vid.src = url;
8947 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8948 preview.appendChild(vid);
8949 doInsert.disabled = false;
8950 } else {
8951 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>';
8952 }
8953 }
8954
8955 input.addEventListener('input', onUrlChange);
8956 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
8957
8958 doInsert.onclick = function () {
8959 var url = input.value.trim();
8960 if (!url) return;
8961 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
8962 insertAtCursor(textarea, insertion);
8963 dialog.remove();
8964 };
8965
8966 dialog.appendChild(input);
8967 dialog.appendChild(preview);
8968 dialog.appendChild(actions);
8969 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
8970 input.focus();
8971 }
8972
8973 function insertAtCursor(textarea, text) {
8974 var start = textarea.selectionStart;
8975 var end = textarea.selectionEnd;
8976 var val = textarea.value;
8977 var before = val.substring(0, start);
8978 var needsNewline = before.length > 0 && !before.endsWith('\n');
8979 var insertion = (needsNewline ? '\n' : '') + text + '\n';
8980 textarea.value = before + insertion + val.substring(end);
8981 var newPos = start + insertion.length;
8982 textarea.setSelectionRange(newPos, newPos);
8983 textarea.focus();
8984 }
8985
8986 /**
8987 * Compress an image File/Blob using the Canvas API so it fits within the
8988 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
8989 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
8990 * Falls back to the original file if Canvas is unavailable or the image is
8991 * already small enough.
8992 */
8993 function compressImageIfNeeded(file) {
8994 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
8995 var MAX_DIM = 2048;
8996 return new Promise(function (resolve) {
8997 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
8998 return resolve(file);
8999 }
9000 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
9001 return resolve(file);
9002 }
9003 var img = new window.Image();
9004 var objectUrl = URL.createObjectURL(file);
9005 img.onload = function () {
9006 URL.revokeObjectURL(objectUrl);
9007 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
9008 var w = Math.round(img.width * ratio);
9009 var h = Math.round(img.height * ratio);
9010 var canvas = document.createElement('canvas');
9011 canvas.width = w;
9012 canvas.height = h;
9013 var ctx = canvas.getContext('2d');
9014 ctx.drawImage(img, 0, 0, w, h);
9015 var tryQuality = function (quality, attempt) {
9016 canvas.toBlob(function (blob) {
9017 if (!blob) return resolve(file); // Canvas failed — upload original
9018 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
9019 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
9020 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
9021 } else {
9022 tryQuality(quality - 0.2, attempt + 1);
9023 }
9024 }, 'image/jpeg', quality);
9025 };
9026 tryQuality(0.82, 0);
9027 };
9028 img.onerror = function () {
9029 URL.revokeObjectURL(objectUrl);
9030 resolve(file);
9031 };
9032 img.src = objectUrl;
9033 });
9034 }
9035
9036 function triggerImageUpload(textarea) {
9037 var fileInput = document.createElement('input');
9038 fileInput.type = 'file';
9039 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
9040 fileInput.onchange = async function () {
9041 var file = fileInput.files && fileInput.files[0];
9042 if (!file || !currentOpenNote) return;
9043 try {
9044 if (typeof showToast === 'function') showToast('Uploading image…');
9045 // Compress before uploading to stay within the Netlify Lambda 6 MB
9046 // payload limit (~4.5 MB binary after base64 overhead).
9047 var uploadFile = await compressImageIfNeeded(file);
9048 var form = new FormData();
9049 form.append('image', uploadFile);
9050 var notePath = encodeURIComponent(currentOpenNote.path);
9051 var vaultIdParam = '';
9052 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
9053 // Build auth headers from the shared helper (omit Content-Type so the
9054 // browser sets the correct multipart/form-data boundary automatically).
9055 var uploadHeaders = headers();
9056 delete uploadHeaders['Content-Type'];
9057 // Use apiBase so this request reaches the gateway when the frontend is served
9058 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
9059 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9060 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
9061 method: 'POST',
9062 headers: uploadHeaders,
9063 body: form,
9064 });
9065 if (!res.ok) {
9066 var errData = await res.json().catch(function () { return {}; });
9067 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
9068 }
9069 var data = await res.json();
9070 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
9071 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
9072 } catch (e) {
9073 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
9074 }
9075 };
9076 fileInput.click();
9077 }
9078
9079 function switchNoteToEditMode() {
9080 if (!currentOpenNote) return;
9081 closeCreateModal();
9082 resetDetailSectionSourceState();
9083 const bcbEdit = el('btn-detail-copy-body');
9084 if (bcbEdit) bcbEdit.classList.add('hidden');
9085 const bodyEl = el('detail-body');
9086 const actionsEl = el('detail-actions');
9087 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
9088 bodyEl.className = 'detail-edit-container create-panel';
9089 bodyEl.innerHTML =
9090 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
9091 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
9092 '<label for="detail-edit-title">Title</label>' +
9093 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
9094 '<label for="detail-edit-body">Body (Markdown)</label>' +
9095 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
9096 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
9097 '</div>' +
9098 '<label for="detail-edit-date">Date</label>' +
9099 '<input type="date" id="detail-edit-date" />' +
9100 '<label for="detail-edit-project">Project (slug)</label>' +
9101 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
9102 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
9103 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
9104 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
9105 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
9106 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
9107 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
9108 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
9109 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
9110 '<label for="detail-edit-episode">Episode ID</label>' +
9111 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
9112 '<label for="detail-edit-follows">Follows (vault path)</label>' +
9113 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
9114 const pathDisp = el('detail-edit-path-display');
9115 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
9116 fillDetailEditFieldsFromFrontmatter(fm);
9117 attachMediaToolbar();
9118 wireDetailEditBodyLayout();
9119 actionsEl.innerHTML = '';
9120 const saveBtn = document.createElement('button');
9121 saveBtn.textContent = 'Save';
9122 saveBtn.className = 'btn-primary';
9123 saveBtn.onclick = async () => {
9124 closeCreateModal();
9125 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
9126 const frontmatter = mergedFrontmatterForDetailSave();
9127 await withButtonBusy(saveBtn, 'Saving…', async () => {
9128 try {
9129 await api('/api/v1/notes', {
9130 method: 'POST',
9131 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
9132 });
9133 hubMarkSemanticIndexStale();
9134 if (typeof showToast === 'function') showToast('Note saved');
9135 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
9136 const nfm = materializeFrontmatter(refreshed.frontmatter);
9137 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
9138 switchNoteToReadMode();
9139 if (typeof loadNotes === 'function') loadNotes();
9140 if (typeof loadFacets === 'function') loadFacets();
9141 } catch (e) {
9142 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
9143 }
9144 });
9145 };
9146 const cancelBtn = document.createElement('button');
9147 cancelBtn.textContent = 'Cancel';
9148 cancelBtn.onclick = () => switchNoteToReadMode();
9149 const delBtn = document.createElement('button');
9150 delBtn.type = 'button';
9151 delBtn.textContent = 'Delete';
9152 delBtn.onclick = () => deleteOpenNote();
9153 actionsEl.append(saveBtn, delBtn, cancelBtn);
9154 }
9155
9156 function openNote(path) {
9157 const seq = ++hubOpenNoteSeq;
9158 resetDetailSectionSourceState();
9159 teardownDetailEditBodyLayout();
9160 closeCreateModal();
9161 currentNotePathForCopy = path;
9162 currentOpenNote = null;
9163 const panel = el('detail-panel');
9164 panel.classList.remove('detail-panel-proposal-wide');
9165 const title = el('detail-title');
9166 const bodyEl = el('detail-body');
9167 const actionsEl = el('detail-actions');
9168 const btnCopy = el('btn-copy-path');
9169 const btnCopyBody = el('btn-detail-copy-body');
9170 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9171 title.textContent = path;
9172 bodyEl.textContent = 'Loading…';
9173 bodyEl.className = '';
9174 actionsEl.innerHTML = '';
9175 btnCopy.classList.remove('hidden');
9176 panel.classList.remove('hidden');
9177 api('/api/v1/notes/' + encodeURIComponent(path))
9178 .then((note) => {
9179 if (seq !== hubOpenNoteSeq) return;
9180 const fm = materializeFrontmatter(note.frontmatter);
9181 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9182 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9183 bodyEl.className = 'note-rendered-body';
9184 actionsEl.innerHTML = '';
9185 attachNoteDetailReadActions(actionsEl);
9186 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9187 })
9188 .catch((e) => {
9189 if (seq !== hubOpenNoteSeq) return;
9190 bodyEl.textContent = 'Error: ' + e.message;
9191 bodyEl.className = '';
9192 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9193 });
9194 }
9195
9196 el('btn-copy-path').onclick = () => {
9197 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
9198 };
9199
9200 const btnDetailCopyBody = el('btn-detail-copy-body');
9201 if (btnDetailCopyBody) {
9202 btnDetailCopyBody.onclick = () => {
9203 if (!currentOpenNote) {
9204 if (typeof showToast === 'function') showToast('Open a note first.', true);
9205 return;
9206 }
9207 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
9208 if (navigator.clipboard && navigator.clipboard.writeText) {
9209 navigator.clipboard.writeText(text).then(
9210 () => {
9211 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
9212 },
9213 () => {
9214 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
9215 },
9216 );
9217 } else if (typeof showToast === 'function') {
9218 showToast('Clipboard not available in this browser.', true);
9219 }
9220 };
9221 }
9222
9223 const btnCopyUserId = el('btn-copy-user-id');
9224 if (btnCopyUserId) {
9225 btnCopyUserId.onclick = () => {
9226 const idEl = el('settings-user-id');
9227 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
9228 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9229 navigator.clipboard.writeText(text).then(() => {
9230 if (typeof showToast === 'function') showToast('User ID copied.');
9231 }).catch(() => {});
9232 }
9233 };
9234 }
9235 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
9236 if (btnCopyAgentceptionEnv) {
9237 btnCopyAgentceptionEnv.onclick = () => {
9238 const envEl = el('integrations-agentception-env');
9239 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
9240 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9241 navigator.clipboard.writeText(text).then(() => {
9242 if (typeof showToast === 'function') showToast('Env snippet copied.');
9243 }).catch(() => {});
9244 }
9245 };
9246 }
9247 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
9248 if (btnIntegrationsHowToAgentception) {
9249 btnIntegrationsHowToAgentception.onclick = () => {
9250 closeSettings();
9251 openHowToUse('setup');
9252 };
9253 }
9254 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
9255 if (btnHowToFlexibleNetwork) {
9256 btnHowToFlexibleNetwork.onclick = () => {
9257 closeSettings();
9258 openHowToUse('setup', 'how-to-flexible-network');
9259 };
9260 }
9261
9262 function renderProposalMarkdownHtml(md) {
9263 try {
9264 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9265 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9266 var withVideo = expandVideoUrls(raw);
9267 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9268 return rewriteGitHubImageUrls(sanitised);
9269 }
9270 } catch (_) {
9271 /* fall through */
9272 }
9273 return escapeHtml(md || '');
9274 }
9275
9276 /** Canister stores checklist as JSON text; Node may return an array. */
9277 function parseProposalEvaluationChecklist(raw) {
9278 if (Array.isArray(raw)) return raw;
9279 if (raw == null || raw === '') return [];
9280 const s = String(raw).trim();
9281 if (!s) return [];
9282 try {
9283 const j = JSON.parse(s);
9284 return Array.isArray(j) ? j : [];
9285 } catch (_) {
9286 return [];
9287 }
9288 }
9289
9290 /**
9291 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
9292 */
9293 function buildProposalEvaluationRecordHtml(p, rubricItems) {
9294 const st = p.status;
9295 if (st !== 'approved' && st !== 'discarded') return '';
9296 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9297 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
9298 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
9299 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
9300 const meaningfulStatus = es && es !== 'none';
9301 let waiverText = '';
9302 const w = p.evaluation_waiver;
9303 if (w != null && w !== '') {
9304 try {
9305 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
9306 if (o && typeof o === 'object') {
9307 const r1 = o.reason != null ? String(o.reason).trim() : '';
9308 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
9309 waiverText = r1 || r2;
9310 }
9311 } catch (_) {
9312 /* ignore */
9313 }
9314 }
9315 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
9316 const rubricById = new Map(
9317 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
9318 String(it.id || '').trim(),
9319 String(it.label || it.id || '').trim(),
9320 ]),
9321 );
9322 const rows = checklist
9323 .map((c) => {
9324 const rid = c && c.id != null ? String(c.id) : '';
9325 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
9326 const pass = c && c.passed === true;
9327 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
9328 })
9329 .join('');
9330 return (
9331 '<div class="proposal-eval proposal-eval-readonly">' +
9332 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
9333 '<p class="small">' +
9334 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
9335 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
9336 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
9337 (p.evaluated_at
9338 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
9339 : '') +
9340 '</p>' +
9341 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
9342 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
9343 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
9344 '</div>'
9345 );
9346 }
9347
9348 function openProposal(id) {
9349 resetDetailSectionSourceState();
9350 currentNotePathForCopy = '';
9351 currentOpenNote = null;
9352 el('btn-copy-path').classList.add('hidden');
9353 const bcbProp = el('btn-detail-copy-body');
9354 if (bcbProp) bcbProp.classList.add('hidden');
9355 const panel = el('detail-panel');
9356 panel.classList.add('detail-panel-proposal-wide');
9357 const title = el('detail-title');
9358 const body = el('detail-body');
9359 const actions = el('detail-actions');
9360 body.className = 'detail-body-proposal';
9361 panel.classList.remove('hidden');
9362 body.innerHTML = '<p class="muted">Loading…</p>';
9363 actions.innerHTML = '';
9364 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
9365 api('/api/v1/proposals/' + encodeURIComponent(id))
9366 .then((p) =>
9367 api('/api/v1/notes/' + pathEnc(p.path)).then(
9368 (note) => ({ p, note }),
9369 () => ({ p, note: null }),
9370 ),
9371 )
9372 .then(({ p, note }) => {
9373 title.textContent = p.path + ' (' + p.status + ')';
9374 const pFm = materializeFrontmatter(p.frontmatter);
9375 const currentBlock = note
9376 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
9377 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
9378 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
9379 const mdHtml = renderProposalMarkdownHtml(p.body || '');
9380 const chips = [];
9381 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
9382 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
9383 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
9384 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
9385 });
9386 if (p.external_ref) {
9387 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
9388 }
9389 const role = window.__hubUserRole || 'member';
9390 const isAdmin = role === 'admin';
9391 const isEvaluator = role === 'evaluator';
9392 const canEvaluate = isAdmin || isEvaluator;
9393 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
9394 const canDiscard = isAdmin;
9395 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
9396 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9397 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
9398 function prevEvalPassed(rid) {
9399 const row = prevChecklist.find((c) => c && c.id === rid);
9400 return Boolean(row && row.passed === true);
9401 }
9402 let evalHtml = '';
9403 let waiverHtml = '';
9404 if (canEvaluate && p.status === 'proposed') {
9405 const es = p.evaluation_status || 'none';
9406 let evalIntro = '';
9407 if (es && es !== 'none' && es !== 'pending') {
9408 evalIntro =
9409 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
9410 escapeHtml(es) +
9411 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
9412 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
9413 (p.evaluation_comment
9414 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
9415 : '') +
9416 '</div>';
9417 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
9418 evalIntro =
9419 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
9420 }
9421 const checks = rubricItems.length
9422 ? rubricItems
9423 .map((it) => {
9424 const rid = String(it.id || '').trim();
9425 if (!rid) return '';
9426 const lab = String(it.label || rid);
9427 const ck = prevEvalPassed(rid) ? ' checked' : '';
9428 return (
9429 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
9430 escapeHtml(rid) +
9431 '"' +
9432 ck +
9433 ' /> ' +
9434 escapeHtml(lab) +
9435 '</label>'
9436 );
9437 })
9438 .join('')
9439 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
9440 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
9441 evalHtml =
9442 '<div class="proposal-eval">' +
9443 '<h4 class="proposal-md-heading">Evaluation</h4>' +
9444 evalIntro +
9445 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
9446 '<option value="pass">Pass</option>' +
9447 '<option value="fail">Fail</option>' +
9448 '<option value="needs_changes">Needs changes</option>' +
9449 '</select></label>' +
9450 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
9451 gradeVal +
9452 '" placeholder="e.g. A or 4" /></label>' +
9453 '<div class="proposal-eval-checklist">' +
9454 checks +
9455 '</div>' +
9456 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
9457 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
9458 '</textarea></label>' +
9459 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
9460 '</div>';
9461 }
9462 if (canApprove && p.status === 'proposed') {
9463 waiverHtml =
9464 '<div class="proposal-eval-waiver">' +
9465 '<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>' +
9466 '</div>';
9467 }
9468 let autoFlagHtml = '';
9469 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
9470 autoFlagHtml =
9471 '<p class="small muted">Auto-flagged: ' +
9472 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
9473 '</p>';
9474 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
9475 try {
9476 const ar = JSON.parse(String(p.auto_flag_reasons_json));
9477 if (Array.isArray(ar) && ar.length) {
9478 autoFlagHtml =
9479 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
9480 }
9481 } catch (_) {
9482 /* ignore */
9483 }
9484 }
9485 let hintsHtml = '';
9486 if (p.review_hints) {
9487 hintsHtml =
9488 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
9489 (p.review_hints_model
9490 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
9491 : '') +
9492 (p.review_hints_at
9493 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
9494 : '') +
9495 '<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>' +
9496 '<pre class="proposal-pre">' +
9497 escapeHtml(String(p.review_hints)) +
9498 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
9499 }
9500 let assistantHtml = '';
9501 if (p.assistant_notes) {
9502 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
9503 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
9504 .join('');
9505 assistantHtml =
9506 '<div class="proposal-assistant"><strong>Assistant</strong>' +
9507 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
9508 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
9509 '<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>' +
9510 '<p>' +
9511 escapeHtml(String(p.assistant_notes)) +
9512 '</p>' +
9513 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
9514 '</div>';
9515 }
9516 let suggestedFmHtml = '';
9517 {
9518 let fm = p.assistant_suggested_frontmatter;
9519 if (typeof fm === 'string') {
9520 try {
9521 fm = JSON.parse(fm);
9522 } catch {
9523 fm = null;
9524 }
9525 }
9526 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
9527 const keys = Object.keys(fm).filter((k) => {
9528 const v = fm[k];
9529 return v !== undefined && v !== null && v !== '';
9530 });
9531 if (keys.length) {
9532 const rows = keys
9533 .map((k) => {
9534 const v = fm[k];
9535 let cell;
9536 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
9537 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
9538 else cell = String(v);
9539 return (
9540 '<tr><th scope="row">' +
9541 escapeHtml(k) +
9542 '</th><td>' +
9543 escapeHtml(cell) +
9544 '</td></tr>'
9545 );
9546 })
9547 .join('');
9548 suggestedFmHtml =
9549 '<div class="proposal-suggested-fm">' +
9550 '<strong>Suggested frontmatter</strong> ' +
9551 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
9552 '<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>' +
9553 '<table class="proposal-suggested-fm-table"><tbody>' +
9554 rows +
9555 '</tbody></table></div>';
9556 }
9557 }
9558 }
9559 const openVaultNoteLine = note
9560 ? '<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>'
9561 : '<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>';
9562 body.innerHTML =
9563 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
9564 autoFlagHtml +
9565 '<p class="small muted">Intent: ' +
9566 escapeHtml(p.intent || '—') +
9567 ' · base_state_id: ' +
9568 escapeHtml(p.base_state_id || '—') +
9569 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
9570 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
9571 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
9572 '</p>' +
9573 openVaultNoteLine +
9574 '<div class="proposal-diff-grid">' +
9575 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
9576 escapeHtml(currentBlock) +
9577 '</pre></div>' +
9578 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
9579 escapeHtml(proposedBlock) +
9580 '</pre></div>' +
9581 '</div>' +
9582 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
9583 '<div class="proposal-md">' +
9584 mdHtml +
9585 '</div>' +
9586 evalRecordHtml +
9587 evalHtml +
9588 waiverHtml +
9589 assistantHtml +
9590 suggestedFmHtml +
9591 hintsHtml;
9592 actions.innerHTML = '';
9593 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
9594 if (openNoteBtn && note && p.path) {
9595 openNoteBtn.onclick = () => openNote(String(p.path));
9596 }
9597 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
9598 if (copyFmBtn) {
9599 let fmForCopy = p.assistant_suggested_frontmatter;
9600 if (typeof fmForCopy === 'string') {
9601 try {
9602 fmForCopy = JSON.parse(fmForCopy);
9603 } catch {
9604 fmForCopy = null;
9605 }
9606 }
9607 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
9608 copyFmBtn.onclick = async () => {
9609 try {
9610 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
9611 showToast('Copied suggested frontmatter JSON.');
9612 } catch (err) {
9613 showToast(err.message || 'Copy failed', true);
9614 }
9615 };
9616 }
9617 }
9618 const saveEvalBtn = body.querySelector('#proposal-eval-save');
9619 if (saveEvalBtn) {
9620 saveEvalBtn.onclick = async () => {
9621 const outcomeEl = body.querySelector('#proposal-eval-outcome');
9622 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
9623 const gradeEl = body.querySelector('#proposal-eval-grade');
9624 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
9625 const commentEl = body.querySelector('#proposal-eval-comment');
9626 const comment = commentEl ? String(commentEl.value || '').trim() : '';
9627 const checklist = [];
9628 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
9629 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
9630 });
9631 try {
9632 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
9633 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
9634 method: 'POST',
9635 body: JSON.stringify({
9636 outcome,
9637 grade: grade || undefined,
9638 comment: comment || undefined,
9639 checklist,
9640 }),
9641 });
9642 });
9643 showToast('Evaluation saved.');
9644 openProposal(id);
9645 loadProposals();
9646 } catch (err) {
9647 showToast(err.message || 'Evaluation failed', true);
9648 }
9649 };
9650 }
9651 if (p.status === 'proposed') {
9652 if (canApprove) {
9653 const approveBtn = document.createElement('button');
9654 approveBtn.textContent = 'Approve';
9655 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
9656 actions.append(approveBtn);
9657 }
9658 if (canDiscard) {
9659 const discardBtn = document.createElement('button');
9660 discardBtn.textContent = 'Discard';
9661 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
9662 actions.append(discardBtn);
9663 }
9664 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
9665 const enrichBtn = document.createElement('button');
9666 enrichBtn.type = 'button';
9667 enrichBtn.className = 'btn-secondary';
9668 enrichBtn.textContent = 'Enrich (AI)';
9669 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
9670 actions.append(enrichBtn);
9671 }
9672 if (isEvaluator && !canApprove) {
9673 const hintEv = document.createElement('p');
9674 hintEv.className = 'muted small';
9675 hintEv.textContent =
9676 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
9677 actions.append(hintEv);
9678 } else if (!canEvaluate) {
9679 const hint = document.createElement('p');
9680 hint.className = 'muted small';
9681 hint.textContent =
9682 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
9683 actions.append(hint);
9684 }
9685 }
9686 })
9687 .catch((e) => {
9688 body.className = 'detail-body-proposal';
9689 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
9690 });
9691 }
9692
9693 async function enrichProposal(id, panel, btn) {
9694 try {
9695 await withButtonBusy(btn, 'Enriching…', async () => {
9696 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
9697 });
9698 showToast('Proposal enriched.');
9699 openProposal(id);
9700 loadProposals();
9701 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
9702 // is visible instead of the browser staying at whatever scroll position it was at.
9703 const scrollHost = el('detail-body');
9704 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
9705 // Also highlight the matching row in the Suggested/Activity list so the user can see which
9706 // proposal was enriched.
9707 requestAnimationFrame(() => {
9708 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
9709 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
9710 });
9711 } catch (e) {
9712 showToast(e.message || 'Enrich failed', true);
9713 }
9714 }
9715
9716 async function approveProposal(id, panel, btn) {
9717 try {
9718 const db = el('detail-body');
9719 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
9720 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
9721 const approveBody = {};
9722 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
9723 let approveOut = null;
9724 await withButtonBusy(btn, 'Approving…', async () => {
9725 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
9726 method: 'POST',
9727 body: JSON.stringify(approveBody),
9728 });
9729 });
9730 if (approveOut && approveOut.approval_log_written === false) {
9731 showToast(
9732 approveOut.approval_log_error
9733 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
9734 : 'Approved, but approval log was not written. Check server logs and re-index.',
9735 true,
9736 );
9737 }
9738 hideDetailPanelChrome();
9739 hubMarkSemanticIndexStale();
9740 loadProposals();
9741 loadNotes();
9742 loadActivity();
9743 } catch (e) {
9744 const msg = e.message || String(e);
9745 showToast('Approve failed: ' + msg, true);
9746 }
9747 }
9748
9749 async function discardProposal(id, panel, btn) {
9750 try {
9751 await withButtonBusy(btn, 'Discarding…', async () => {
9752 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
9753 });
9754 hideDetailPanelChrome();
9755 loadProposals();
9756 loadActivity();
9757 } catch (e) {
9758 const msg = e.message || String(e);
9759 showToast('Discard failed: ' + msg, true);
9760 }
9761 }
9762
9763 el('detail-close').onclick = () => closeDetailPanel();
9764 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
9765 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
9766 (function wireDetailPanelFooterClose() {
9767 const panel = el('detail-panel');
9768 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
9769 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
9770 })();
9771
9772 // Resizable detail panel — drag the left edge to widen/narrow.
9773 (function initDetailPanelResize() {
9774 const panel = el('detail-panel');
9775 if (!panel) return;
9776 const handle = document.createElement('div');
9777 handle.className = 'detail-resize-handle';
9778 handle.title = 'Drag to resize panel';
9779 panel.prepend(handle);
9780 const MIN_W = 280;
9781 const MAX_W = Math.round(window.innerWidth * 0.92);
9782 let startX = 0, startW = 0, dragging = false;
9783 const onMove = (e) => {
9784 if (!dragging) return;
9785 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
9786 const delta = startX - clientX;
9787 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
9788 panel.style.width = newW + 'px';
9789 };
9790 const onUp = () => {
9791 if (!dragging) return;
9792 dragging = false;
9793 handle.classList.remove('dragging');
9794 document.removeEventListener('mousemove', onMove);
9795 document.removeEventListener('mouseup', onUp);
9796 document.removeEventListener('touchmove', onMove);
9797 document.removeEventListener('touchend', onUp);
9798 document.body.style.userSelect = '';
9799 };
9800 handle.addEventListener('mousedown', (e) => {
9801 e.preventDefault();
9802 dragging = true;
9803 startX = e.clientX;
9804 startW = panel.offsetWidth;
9805 handle.classList.add('dragging');
9806 document.body.style.userSelect = 'none';
9807 document.addEventListener('mousemove', onMove);
9808 document.addEventListener('mouseup', onUp);
9809 });
9810 handle.addEventListener('touchstart', (e) => {
9811 dragging = true;
9812 startX = e.touches[0].clientX;
9813 startW = panel.offsetWidth;
9814 handle.classList.add('dragging');
9815 document.addEventListener('touchmove', onMove, { passive: true });
9816 document.addEventListener('touchend', onUp);
9817 });
9818 })();
9819
9820 document.addEventListener('keydown', (e) => {
9821 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
9822 if (e.key === 'Escape') {
9823 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
9824 closeDetailPanel();
9825 e.preventDefault();
9826 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
9827 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
9828 closeHowToUse();
9829 e.preventDefault();
9830 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
9831 closeIntegGuideModal();
9832 e.preventDefault();
9833 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
9834 closeSettings();
9835 e.preventDefault();
9836 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
9837 closeProjectsHelpModal();
9838 e.preventDefault();
9839 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
9840 closeOnboardingWizardResume();
9841 e.preventDefault();
9842 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
9843 closeImportModal();
9844 e.preventDefault();
9845 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
9846 closeFullCreateSimilarModal();
9847 e.preventDefault();
9848 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
9849 closeCreateProposalModal();
9850 e.preventDefault();
9851 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
9852 closeCreateModal();
9853 e.preventDefault();
9854 } else if (el('search-key-help')?.open) {
9855 el('search-key-help').open = false;
9856 e.preventDefault();
9857 }
9858 return;
9859 }
9860 if (inInput && e.key !== 'Escape') return;
9861 if (e.key === '/') {
9862 searchQuery.focus();
9863 e.preventDefault();
9864 return;
9865 }
9866 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
9867 // run semantic search instead of opening the selected row (avoids "second search does nothing").
9868 if (e.key === 'Enter') {
9869 const q = (searchQuery.value || '').trim();
9870 if (q) {
9871 e.preventDefault();
9872 void runVaultSearch();
9873 return;
9874 }
9875 }
9876 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
9877 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
9878 const items = notesList.querySelectorAll('.list-item');
9879 if (notesTabActive && listViewVisible && items.length > 0) {
9880 if (e.key === 'j' || e.key === 'J') {
9881 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
9882 updateListSelection();
9883 e.preventDefault();
9884 } else if (e.key === 'k' || e.key === 'K') {
9885 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
9886 updateListSelection();
9887 e.preventDefault();
9888 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
9889 const node = items[listSelectedIndex];
9890 if (node.dataset.path) openNote(node.dataset.path);
9891 else if (node.dataset.id) openProposal(node.dataset.id);
9892 e.preventDefault();
9893 }
9894 }
9895 });
9896
9897 document.addEventListener('click', (e) => {
9898 const keyHelp = el('search-key-help');
9899 if (!keyHelp || !keyHelp.open) return;
9900 if (keyHelp.contains(e.target)) return;
9901 keyHelp.open = false;
9902 });
9903
9904 document.querySelectorAll('.tab').forEach((tab) => {
9905 tab.onclick = () => {
9906 switchHubMainTab(tab.dataset.tab);
9907 };
9908 });
9909 if (btnHeaderSuggested) {
9910 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
9911 }
9912
9913 function escapeHtml(s) {
9914 const div = document.createElement('div');
9915 div.textContent = s == null ? '' : String(s);
9916 return div.innerHTML;
9917 }
9918
9919 // ── Consolidation UI (Stream 2) ───────────────────────────────
9920
9921 function consolModeFromSettings(s) {
9922 if (!s || !s.daemon) return 'off';
9923 if (s.daemon.enabled) return 'daemon';
9924 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
9925 return 'off';
9926 }
9927
9928 function populateConsolSettingsForm(s) {
9929 if (!s || !s.daemon) return;
9930 const d = s.daemon;
9931 const mode = consolModeFromSettings(s);
9932 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
9933 applyConsolModeVisibility(mode);
9934 const iv = el('consol-interval');
9935 if (iv) iv.value = d.interval_minutes ?? 120;
9936 const idle = el('consol-idle-only');
9937 if (idle) idle.checked = d.idle_only !== false;
9938 const idleTh = el('consol-idle-threshold');
9939 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
9940 const ros = el('consol-run-on-start');
9941 if (ros) ros.checked = Boolean(d.run_on_start);
9942 const pc = el('pass-consolidate');
9943 if (pc) pc.checked = d.passes?.consolidate !== false;
9944 const pv = el('pass-verify');
9945 if (pv) pv.checked = d.passes?.verify !== false;
9946 const pd = el('pass-discover');
9947 if (pd) pd.checked = Boolean(d.passes?.discover);
9948 const lp = el('consol-llm-provider');
9949 if (lp) lp.value = d.llm?.provider || '';
9950 const lm = el('consol-llm-model');
9951 if (lm) lm.value = d.llm?.model || '';
9952 const lb = el('consol-llm-base-url');
9953 if (lb) lb.value = d.llm?.base_url || '';
9954 const lbh = el('consol-lookback-hours');
9955 if (lbh) lbh.value = d.lookback_hours ?? 24;
9956 const me = el('consol-max-events');
9957 if (me) me.value = d.max_events_per_pass ?? 200;
9958 const mt = el('consol-max-topics');
9959 if (mt) mt.value = d.max_topics_per_pass ?? 10;
9960 const lmt = el('consol-llm-max-tokens');
9961 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
9962 const cc = el('consol-cost-cap');
9963 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
9964 const chi = el('consol-hosted-interval');
9965 if (chi && d.interval_minutes != null) {
9966 const v = String(d.interval_minutes);
9967 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
9968 chi.value = allowed.includes(v) ? v : '120';
9969 }
9970 }
9971
9972 function buildConsolSettingsPayload() {
9973 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
9974 const mode = modeRadio ? modeRadio.value : 'off';
9975 const hostedSel = el('consol-hosted-interval');
9976 const intervalRaw =
9977 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
9978 const llm = {
9979 provider: el('consol-llm-provider')?.value || '',
9980 model: el('consol-llm-model')?.value || '',
9981 base_url: el('consol-llm-base-url')?.value || '',
9982 };
9983 if (mode === 'daemon') {
9984 llm.max_tokens = Math.max(
9985 64,
9986 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
9987 );
9988 }
9989 const payload = {
9990 mode,
9991 enabled: mode === 'daemon',
9992 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
9993 idle_only: Boolean(el('consol-idle-only')?.checked),
9994 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
9995 run_on_start: Boolean(el('consol-run-on-start')?.checked),
9996 passes: {
9997 consolidate: Boolean(el('pass-consolidate')?.checked),
9998 verify: Boolean(el('pass-verify')?.checked),
9999 discover: Boolean(el('pass-discover')?.checked),
10000 },
10001 llm,
10002 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
10003 };
10004 if (mode === 'daemon') {
10005 payload.lookback_hours = Math.max(
10006 1,
10007 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
10008 );
10009 payload.max_events_per_pass = Math.max(
10010 1,
10011 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
10012 );
10013 payload.max_topics_per_pass = Math.max(
10014 1,
10015 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
10016 );
10017 }
10018 return payload;
10019 }
10020
10021 function applyConsolModeVisibility(mode) {
10022 const daemonSection = el('consol-daemon-settings');
10023 const hostedSection = el('consol-hosted-settings');
10024 const llmSection = el('consol-llm-settings');
10025 const costGuard = el('consol-cost-guard');
10026 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
10027 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
10028 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
10029 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
10030 }
10031
10032 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
10033 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
10034 });
10035
10036 let lastChatKeyAvailable = {};
10037
10038 function chatProviderKeyHintText(provider, keyAvail) {
10039 const ka = keyAvail || {};
10040 switch (provider) {
10041 case '':
10042 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
10043 case 'ollama':
10044 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
10045 case 'openrouter':
10046 return ka.openrouter
10047 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
10048 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
10049 case 'openai':
10050 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
10051 case 'anthropic':
10052 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
10053 case 'deepinfra':
10054 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
10055 default:
10056 return '';
10057 }
10058 }
10059
10060 function applyChatProviderSettings(s) {
10061 const chat = (s && s.chat) || {};
10062 const sel = el('chat-provider-select');
10063 const keyHint = el('chat-provider-key-hint');
10064 const envHint = el('chat-provider-env-hint');
10065 const adminHint = el('chat-provider-admin-hint');
10066 const saveBtn = el('btn-chat-provider-save');
10067 const msg = el('chat-provider-msg');
10068 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10069 if (!sel) return;
10070 lastChatKeyAvailable = chat.key_available || {};
10071 const isAdmin = String(s && s.role) === 'admin';
10072 const envLocked = Boolean(chat.env_locked);
10073 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
10074 sel.disabled = envLocked || !isAdmin;
10075 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
10076 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
10077 if (envHint) {
10078 if (envLocked) {
10079 envHint.textContent =
10080 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
10081 envHint.classList.remove('hidden');
10082 } else {
10083 envHint.classList.add('hidden');
10084 }
10085 }
10086 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10087
10088 if (!sel.dataset.knowtationBound) {
10089 sel.dataset.knowtationBound = '1';
10090 sel.addEventListener('change', () => {
10091 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10092 });
10093 }
10094 const btn = el('btn-chat-provider-save');
10095 if (btn && !btn.dataset.knowtationBound) {
10096 btn.dataset.knowtationBound = '1';
10097 btn.addEventListener('click', async () => {
10098 const m = el('chat-provider-msg');
10099 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
10100 try {
10101 const res = await api('/api/v1/settings/chat', {
10102 method: 'POST',
10103 body: JSON.stringify({ provider: sel.value }),
10104 });
10105 if (res && res.chat) sel.value = res.chat.provider || '';
10106 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
10107 } catch (e) {
10108 if (m) {
10109 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
10110 m.className = 'settings-msg err';
10111 }
10112 }
10113 });
10114 }
10115 }
10116
10117 async function loadConsolidationSettings() {
10118 const msg = el('consol-save-status');
10119 if (msg) msg.textContent = '';
10120 try {
10121 const s = await api('/api/v1/settings');
10122 populateConsolSettingsForm(s);
10123 } catch (e) {
10124 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
10125 }
10126 }
10127
10128 const btnConsolSave = el('btn-consol-save');
10129 if (btnConsolSave) {
10130 btnConsolSave.addEventListener('click', async () => {
10131 const msg = el('consol-save-status');
10132 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10133 const payload = buildConsolSettingsPayload();
10134 if (payload.enabled && payload.interval_minutes < 30) {
10135 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
10136 return;
10137 }
10138 setButtonBusy(btnConsolSave, true, 'Saving…');
10139 try {
10140 await api('/api/v1/settings/consolidation', {
10141 method: 'POST',
10142 body: JSON.stringify(payload),
10143 });
10144 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
10145 } catch (e) {
10146 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
10147 }
10148 setButtonBusy(btnConsolSave, false);
10149 });
10150 }
10151
10152 const linkConsolHelp = el('link-consol-help');
10153 if (linkConsolHelp) {
10154 linkConsolHelp.addEventListener('click', (e) => {
10155 e.preventDefault();
10156 closeSettings();
10157 openHowToUse('consolidation');
10158 });
10159 }
10160
10161 // ── Consolidation Dashboard Card ──────────────────────────────
10162
10163 function formatCostMeter(costUsd, capUsd) {
10164 const cost = Math.max(0, Number(costUsd) || 0);
10165 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
10166 const display = '$' + cost.toFixed(3) + ' today';
10167 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
10168 const pct = Math.min(100, (cost / cap) * 100);
10169 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
10170 }
10171
10172 function renderConsolidationHistory(events, container) {
10173 if (!container) return;
10174 if (!events || events.length === 0) {
10175 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
10176 return;
10177 }
10178 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>';
10179 events.forEach((ev) => {
10180 const ts = ev.ts || ev.timestamp || ev.created_at;
10181 const date = ts ? new Date(ts).toLocaleString() : '—';
10182 const rawTopics = ev.data?.topics_count;
10183 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
10184 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
10185 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
10186 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
10187 });
10188 html += '</tbody></table>';
10189 container.innerHTML = html;
10190 }
10191
10192 async function refreshConsolidationCard() {
10193 const card = el('consolidation-card');
10194 const badge = el('consol-status-badge');
10195 const lastPass = el('consol-last-pass');
10196 const nextPass = el('consol-next-pass');
10197 const quotaMeter = el('consol-quota-meter');
10198 const quotaLabel = el('consol-quota-label');
10199 const quotaFill = el('consol-quota-fill');
10200 const btnNow = el('btn-consol-now');
10201 if (!card) return;
10202
10203 try {
10204 const s = await api('/api/v1/settings');
10205 const mode = consolModeFromSettings(s);
10206 if (mode === 'off') {
10207 card.style.display = 'none';
10208 return;
10209 }
10210 card.style.display = '';
10211
10212 if (mode === 'hosted') {
10213 try {
10214 const st = await api('/api/v1/memory/consolidate/status');
10215 if (badge) {
10216 badge.textContent = '● Active (hosted)';
10217 badge.className = 'consol-badge consol-badge-success';
10218 }
10219 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
10220 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
10221
10222 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
10223 const passUsed = st.pass_count_month ?? 0;
10224 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10225 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10226 if (quotaMeter) {
10227 if (passLimit === null) {
10228 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
10229 if (quotaFill) quotaFill.style.width = '0%';
10230 } else if (passLimit > 0) {
10231 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
10232 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
10233 if (quotaFill) quotaFill.style.width = pct + '%';
10234 }
10235 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
10236 }
10237
10238 // Disable "Consolidate Now" during cooldown; show time remaining.
10239 const cooldown = st.cooldown_minutes ?? 0;
10240 if (btnNow && cooldown > 0) {
10241 btnNow.disabled = true;
10242 btnNow.textContent = 'Available in ' + cooldown + ' min';
10243 } else if (btnNow) {
10244 btnNow.disabled = false;
10245 btnNow.textContent = 'Consolidate Now';
10246 }
10247 } catch (_) {
10248 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
10249 }
10250 } else {
10251 if (badge) {
10252 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
10253 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
10254 }
10255 if (lastPass) lastPass.textContent = 'Last pass: —';
10256 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
10257 if (quotaMeter) quotaMeter.style.display = 'none';
10258 }
10259 } catch (_) {
10260 card.style.display = 'none';
10261 }
10262 }
10263
10264 const btnConsolNow = el('btn-consol-now');
10265 if (btnConsolNow) {
10266 btnConsolNow.addEventListener('click', async () => {
10267 setButtonBusy(btnConsolNow, true, 'Previewing…');
10268 try {
10269 const preview = await api('/api/v1/memory/consolidate', {
10270 method: 'POST',
10271 body: JSON.stringify({ dry_run: true }),
10272 });
10273 setButtonBusy(btnConsolNow, false);
10274 const topicsRaw = preview.topics;
10275 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
10276 const events = preview.total_events ?? 0;
10277 // Fetch current quota to show remaining passes in the preview dialog.
10278 let quotaLine = '';
10279 try {
10280 const st = await api('/api/v1/memory/consolidate/status');
10281 const passUsed = st.pass_count_month ?? 0;
10282 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10283 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10284 if (passLimit === null) {
10285 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
10286 } else if (passLimit > 0) {
10287 const remaining = Math.max(0, passLimit - passUsed);
10288 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
10289 }
10290 } catch (_) {}
10291 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
10292 if (!ok) return;
10293 setButtonBusy(btnConsolNow, true, 'Consolidating…');
10294 await api('/api/v1/memory/consolidate', {
10295 method: 'POST',
10296 body: JSON.stringify({ dry_run: false }),
10297 });
10298 if (typeof showToast === 'function') showToast('Consolidation complete.');
10299 refreshConsolidationCard();
10300 } catch (e) {
10301 const msg = e?.message || 'Consolidation failed';
10302 if (typeof showToast === 'function') showToast(msg, true);
10303 // Re-check cooldown after a rate-limit response so the button state updates.
10304 refreshConsolidationCard();
10305 }
10306 setButtonBusy(btnConsolNow, false);
10307 });
10308 }
10309
10310 const btnConsolHistory = el('btn-consol-history');
10311 if (btnConsolHistory) {
10312 btnConsolHistory.addEventListener('click', async () => {
10313 setButtonBusy(btnConsolHistory, true, 'Loading…');
10314 try {
10315 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
10316 const events = res.events || res.history || [];
10317 setButtonBusy(btnConsolHistory, false);
10318 const modal = document.createElement('div');
10319 modal.className = 'modal';
10320 modal.setAttribute('aria-modal', 'true');
10321 modal.innerHTML =
10322 '<div class="modal-backdrop"></div>' +
10323 '<div class="modal-card consol-history-modal">' +
10324 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
10325 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
10326 document.body.appendChild(modal);
10327 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
10328 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
10329 modal.querySelector('.modal-close').onclick = () => modal.remove();
10330 } catch (e) {
10331 setButtonBusy(btnConsolHistory, false);
10332 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
10333 }
10334 });
10335 }
10336
10337 function openSettingsConsolidationTab() {
10338 openSettings();
10339 document.querySelectorAll('.settings-tab').forEach((t) => {
10340 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
10341 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
10342 });
10343 document.querySelectorAll('.settings-panel').forEach((p) => {
10344 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
10345 });
10346 loadConsolidationSettings();
10347 }
10348
10349 const btnConsolSettings = el('btn-consol-settings');
10350 if (btnConsolSettings) {
10351 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
10352 }
10353
10354 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
10355 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
10356 // Billing consolidation row is populated inline in loadBillingPanel's try block.
10357 // We add to the existing billing flow by hooking the billing API response.
10358
10359 // Refresh consolidation card when dashboard renders
10360 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
10361 })();
File History 1 commit
sha256:c2dbf04d56308f3bbf2d06e6d2eb022b8948b1e827195fe525a44e5e18d5f9c0 feat(auth): Phase B Connect cloud agent (RFC 8628) + Hermes… Human minor 9 days ago