hub.js javascript
10,212 lines 414.2 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 3 hours 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 URL, token, and vault id.';
5197 msg.className = 'settings-msg';
5198 }
5199 refreshIntegApiStatus();
5200 setTimeout(() => {
5201 if (msg) msg.textContent = '';
5202 }, 2500);
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 const btnSettingsMuseSave = el('btn-settings-muse-save');
5217 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
5218 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
5219 btnSettingsMuseSave.addEventListener('click', async () => {
5220 const msg = el('settings-muse-msg');
5221 if (msg) {
5222 msg.textContent = '';
5223 msg.className = 'settings-msg';
5224 }
5225 const input = el('settings-muse-url');
5226 const url = input ? String(input.value || '').trim() : '';
5227 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
5228 try {
5229 await api('/api/v1/settings/muse', {
5230 method: 'POST',
5231 body: JSON.stringify({ url }),
5232 });
5233 if (msg) {
5234 msg.textContent = 'Saved.';
5235 msg.className = 'settings-msg ok';
5236 }
5237 const s = await api('/api/v1/settings');
5238 applySettingsPayloadToHubChrome(s);
5239 } catch (e) {
5240 if (msg) {
5241 msg.textContent =
5242 e && e.code === 'ENV_CONFLICT'
5243 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
5244 : (e && e.message) || 'Save failed';
5245 msg.className = 'settings-msg err';
5246 }
5247 }
5248 });
5249 });
5250 }
5251
5252 document.querySelectorAll('.settings-tab').forEach((tab) => {
5253 tab.addEventListener('click', () => {
5254 const id = tab.dataset.settingsTab;
5255 document.querySelectorAll('.settings-tab').forEach((t) => {
5256 t.classList.toggle('active', t.dataset.settingsTab === id);
5257 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
5258 });
5259 document.querySelectorAll('.settings-panel').forEach((p) => {
5260 p.classList.toggle('active', p.id === 'settings-panel-' + id);
5261 });
5262 if (id === 'team') {
5263 loadTeamRolesList();
5264 loadInvitesList();
5265 }
5266 if (id === 'vaults') loadVaultsPanel();
5267 if (id === 'billing') loadBillingPanel();
5268 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
5269 if (id === 'consolidation') loadConsolidationSettings();
5270 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
5271 });
5272 });
5273
5274 function formatTokenCount(n) {
5275 if (n == null || !Number.isFinite(Number(n))) return '—';
5276 return Number(n).toLocaleString();
5277 }
5278
5279 function formatTokenCountShort(n) {
5280 if (n == null || !Number.isFinite(Number(n))) return '—';
5281 const v = Number(n);
5282 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
5283 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
5284 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
5285 return String(v);
5286 }
5287
5288 /**
5289 * Update the token usage progress bar.
5290 * @param {number} used - tokens used this period
5291 * @param {number|null} included - tokens included (null = unlimited)
5292 */
5293 function updateUsageBar(fillId, used, included) {
5294 const fill = el(fillId);
5295 if (!fill) return;
5296 if (included == null) {
5297 fill.style.width = '15%';
5298 fill.className = 'billing-usage-bar-fill';
5299 return;
5300 }
5301 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
5302 fill.style.width = pct + '%';
5303 fill.className =
5304 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
5305 }
5306
5307 const TIER_LABELS = {
5308 free: 'Free',
5309 plus: 'Plus',
5310 growth: 'Growth',
5311 pro: 'Pro',
5312 beta: 'Beta',
5313 starter: 'Plus',
5314 team: 'Team',
5315 };
5316
5317 const TIER_CSS_CLASSES = {
5318 free: 'tier-free',
5319 plus: 'tier-plus',
5320 growth: 'tier-growth',
5321 pro: 'tier-pro',
5322 beta: 'tier-beta',
5323 starter: 'tier-plus',
5324 team: 'tier-pro',
5325 };
5326
5327 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
5328
5329 const TIER_PLAN_DATA = [
5330 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
5331 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
5332 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
5333 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
5334 ];
5335
5336 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
5337 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
5338
5339 /**
5340 * Render the plan comparison grid into #billing-plan-grid.
5341 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
5342 */
5343 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
5344 const grid = el('billing-plan-grid');
5345 if (!grid) return;
5346
5347 const normalized =
5348 currentTier === 'starter' ? 'plus'
5349 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
5350 : currentTier;
5351 const currentRank = TIER_ORDER.indexOf(normalized);
5352
5353 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
5354 const rank = TIER_ORDER.indexOf(tier);
5355 const isCurrent = rank === currentRank;
5356 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
5357
5358 let ctaHtml = '';
5359 if (isCurrent) {
5360 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
5361 } else if (isUpgrade) {
5362 const label = hasSub
5363 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
5364 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
5365 ctaHtml =
5366 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
5367 tier + '">' + label + '</button>';
5368 }
5369
5370 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
5371 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
5372
5373 return (
5374 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
5375 '<div class="billing-plan-card-header">' +
5376 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
5377 '<span class="billing-plan-card-price">' + price + '</span>' +
5378 '</div>' +
5379 '<ul class="billing-plan-card-features">' +
5380 '<li>' + searches + '</li>' +
5381 '<li>' + indexJobs + '</li>' +
5382 '<li>' + notes + '</li>' +
5383 consolLine +
5384 packLine +
5385 '</ul>' +
5386 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
5387 '</div>'
5388 );
5389 });
5390
5391 grid.innerHTML = cards.join('');
5392
5393 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
5394 btn.addEventListener('click', async () => {
5395 const tier = btn.dataset.tier;
5396 setButtonBusy(btn, true, 'Redirecting\u2026');
5397 try {
5398 await redirectToCheckout({ tier });
5399 } catch (e) {
5400 setButtonBusy(btn, false);
5401 const msg = el('billing-panel-msg');
5402 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
5403 }
5404 });
5405 });
5406 }
5407
5408 /**
5409 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
5410 * @param {{ price_id?: string, tier?: string }} opts
5411 */
5412 async function redirectToCheckout(opts) {
5413 const resp = await api('/api/v1/billing/checkout', {
5414 method: 'POST',
5415 headers: { 'Content-Type': 'application/json' },
5416 body: JSON.stringify({
5417 ...opts,
5418 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
5419 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
5420 }),
5421 });
5422 if (resp && resp.url) {
5423 window.location.href = resp.url;
5424 }
5425 }
5426
5427 /**
5428 * Redirect to Stripe Customer Portal.
5429 */
5430 async function redirectToPortal() {
5431 const resp = await api('/api/v1/billing/portal', {
5432 method: 'POST',
5433 headers: { 'Content-Type': 'application/json' },
5434 body: JSON.stringify({
5435 return_url: window.location.origin + window.location.pathname + '?open=billing',
5436 }),
5437 });
5438 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
5439 if (!url) {
5440 throw new Error(
5441 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
5442 );
5443 }
5444 window.location.assign(url);
5445 }
5446
5447 async function loadBillingPanel() {
5448 const msg = el('billing-panel-msg');
5449 const tierEl = el('billing-tier');
5450 const searchesUsedEl = el('billing-searches-used');
5451 const searchesIncEl = el('billing-searches-included');
5452 const indexJobsUsedEl = el('billing-index-jobs-used');
5453 const indexJobsIncEl = el('billing-index-jobs-included');
5454 const packEl = el('billing-pack-balance');
5455 const packRow = el('billing-pack-balance-row');
5456 const periodEl = el('billing-period');
5457 const renewalEl = el('billing-renewal');
5458 const credEl = el('billing-credits-used');
5459 const credRow = el('billing-credits-row');
5460 const polEl = el('billing-indexing-policy');
5461 const noteCap = el('billing-note-cap');
5462 const refreshBtn = el('btn-billing-refresh');
5463 const upgradeBtn = el('btn-billing-upgrade');
5464 const manageBtn = el('btn-billing-manage');
5465 const packSection = el('billing-pack-section');
5466 if (!tierEl || !searchesUsedEl) return;
5467 if (msg) msg.textContent = '';
5468 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
5469
5470 const setDash = () => {
5471 tierEl.textContent = '—';
5472 tierEl.className = 'billing-plan-badge tier-beta';
5473 if (searchesUsedEl) searchesUsedEl.textContent = '—';
5474 if (searchesIncEl) searchesIncEl.textContent = '—';
5475 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
5476 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
5477 if (packEl) packEl.textContent = '0';
5478 if (packRow) packRow.style.display = 'none';
5479 if (periodEl) periodEl.textContent = '—';
5480 if (renewalEl) renewalEl.textContent = '';
5481 if (credEl) credEl.textContent = '—';
5482 if (credRow) credRow.style.display = 'none';
5483 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
5484 if (noteCap) noteCap.textContent = '—';
5485 if (packSection) packSection.style.display = 'none';
5486 if (upgradeBtn) upgradeBtn.style.display = 'none';
5487 if (manageBtn) manageBtn.style.display = 'none';
5488 updateUsageBar('billing-searches-bar-fill', 0, 0);
5489 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
5490 updateUsageBar('billing-consol-bar-fill', 0, 0);
5491 const consolUsedReset = el('billing-consol-used');
5492 const consolIncReset = el('billing-consol-included');
5493 if (consolUsedReset) consolUsedReset.textContent = '—';
5494 if (consolIncReset) consolIncReset.textContent = '—';
5495 renderBillingPlanGrid('beta', false, false);
5496 };
5497
5498 if (!token) {
5499 setDash();
5500 if (msg) msg.textContent = 'Sign in to view billing usage.';
5501 if (refreshBtn) setButtonBusy(refreshBtn, false);
5502 return;
5503 }
5504
5505 try {
5506 const d = await api('/api/v1/billing/summary');
5507 const tier = d.tier != null ? String(d.tier) : 'beta';
5508
5509 // Plan badge
5510 tierEl.textContent = TIER_LABELS[tier] || tier;
5511 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
5512
5513 // Renewal date
5514 if (renewalEl) {
5515 const pe = d.period_end;
5516 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
5517 }
5518
5519 // Plan comparison grid
5520 const hasSub = Boolean(d.has_active_subscription);
5521 const isFreeTier = tier === 'free' || tier === 'beta';
5522 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
5523
5524 // Legacy upgrade button stays hidden (grid handles upgrades now)
5525 if (upgradeBtn) upgradeBtn.style.display = 'none';
5526 // Manage button: visible for active subscribers to reach the Stripe portal
5527 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
5528
5529 // Searches usage bar
5530 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
5531 const searchesInc = d.monthly_searches_included ?? null;
5532 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
5533 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
5534 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
5535
5536 // Index jobs usage bar
5537 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
5538 const indexJobsInc = d.monthly_index_jobs_included ?? null;
5539 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
5540 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
5541 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
5542
5543 // Consolidation jobs usage bar
5544 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
5545 const consolInc = d.monthly_consolidation_jobs_included ?? null;
5546 const consolUsedEl = el('billing-consol-used');
5547 const consolIncEl = el('billing-consol-included');
5548 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
5549 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
5550 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
5551
5552 // Pack balance
5553 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
5554 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
5555 if (packEl) {
5556 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
5557 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
5558 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
5559 let packText = formatTokenCountShort(packBal) +
5560 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
5561 if (packConsolPasses > 0) {
5562 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
5563 }
5564 packEl.textContent = packText;
5565 }
5566 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
5567
5568 // Period
5569 if (periodEl) {
5570 const ps = d.period_start;
5571 const pe = d.period_end;
5572 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
5573 }
5574
5575 // Note cap
5576 if (noteCap) {
5577 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
5578 }
5579
5580 // Legacy credits row (only show if non-zero)
5581 const mu = Number(d.monthly_used_cents) || 0;
5582 const mi = Number(d.monthly_included_effective_cents) || 0;
5583 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
5584 if (credEl && (mu > 0 || mi > 0)) {
5585 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
5586 }
5587
5588 // Token policy
5589 if (polEl) {
5590 const pol = d.indexing_tokens_policy;
5591 if (pol && String(pol).trim()) {
5592 polEl.textContent = String(pol).trim();
5593 polEl.style.display = '';
5594 } else {
5595 polEl.style.display = 'none';
5596 }
5597 }
5598
5599 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
5600 if (packSection) {
5601 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
5602 packSection.style.display = showPacks ? '' : 'none';
5603 }
5604
5605 if (msg) {
5606 msg.textContent = '';
5607 msg.className = 'settings-intro small muted';
5608 }
5609 } catch (e) {
5610 setDash();
5611 const m = e && e.message ? String(e.message) : String(e);
5612 if (msg) {
5613 msg.textContent =
5614 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
5615 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
5616 : m;
5617 msg.className = 'settings-intro small err';
5618 }
5619 }
5620 if (refreshBtn) setButtonBusy(refreshBtn, false);
5621 }
5622
5623 const btnBillingRefresh = el('btn-billing-refresh');
5624 if (btnBillingRefresh) {
5625 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
5626 }
5627
5628 const btnBillingUpgrade = el('btn-billing-upgrade');
5629 if (btnBillingUpgrade) {
5630 btnBillingUpgrade.addEventListener('click', async () => {
5631 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
5632 try {
5633 await redirectToCheckout({ tier: 'plus' });
5634 } catch (e) {
5635 setButtonBusy(btnBillingUpgrade, false);
5636 const packMsg = el('billing-panel-msg');
5637 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
5638 }
5639 });
5640 }
5641
5642 const btnBillingManage = el('btn-billing-manage');
5643 if (btnBillingManage) {
5644 btnBillingManage.addEventListener('click', async () => {
5645 const panelMsg = el('billing-panel-msg');
5646 if (panelMsg) {
5647 panelMsg.textContent = '';
5648 panelMsg.className = 'settings-intro small muted';
5649 }
5650 setButtonBusy(btnBillingManage, true, 'Redirecting…');
5651 try {
5652 await redirectToPortal();
5653 } catch (e) {
5654 setButtonBusy(btnBillingManage, false);
5655 const errText = e?.message || 'Could not open billing portal.';
5656 if (panelMsg) {
5657 panelMsg.textContent = errText;
5658 panelMsg.className = 'settings-intro small err';
5659 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
5660 }
5661 }
5662 });
5663 }
5664
5665 // Token pack purchase buttons
5666 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
5667 btn.addEventListener('click', async () => {
5668 const pack = btn.dataset.pack;
5669 const packMsgEl = el('billing-pack-msg');
5670 setButtonBusy(btn, true, 'Redirecting…');
5671 if (packMsgEl) packMsgEl.textContent = '';
5672 try {
5673 await redirectToCheckout({ pack_size: pack });
5674 } catch (e) {
5675 setButtonBusy(btn, false);
5676 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
5677 }
5678 });
5679 });
5680
5681 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
5682 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
5683 const arr = Array.isArray(vaults) ? vaults : [];
5684 if (arr.length === 0) {
5685 return isHosted
5686 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
5687 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
5688 }
5689 const items = arr
5690 .map((v) => {
5691 if (!v || v.id == null) return '';
5692 const id = escapeHtml(String(v.id).trim());
5693 const lab =
5694 v.label != null && String(v.label).trim()
5695 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
5696 : '';
5697 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
5698 const pathHtml = pathRaw
5699 ? escapeHtml(pathRaw)
5700 : '<span class="muted">—</span>';
5701 return (
5702 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
5703 id +
5704 '</code>' +
5705 lab +
5706 '</div><div class="vaults-summary-path muted small">' +
5707 pathHtml +
5708 '</div></li>'
5709 );
5710 })
5711 .filter(Boolean)
5712 .join('');
5713 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
5714 }
5715
5716 function collectVaultIdsForAccessForm(vaults, settingsRes) {
5717 const set = new Set(['default']);
5718 const allowed =
5719 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
5720 allowed.forEach((id) => {
5721 if (id != null && String(id).trim()) set.add(String(id).trim());
5722 });
5723 (vaults || []).forEach((v) => {
5724 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
5725 });
5726 return Array.from(set).sort((a, b) => {
5727 if (a === 'default') return -1;
5728 if (b === 'default') return 1;
5729 return a.localeCompare(b);
5730 });
5731 }
5732
5733 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
5734 if (!selectEl) return;
5735 const uids = new Set();
5736 (roleIds || []).forEach((id) => {
5737 if (id != null && String(id).trim()) uids.add(String(id).trim());
5738 });
5739 if (currentUserId != null && String(currentUserId).trim()) {
5740 uids.add(String(currentUserId).trim());
5741 }
5742 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
5743 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
5744 sorted.forEach((uid) => {
5745 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
5746 });
5747 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
5748 selectEl.innerHTML = html;
5749 }
5750
5751 function renderAccessVaultCheckboxes(vaultIds) {
5752 const wrap = el('access-form-vault-checkboxes');
5753 if (!wrap) return;
5754 if (!vaultIds.length) {
5755 wrap.innerHTML =
5756 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
5757 return;
5758 }
5759 wrap.innerHTML = vaultIds
5760 .map((id) => {
5761 const idAttr = escapeHtml(id);
5762 return (
5763 '<label><input type="checkbox" name="hub-access-vault" value="' +
5764 idAttr +
5765 '"> <code>' +
5766 idAttr +
5767 '</code></label>'
5768 );
5769 })
5770 .join('');
5771 }
5772
5773 function parseVaultAccessFromTextarea() {
5774 const accessText = el('vault-access-json');
5775 try {
5776 const access = JSON.parse((accessText && accessText.value) || '{}');
5777 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
5778 } catch (_) {
5779 return {};
5780 }
5781 }
5782
5783 function refreshAccessRulesSummary(access) {
5784 const wrap = el('access-rules-summary');
5785 if (!wrap) return;
5786 if (typeof access !== 'object' || access === null) access = {};
5787 const keys = Object.keys(access);
5788 if (keys.length === 0) {
5789 wrap.innerHTML =
5790 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
5791 return;
5792 }
5793 wrap.innerHTML = keys
5794 .sort((a, b) => a.localeCompare(b))
5795 .map((uid) => {
5796 const arr = access[uid];
5797 const vaults =
5798 Array.isArray(arr) && arr.length
5799 ? arr.map((x) => escapeHtml(String(x))).join(', ')
5800 : '<span class="muted">(invalid)</span>';
5801 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
5802 })
5803 .join('');
5804 }
5805
5806 function accessFormToggleOtherInput() {
5807 const sel = el('access-form-user-select');
5808 const wrap = el('access-form-user-other-wrap');
5809 const other = el('access-form-user-other');
5810 if (!sel || !wrap) return;
5811 const show = sel.value === '__other__';
5812 wrap.classList.toggle('hidden', !show);
5813 if (!show && other) other.value = '';
5814 }
5815
5816 function accessFormSyncCheckboxesFromAccessJson() {
5817 const sel = el('access-form-user-select');
5818 const other = el('access-form-user-other');
5819 if (!sel) return;
5820 let uid = '';
5821 if (sel.value === '__other__') {
5822 uid = ((other && other.value) || '').trim();
5823 } else {
5824 uid = (sel.value || '').trim();
5825 }
5826 const access = parseVaultAccessFromTextarea();
5827 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
5828 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
5829 cb.checked = allowed.indexOf(cb.value) !== -1;
5830 });
5831 }
5832
5833 function getAccessFormResolvedUserId() {
5834 const sel = el('access-form-user-select');
5835 const other = el('access-form-user-other');
5836 if (!sel) return '';
5837 if (sel.value === '__other__') return ((other && other.value) || '').trim();
5838 return (sel.value || '').trim();
5839 }
5840
5841 const accessUserSel = el('access-form-user-select');
5842 if (accessUserSel) {
5843 accessUserSel.addEventListener('change', () => {
5844 accessFormToggleOtherInput();
5845 accessFormSyncCheckboxesFromAccessJson();
5846 });
5847 }
5848 const accessUserOther = el('access-form-user-other');
5849 if (accessUserOther) {
5850 accessUserOther.addEventListener('input', () => {
5851 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
5852 accessFormSyncCheckboxesFromAccessJson();
5853 }
5854 });
5855 }
5856 const scopeUserSelInit = el('scope-form-user-select');
5857 if (scopeUserSelInit) {
5858 scopeUserSelInit.addEventListener('change', () => {
5859 const inp = el('scope-form-user-id');
5860 if (scopeUserSelInit.value === '__other__') {
5861 if (inp) inp.focus();
5862 } else if (scopeUserSelInit.value && inp) {
5863 inp.value = scopeUserSelInit.value;
5864 }
5865 });
5866 }
5867
5868 function populateVaultListExistingSelect(vaults) {
5869 const sel = el('vault-list-form-existing');
5870 if (!sel) return;
5871 let html = '<option value="">New vault</option>';
5872 (vaults || []).forEach((v) => {
5873 if (v && v.id != null && String(v.id).trim()) {
5874 const id = String(v.id).trim();
5875 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
5876 }
5877 });
5878 sel.innerHTML = html;
5879 }
5880
5881 function parseVaultsJsonArrayFromTextarea() {
5882 const ta = el('vaults-json');
5883 try {
5884 const arr = JSON.parse((ta && ta.value) || '[]');
5885 return Array.isArray(arr) ? arr : [];
5886 } catch (_) {
5887 return null;
5888 }
5889 }
5890
5891 function fillVaultListFormFromExisting() {
5892 const sel = el('vault-list-form-existing');
5893 const idInp = el('vault-list-form-id');
5894 const pathInp = el('vault-list-form-path');
5895 const labelInp = el('vault-list-form-label');
5896 if (!sel) return;
5897 if (!sel.value) {
5898 if (idInp) {
5899 idInp.value = '';
5900 idInp.readOnly = false;
5901 }
5902 if (pathInp) pathInp.value = '';
5903 if (labelInp) labelInp.value = '';
5904 return;
5905 }
5906 const vaults = parseVaultsJsonArrayFromTextarea();
5907 if (!vaults) return;
5908 const v = vaults.find((x) => x && String(x.id) === sel.value);
5909 if (v) {
5910 if (idInp) {
5911 idInp.value = String(v.id);
5912 idInp.readOnly = true;
5913 }
5914 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
5915 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
5916 }
5917 }
5918
5919 function toggleVaultsInfoPanel(panelId) {
5920 const panel = el(panelId);
5921 const modal = el('modal-settings');
5922 if (!panel || !modal) return;
5923 const wasHidden = panel.classList.contains('hidden');
5924 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
5925 if (wasHidden) panel.classList.remove('hidden');
5926 }
5927
5928 const modalSettingsForVaultsInfo = el('modal-settings');
5929 if (modalSettingsForVaultsInfo) {
5930 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
5931 const infoBtn = e.target.closest('.btn-settings-info');
5932 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
5933 e.stopPropagation();
5934 const tid = infoBtn.getAttribute('data-settings-info-target');
5935 if (tid) toggleVaultsInfoPanel(tid);
5936 return;
5937 }
5938 if (
5939 !e.target.closest('.settings-info-panel') &&
5940 !e.target.closest('.btn-settings-info')
5941 ) {
5942 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
5943 p.classList.add('hidden');
5944 });
5945 }
5946 });
5947 }
5948
5949 const vaultListExistingSel = el('vault-list-form-existing');
5950 if (vaultListExistingSel) {
5951 vaultListExistingSel.addEventListener('change', () => {
5952 fillVaultListFormFromExisting();
5953 const msg = el('vault-list-form-msg');
5954 if (msg) msg.textContent = '';
5955 });
5956 }
5957
5958 const btnVaultListFormApply = el('btn-vault-list-form-apply');
5959 if (btnVaultListFormApply) {
5960 btnVaultListFormApply.onclick = () => {
5961 const msg = el('vault-list-form-msg');
5962 const ta = el('vaults-json');
5963 const idInp = el('vault-list-form-id');
5964 const pathInp = el('vault-list-form-path');
5965 const labelInp = el('vault-list-form-label');
5966 const vaults = parseVaultsJsonArrayFromTextarea();
5967 if (!vaults) {
5968 if (msg) {
5969 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
5970 msg.className = 'settings-msg err';
5971 }
5972 return;
5973 }
5974 const id = ((idInp && idInp.value) || '').trim();
5975 const path = ((pathInp && pathInp.value) || '').trim();
5976 const label = ((labelInp && labelInp.value) || '').trim();
5977 if (!id || !path) {
5978 if (msg) {
5979 msg.textContent = 'Enter vault id and folder path.';
5980 msg.className = 'settings-msg err';
5981 }
5982 return;
5983 }
5984 const entry = { id, path };
5985 if (label) entry.label = label;
5986 const idx = vaults.findIndex((x) => x && String(x.id) === id);
5987 if (idx >= 0) {
5988 vaults[idx] = Object.assign({}, vaults[idx], entry);
5989 } else {
5990 if (idInp && idInp.readOnly) {
5991 if (msg) {
5992 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
5993 msg.className = 'settings-msg err';
5994 }
5995 return;
5996 }
5997 vaults.push(entry);
5998 }
5999 if (ta) ta.value = JSON.stringify(vaults, null, 2);
6000 populateVaultListExistingSelect(vaults);
6001 const sel = el('vault-list-form-existing');
6002 if (sel) sel.value = '';
6003 fillVaultListFormFromExisting();
6004 const lc = el('vaults-list-container');
6005 if (lc && !isHostedHubFromSettings()) {
6006 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6007 }
6008 if (msg) {
6009 msg.textContent = 'Updated. Click Save vault list to persist.';
6010 msg.className = 'settings-msg ok';
6011 }
6012 };
6013 }
6014
6015 async function loadVaultsPanel() {
6016 const listContainer = el('vaults-list-container');
6017 const serverView = el('vaults-server-view');
6018 const vaultsJson = el('vaults-json');
6019 const accessText = el('vault-access-json');
6020 const scopeText = el('scope-json');
6021 const helpHostedBlock = el('vaults-help-hosted-block');
6022 const helpSelfBlock = el('vaults-help-self-block');
6023 const selfHostedEditors = el('vaults-self-hosted-editors');
6024 const yamlOnly = el('vaults-hub-yaml-only');
6025 const hostedCreate = el('vaults-hosted-create');
6026 const workspacePanel = el('vaults-hosted-workspace');
6027 const workspaceInput = el('workspace-owner-input');
6028 const workspaceMsg = el('workspace-save-msg');
6029 if (listContainer) listContainer.textContent = 'Loading…';
6030 if (serverView) serverView.textContent = 'Loading…';
6031 try {
6032 const settingsRes = await api('/api/v1/settings');
6033 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6034 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6035 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6036 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6037 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6038 const ownerFromSettings =
6039 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6040 ? String(settingsRes.workspace_owner_id).trim()
6041 : '';
6042 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
6043 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
6044 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
6045 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
6046 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
6047 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
6048 const hostedCreateMsg = el('vaults-hosted-create-msg');
6049 if (hostedCreateMsg && isHosted) {
6050 hostedCreateMsg.textContent = '';
6051 hostedCreateMsg.className = 'settings-msg';
6052 }
6053 if (workspaceMsg) {
6054 workspaceMsg.textContent = '';
6055 workspaceMsg.className = 'settings-msg';
6056 }
6057
6058 /** @type {{ vaults?: unknown[] }} */
6059 let vRes = { vaults: [] };
6060 try {
6061 vRes = await api('/api/v1/vaults');
6062 } catch (_) {
6063 vRes = { vaults: [] };
6064 }
6065 /** @type {{ access?: Record<string, unknown> }} */
6066 let aRes = { access: {} };
6067 try {
6068 aRes = await api('/api/v1/vault-access');
6069 } catch (_) {
6070 aRes = { access: {} };
6071 }
6072 /** @type {{ scope?: Record<string, unknown> }} */
6073 let sRes = { scope: {} };
6074 try {
6075 sRes = await api('/api/v1/scope');
6076 } catch (_) {
6077 sRes = { scope: {} };
6078 }
6079
6080 if (isHosted && workspaceInput) {
6081 try {
6082 const w = await api('/api/v1/workspace');
6083 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
6084 } catch (e) {
6085 workspaceInput.value = '';
6086 if (workspaceMsg) {
6087 workspaceMsg.textContent =
6088 (e && e.message) ||
6089 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
6090 workspaceMsg.className = 'settings-msg err';
6091 }
6092 }
6093 } else if (workspaceInput && !isHosted) {
6094 workspaceInput.value = '';
6095 }
6096 const vaults = vRes.vaults || [];
6097 if (serverView) {
6098 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
6099 const allowed = settingsRes.allowed_vault_ids;
6100 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
6101 if (isHosted) {
6102 serverView.innerHTML =
6103 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6104 escapeHtml(uid) +
6105 '</code> · <strong>Vaults:</strong> <code>' +
6106 escapeHtml(allowedStr) +
6107 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
6108 } else {
6109 const dataDir =
6110 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
6111 serverView.innerHTML =
6112 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6113 escapeHtml(uid) +
6114 '</code> · <strong>Allowed vaults:</strong> <code>' +
6115 escapeHtml(allowedStr) +
6116 '</code> · <strong>Data:</strong> <code>' +
6117 dataDir +
6118 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
6119 }
6120 }
6121 if (listContainer) {
6122 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
6123 }
6124 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
6125 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
6126 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
6127
6128 const vaultListJsonDetails = el('vault-list-json-details');
6129 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
6130 const vaultAccessDetails = el('vault-access-json-details');
6131 if (vaultAccessDetails) vaultAccessDetails.open = false;
6132 const scopeJsonDetails = el('scope-json-details');
6133 if (scopeJsonDetails) scopeJsonDetails.open = false;
6134
6135 let roleIds = [];
6136 try {
6137 const ro = await api('/api/v1/roles');
6138 roleIds = Object.keys(ro.roles || {});
6139 } catch (_) {
6140 roleIds = [];
6141 }
6142 populateHostedTeamUserSelect(
6143 el('access-form-user-select'),
6144 roleIds,
6145 settingsRes.user_id,
6146 '— Choose a person —',
6147 );
6148 populateHostedTeamUserSelect(
6149 el('scope-form-user-select'),
6150 roleIds,
6151 settingsRes.user_id,
6152 '— Choose or type User ID below —',
6153 );
6154 const asel = el('access-form-user-select');
6155 if (asel) asel.value = '';
6156 const ssel = el('scope-form-user-select');
6157 if (ssel) ssel.value = '';
6158 accessFormToggleOtherInput();
6159 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
6160 renderAccessVaultCheckboxes(vaultIdsForForm);
6161 accessFormSyncCheckboxesFromAccessJson();
6162 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6163
6164 const scopeVaultSelect = el('scope-form-vault-id');
6165 if (scopeVaultSelect) {
6166 scopeVaultSelect.innerHTML =
6167 vaults.length === 0
6168 ? '<option value="default">default</option>'
6169 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
6170 }
6171
6172 if (!isHosted) {
6173 populateVaultListExistingSelect(vaults);
6174 const vSel = el('vault-list-form-existing');
6175 if (vSel) vSel.value = '';
6176 fillVaultListFormFromExisting();
6177 }
6178 } catch (e) {
6179 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
6180 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
6181 }
6182 }
6183
6184 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
6185 function sanitizeNewHostedVaultId(raw) {
6186 const t = String(raw || '').trim();
6187 if (!t) return { error: 'Enter a vault id.' };
6188 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
6189 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
6190 s = s.slice(0, 64);
6191 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
6192 if (s === 'default') {
6193 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
6194 }
6195 return { id: s };
6196 }
6197
6198 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
6199 if (btnHostedVaultCreate) {
6200 btnHostedVaultCreate.onclick = async () => {
6201 const msgEl = el('vaults-hosted-create-msg');
6202 const inp = el('vaults-hosted-new-id');
6203 const setCreateVaultMsg = (text, isErr) => {
6204 if (!msgEl) return;
6205 msgEl.textContent = text;
6206 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6207 };
6208 if (!isHostedHubFromSettings()) {
6209 setCreateVaultMsg('This action is only available on hosted Hub.', true);
6210 return;
6211 }
6212 if (!hubUserCanWriteNotes()) {
6213 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
6214 return;
6215 }
6216 const ws = lastBackupSettingsPayload;
6217 const ownerId =
6218 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
6219 ? String(ws.workspace_owner_id).trim()
6220 : '';
6221 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
6222 if (ownerId && me && me !== ownerId) {
6223 setCreateVaultMsg(
6224 '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.',
6225 true,
6226 );
6227 return;
6228 }
6229 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
6230 if (parsed.error) {
6231 setCreateVaultMsg(parsed.error, true);
6232 return;
6233 }
6234 const { id } = parsed;
6235 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
6236 setCreateVaultMsg('');
6237 try {
6238 const fresh = await api('/api/v1/settings');
6239 const allowed = fresh.allowed_vault_ids || [];
6240 if (Array.isArray(allowed) && allowed.includes(id)) {
6241 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the header to switch to it.', true);
6242 return;
6243 }
6244 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
6245 await api('/api/v1/notes', {
6246 method: 'POST',
6247 headers: { 'X-Vault-Id': id },
6248 body: JSON.stringify({
6249 path,
6250 body:
6251 'This note was created when you added the "' +
6252 id +
6253 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
6254 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
6255 }),
6256 });
6257 hubMarkSemanticIndexStaleForVault(id);
6258 const s = await api('/api/v1/settings');
6259 lastBackupSettingsPayload = s;
6260 if (s.role) window.__hubUserRole = String(s.role);
6261 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6262 applyHostedUiFromSettings(s);
6263 setCurrentVaultId(id);
6264 const sel = el('vault-switcher');
6265 if (sel) sel.value = id;
6266 loadFacets();
6267 loadNotes();
6268 loadProposals();
6269 await loadVaultsPanel();
6270 if (inp) inp.value = '';
6271 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the header to switch.', false);
6272 } catch (e) {
6273 setCreateVaultMsg(e.message || 'Could not create vault', true);
6274 }
6275 });
6276 };
6277 }
6278
6279 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
6280 if (btnSettingsDeleteVault) {
6281 btnSettingsDeleteVault.onclick = async () => {
6282 const msgEl = el('settings-delete-vault-msg');
6283 const setVaultDelMsg = (text, isErr) => {
6284 if (!msgEl) return;
6285 msgEl.textContent = text;
6286 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6287 };
6288 if (!hubUserMayDeleteVault()) {
6289 setVaultDelMsg('You are not allowed to delete vaults.', true);
6290 return;
6291 }
6292 const sel = el('settings-delete-vault-select');
6293 const vaultId = (sel && sel.value) || '';
6294 const vaultIdTrim = String(vaultId).trim();
6295 if (!vaultIdTrim) {
6296 setVaultDelMsg('Choose a vault to delete.', true);
6297 return;
6298 }
6299 if (vaultIdTrim === 'default') {
6300 setVaultDelMsg('The default vault cannot be deleted.', true);
6301 return;
6302 }
6303 const confirmEl = el('settings-delete-vault-confirm');
6304 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
6305 if (confirmVal !== 'DELETE VAULT') {
6306 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
6307 return;
6308 }
6309 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
6310 setVaultDelMsg('', false);
6311 try {
6312 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
6313 method: 'DELETE',
6314 headers: { 'X-Vault-Id': vaultIdTrim },
6315 });
6316 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
6317 if (wasCurrent) {
6318 setCurrentVaultId('default');
6319 const vSel = el('vault-switcher');
6320 if (vSel) vSel.value = 'default';
6321 }
6322 const s = await api('/api/v1/settings');
6323 lastBackupSettingsPayload = s;
6324 if (s.role) window.__hubUserRole = String(s.role);
6325 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
6326 applyHostedUiFromSettings(s);
6327 refreshDeleteProjectPanelVisibility();
6328 loadFacets();
6329 loadNotes();
6330 loadProposals();
6331 await loadVaultsPanel();
6332 if (confirmEl) confirmEl.value = '';
6333 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
6334 } catch (e) {
6335 setVaultDelMsg(e.message || 'Could not delete vault', true);
6336 }
6337 });
6338 };
6339 }
6340
6341 const btnScopeFormApply = el('btn-scope-form-apply');
6342 if (btnScopeFormApply) {
6343 btnScopeFormApply.onclick = () => {
6344 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
6345 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
6346 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
6347 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
6348 const msg = el('scope-form-msg');
6349 if (!userId) {
6350 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
6351 return;
6352 }
6353 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
6354 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
6355 const scopeText = el('scope-json');
6356 let scope = {};
6357 if (scopeText && scopeText.value) {
6358 try {
6359 scope = JSON.parse(scopeText.value);
6360 if (typeof scope !== 'object' || scope === null) scope = {};
6361 } catch (_) { scope = {}; }
6362 }
6363 if (!scope[userId]) scope[userId] = {};
6364 scope[userId][vaultId] = { projects, folders };
6365 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
6366 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
6367 };
6368 }
6369
6370 function isHostedHubFromSettings() {
6371 const s = lastBackupSettingsPayload;
6372 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
6373 }
6374
6375 const BULK_PRESET_EMPTY = '';
6376 const BULK_PRESET_CUSTOM = '__custom__';
6377
6378 function fillBulkPresetSelect(sel, items, includeCustom) {
6379 if (!sel) return;
6380 const preserve = sel.value;
6381 sel.innerHTML = '';
6382 const head = document.createElement('option');
6383 head.value = BULK_PRESET_EMPTY;
6384 head.textContent = '— Select or type below —';
6385 sel.appendChild(head);
6386 for (const item of items) {
6387 if (item == null || item === '') continue;
6388 const o = document.createElement('option');
6389 o.value = item;
6390 o.textContent = item;
6391 sel.appendChild(o);
6392 }
6393 if (includeCustom) {
6394 const c = document.createElement('option');
6395 c.value = BULK_PRESET_CUSTOM;
6396 c.textContent = 'Custom (type below)';
6397 sel.appendChild(c);
6398 }
6399 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
6400 else sel.value = BULK_PRESET_EMPTY;
6401 }
6402
6403 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
6404 if (!selectEl || !inputEl) return;
6405 const p = (inputEl.value || '').trim();
6406 if (!p) {
6407 selectEl.value = BULK_PRESET_EMPTY;
6408 return;
6409 }
6410 let best = BULK_PRESET_CUSTOM;
6411 let bestLen = -1;
6412 for (const opt of selectEl.options) {
6413 const v = opt.value;
6414 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
6415 if (p === v || p.startsWith(v + '/')) {
6416 if (v.length > bestLen) {
6417 best = v;
6418 bestLen = v.length;
6419 }
6420 }
6421 }
6422 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
6423 }
6424
6425 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
6426 if (!selectEl || !inputEl) return;
6427 const p = (inputEl.value || '').trim();
6428 if (!p) {
6429 selectEl.value = BULK_PRESET_EMPTY;
6430 return;
6431 }
6432 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
6433 else selectEl.value = BULK_PRESET_CUSTOM;
6434 }
6435
6436 function wireBulkPathPresetPair(selectEl, inputEl) {
6437 if (!selectEl || !inputEl) return;
6438 selectEl.addEventListener('change', () => {
6439 const v = selectEl.value;
6440 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6441 });
6442 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
6443 }
6444
6445 function wireBulkSlugPresetPair(selectEl, inputEl) {
6446 if (!selectEl || !inputEl) return;
6447 selectEl.addEventListener('change', () => {
6448 const v = selectEl.value;
6449 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
6450 });
6451 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
6452 }
6453
6454 let bulkPresetDropdownsToken = 0;
6455 async function refreshBulkDeletePresetDropdowns() {
6456 if (!token) return;
6457 const pathSelect = el('settings-bulk-path-prefix-preset');
6458 const delProjSelect = el('settings-bulk-delete-project-preset');
6459 const renameFromSelect = el('settings-bulk-rename-from-preset');
6460 const pathInput = el('settings-delete-prefix');
6461 const delProjInput = el('settings-delete-project-slug');
6462 const renameFromInput = el('settings-rename-project-from');
6463 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
6464 const my = ++bulkPresetDropdownsToken;
6465 let diskFolders = [];
6466 let facets = { projects: [], folders: [] };
6467 try {
6468 const [vf, fc] = await Promise.all([
6469 api('/api/v1/vault/folders'),
6470 api('/api/v1/notes/facets'),
6471 ]);
6472 if (my !== bulkPresetDropdownsToken) return;
6473 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
6474 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
6475 } catch (_) {
6476 if (my !== bulkPresetDropdownsToken) return;
6477 }
6478 const pathSet = new Set();
6479 for (const f of diskFolders) {
6480 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6481 }
6482 for (const f of facets.folders || []) {
6483 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
6484 }
6485 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
6486 const pathPrefixes = ['inbox', ...rest];
6487 const projects = [
6488 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
6489 ].sort((a, b) => a.localeCompare(b));
6490
6491 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
6492 fillBulkPresetSelect(delProjSelect, projects, true);
6493 fillBulkPresetSelect(renameFromSelect, projects, true);
6494
6495 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
6496 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
6497 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
6498 }
6499
6500 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
6501 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
6502 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
6503
6504 const btnDeletePrefix = el('btn-settings-delete-prefix');
6505 if (btnDeletePrefix) {
6506 btnDeletePrefix.onclick = async () => {
6507 const msg = el('settings-delete-prefix-msg');
6508 const prefixEl = el('settings-delete-prefix');
6509 const confirmEl = el('settings-delete-confirm');
6510 if (!hubUserCanWriteNotes()) {
6511 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6512 return;
6513 }
6514 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
6515 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6516 if (!raw) {
6517 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
6518 return;
6519 }
6520 if (conf !== 'DELETE') {
6521 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6522 return;
6523 }
6524 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
6525 try {
6526 const out = await api('/api/v1/notes/delete-by-prefix', {
6527 method: 'POST',
6528 headers: { 'Content-Type': 'application/json' },
6529 body: JSON.stringify({ path_prefix: raw }),
6530 });
6531 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6532 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6533 if (confirmEl) confirmEl.value = '';
6534 if (msg) {
6535 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6536 msg.className = 'settings-msg ok';
6537 }
6538 if (typeof showToast === 'function') {
6539 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
6540 }
6541 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6542 loadNotes();
6543 loadFacets();
6544 if (typeof loadProposals === 'function') loadProposals();
6545 void refreshBulkDeletePresetDropdowns();
6546 } catch (e) {
6547 const m = e && e.message ? String(e.message) : String(e);
6548 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6549 }
6550 });
6551 };
6552 }
6553
6554 const btnDeleteByProject = el('btn-settings-delete-by-project');
6555 if (btnDeleteByProject) {
6556 btnDeleteByProject.onclick = async () => {
6557 const msg = el('settings-delete-by-project-msg');
6558 const slugEl = el('settings-delete-project-slug');
6559 const confirmEl = el('settings-delete-project-confirm');
6560 if (!hubUserCanWriteNotes()) {
6561 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
6562 return;
6563 }
6564 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
6565 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6566 if (!slug) {
6567 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
6568 return;
6569 }
6570 if (conf !== 'DELETE') {
6571 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
6572 return;
6573 }
6574 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
6575 try {
6576 const out = await api('/api/v1/notes/delete-by-project', {
6577 method: 'POST',
6578 headers: { 'Content-Type': 'application/json' },
6579 body: JSON.stringify({ project: slug }),
6580 });
6581 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
6582 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
6583 if (confirmEl) confirmEl.value = '';
6584 if (msg) {
6585 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
6586 msg.className = 'settings-msg ok';
6587 }
6588 if (typeof showToast === 'function') {
6589 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
6590 }
6591 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
6592 loadNotes();
6593 loadFacets();
6594 if (typeof loadProposals === 'function') loadProposals();
6595 void refreshBulkDeletePresetDropdowns();
6596 } catch (e) {
6597 const m = e && e.message ? String(e.message) : String(e);
6598 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6599 }
6600 });
6601 };
6602 }
6603
6604 const btnRenameProject = el('btn-settings-rename-project');
6605 if (btnRenameProject) {
6606 btnRenameProject.onclick = async () => {
6607 const msg = el('settings-rename-project-msg');
6608 const fromEl = el('settings-rename-project-from');
6609 const toEl = el('settings-rename-project-to');
6610 const confirmEl = el('settings-rename-project-confirm');
6611 if (!hubUserCanWriteNotes()) {
6612 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
6613 return;
6614 }
6615 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
6616 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
6617 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
6618 if (!from || !to) {
6619 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
6620 return;
6621 }
6622 if (conf !== 'RENAME') {
6623 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
6624 return;
6625 }
6626 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
6627 try {
6628 const out = await api('/api/v1/notes/rename-project', {
6629 method: 'POST',
6630 headers: { 'Content-Type': 'application/json' },
6631 body: JSON.stringify({ from, to }),
6632 });
6633 const n = out && typeof out.updated === 'number' ? out.updated : 0;
6634 if (confirmEl) confirmEl.value = '';
6635 if (msg) {
6636 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
6637 msg.className = 'settings-msg ok';
6638 }
6639 if (typeof showToast === 'function') {
6640 showToast('Renamed project on ' + n + ' note(s).', false);
6641 }
6642 if (n > 0) hubMarkSemanticIndexStale();
6643 loadNotes();
6644 loadFacets();
6645 void refreshBulkDeletePresetDropdowns();
6646 } catch (e) {
6647 const m = e && e.message ? String(e.message) : String(e);
6648 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
6649 }
6650 });
6651 };
6652 }
6653
6654 const btnVaultsSave = el('btn-vaults-save');
6655 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
6656 const msg = el('vaults-save-msg');
6657 if (isHostedHubFromSettings()) {
6658 if (msg) {
6659 msg.textContent =
6660 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
6661 msg.className = 'settings-msg err';
6662 }
6663 return;
6664 }
6665 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
6666 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
6667 try {
6668 const vaults = JSON.parse(raw);
6669 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
6670 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
6671 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6672 try {
6673 const s = await api('/api/v1/settings');
6674 applySettingsPayloadToHubChrome(s);
6675 } catch (_) {}
6676 loadVaultsPanel();
6677 } catch (e) {
6678 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6679 }
6680 });
6681 };
6682 function validateVaultAccess(access) {
6683 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
6684 for (const [uid, arr] of Object.entries(access)) {
6685 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
6686 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
6687 }
6688 return null;
6689 }
6690 function validateScope(scope) {
6691 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
6692 for (const [userId, perVault] of Object.entries(scope)) {
6693 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
6694 for (const [vaultId, entry] of Object.entries(perVault)) {
6695 if (typeof entry !== 'object' || entry === null) continue;
6696 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
6697 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
6698 }
6699 }
6700 return null;
6701 }
6702 const btnVaultAccessSave = el('btn-vault-access-save');
6703 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
6704 const msg = el('vault-access-save-msg');
6705 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
6706 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
6707 try {
6708 const access = JSON.parse(raw);
6709 const err = validateVaultAccess(access);
6710 if (err) throw new Error(err);
6711 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
6712 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6713 try {
6714 const s = await api('/api/v1/settings');
6715 applySettingsPayloadToHubChrome(s);
6716 } catch (_) {}
6717 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6718 } catch (e) {
6719 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6720 }
6721 });
6722 };
6723
6724 const btnAccessFormApply = el('btn-access-form-apply');
6725 if (btnAccessFormApply) {
6726 btnAccessFormApply.onclick = () => {
6727 const msg = el('access-form-msg');
6728 const uid = getAccessFormResolvedUserId();
6729 if (!uid) {
6730 if (msg) {
6731 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
6732 msg.className = 'settings-msg err';
6733 }
6734 return;
6735 }
6736 const checked = Array.from(
6737 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
6738 ).map((c) => c.value);
6739 if (checked.length === 0) {
6740 if (msg) {
6741 msg.textContent = 'Tick at least one vault.';
6742 msg.className = 'settings-msg err';
6743 }
6744 return;
6745 }
6746 const access = parseVaultAccessFromTextarea();
6747 access[uid] = checked;
6748 const ta = el('vault-access-json');
6749 if (ta) ta.value = JSON.stringify(access, null, 2);
6750 refreshAccessRulesSummary(access);
6751 if (msg) {
6752 msg.textContent =
6753 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
6754 msg.className = 'settings-msg ok';
6755 }
6756 };
6757 }
6758
6759 const btnAccessFormRemove = el('btn-access-form-remove-user');
6760 if (btnAccessFormRemove) {
6761 btnAccessFormRemove.onclick = () => {
6762 const msg = el('access-form-msg');
6763 const uid = getAccessFormResolvedUserId();
6764 if (!uid) {
6765 if (msg) {
6766 msg.textContent = 'Choose a person to remove.';
6767 msg.className = 'settings-msg err';
6768 }
6769 return;
6770 }
6771 const access = parseVaultAccessFromTextarea();
6772 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
6773 if (msg) {
6774 msg.textContent = 'No rule for that user.';
6775 msg.className = 'settings-msg err';
6776 }
6777 return;
6778 }
6779 delete access[uid];
6780 const ta = el('vault-access-json');
6781 if (ta) ta.value = JSON.stringify(access, null, 2);
6782 refreshAccessRulesSummary(access);
6783 accessFormSyncCheckboxesFromAccessJson();
6784 if (msg) {
6785 msg.textContent =
6786 'Removed from draft rules only. Click Save vault access below to persist (required).';
6787 msg.className = 'settings-msg ok';
6788 }
6789 };
6790 }
6791
6792 const btnScopeSave = el('btn-scope-save');
6793 if (btnScopeSave) btnScopeSave.onclick = async () => {
6794 const msg = el('scope-save-msg');
6795 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
6796 const raw = (el('scope-json') && el('scope-json').value) || '{}';
6797 try {
6798 const scope = JSON.parse(raw);
6799 const err = validateScope(scope);
6800 if (err) throw new Error(err);
6801 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
6802 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
6803 } catch (e) {
6804 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
6805 }
6806 });
6807 };
6808
6809 const btnWorkspaceUseMe = el('btn-workspace-use-me');
6810 if (btnWorkspaceUseMe) {
6811 btnWorkspaceUseMe.onclick = async () => {
6812 const input = el('workspace-owner-input');
6813 const msg = el('workspace-save-msg');
6814 let uid =
6815 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
6816 ? String(lastBackupSettingsPayload.user_id)
6817 : '';
6818 if (!uid) {
6819 try {
6820 const s = await api('/api/v1/settings');
6821 lastBackupSettingsPayload = s;
6822 uid = s.user_id != null ? String(s.user_id) : '';
6823 } catch (e) {
6824 if (msg) {
6825 msg.textContent = e.message || 'Could not load your User ID.';
6826 msg.className = 'settings-msg err';
6827 }
6828 return;
6829 }
6830 }
6831 if (input) input.value = uid;
6832 if (msg) {
6833 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
6834 msg.className = 'settings-msg ok';
6835 }
6836 };
6837 }
6838
6839 const btnWorkspaceSave = el('btn-workspace-save');
6840 if (btnWorkspaceSave) {
6841 btnWorkspaceSave.onclick = async () => {
6842 const msg = el('workspace-save-msg');
6843 const input = el('workspace-owner-input');
6844 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
6845 try {
6846 const raw = (input && input.value) || '';
6847 const trimmed = raw.trim();
6848 const owner_user_id = trimmed === '' ? null : trimmed;
6849 await api('/api/v1/workspace', {
6850 method: 'POST',
6851 body: JSON.stringify({ owner_user_id }),
6852 });
6853 if (msg) {
6854 msg.textContent = 'Saved.';
6855 msg.className = 'settings-msg ok';
6856 }
6857 } catch (e) {
6858 if (msg) {
6859 msg.textContent = e.message || 'Save failed';
6860 msg.className = 'settings-msg err';
6861 }
6862 }
6863 });
6864 };
6865 }
6866
6867 const btnWorkspaceClear = el('btn-workspace-clear');
6868 if (btnWorkspaceClear) {
6869 btnWorkspaceClear.onclick = async () => {
6870 const msg = el('workspace-save-msg');
6871 const input = el('workspace-owner-input');
6872 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
6873 try {
6874 await api('/api/v1/workspace', {
6875 method: 'POST',
6876 body: JSON.stringify({ owner_user_id: null }),
6877 });
6878 if (input) input.value = '';
6879 if (msg) {
6880 msg.textContent = 'Cleared — each person uses their own cloud space.';
6881 msg.className = 'settings-msg ok';
6882 }
6883 } catch (e) {
6884 if (msg) {
6885 msg.textContent = e.message || 'Clear failed';
6886 msg.className = 'settings-msg err';
6887 }
6888 }
6889 });
6890 };
6891 }
6892
6893 async function loadInvitesList() {
6894 const listEl = el('invites-pending-list');
6895 if (!listEl) return;
6896 listEl.textContent = 'Loading…';
6897 try {
6898 const out = await api('/api/v1/invites');
6899 const invites = out.invites || [];
6900 if (invites.length === 0) {
6901 listEl.textContent = 'No pending invites. Create a link above.';
6902 } else {
6903 listEl.innerHTML = invites.map((inv) => {
6904 const tokenShort = inv.token.slice(0, 12) + '…';
6905 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
6906 return '<div class="team-role-row invite-row">' +
6907 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
6908 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
6909 '</div>';
6910 }).join('');
6911 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
6912 btn.onclick = async () => {
6913 const t = btn.dataset.token;
6914 if (!t) return;
6915 try {
6916 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
6917 loadInvitesList();
6918 } catch (e) {
6919 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
6920 }
6921 };
6922 });
6923 }
6924 } catch (e) {
6925 listEl.textContent = 'Could not load: ' + (e.message || '');
6926 }
6927 }
6928
6929 const btnInviteCreate = el('btn-invite-create');
6930 const inviteLinkBlock = el('invite-link-block');
6931 const inviteLinkUrl = el('invite-link-url');
6932 const inviteCreateMsg = el('invite-create-msg');
6933 if (btnInviteCreate) {
6934 btnInviteCreate.onclick = async () => {
6935 const roleSelect = el('invite-role');
6936 const role = (roleSelect && roleSelect.value) || 'editor';
6937 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
6938 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
6939 try {
6940 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
6941 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
6942 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
6943 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
6944 loadInvitesList();
6945 } catch (e) {
6946 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
6947 }
6948 });
6949 };
6950 }
6951 const btnInviteCopy = el('btn-invite-copy');
6952 if (btnInviteCopy && inviteLinkUrl) {
6953 btnInviteCopy.onclick = () => {
6954 inviteLinkUrl.select();
6955 if (navigator.clipboard && navigator.clipboard.writeText) {
6956 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
6957 if (typeof showToast === 'function') showToast('Link copied.');
6958 }).catch(() => {});
6959 }
6960 };
6961 }
6962
6963 function syncTeamAddEvaluatorMayApproveVisibility() {
6964 const wrap = el('team-add-evaluator-may-approve-wrap');
6965 const sel = el('team-role');
6966 if (!wrap || !sel) return;
6967 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
6968 }
6969 const teamRoleSelect = el('team-role');
6970 if (teamRoleSelect) {
6971 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
6972 syncTeamAddEvaluatorMayApproveVisibility();
6973 }
6974
6975 async function loadTeamRolesList() {
6976 const listEl = el('team-roles-list');
6977 if (!listEl) return;
6978 listEl.textContent = 'Loading…';
6979 try {
6980 const out = await api('/api/v1/roles');
6981 const roles = out.roles || {};
6982 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
6983 const entries = Object.entries(roles);
6984 listEl.innerHTML = '';
6985 if (entries.length === 0) {
6986 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
6987 return;
6988 }
6989 for (const [uid, role] of entries) {
6990 const row = document.createElement('div');
6991 row.className = 'team-role-row team-role-row-flex';
6992 const label = document.createElement('span');
6993 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
6994 row.appendChild(label);
6995 if (role === 'evaluator') {
6996 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
6997 const chk = document.createElement('input');
6998 chk.type = 'checkbox';
6999 chk.title = 'May approve proposals';
7000 chk.checked = Boolean(mayMap[uid]);
7001 chk.addEventListener('change', async () => {
7002 chk.disabled = true;
7003 try {
7004 await api('/api/v1/roles/evaluator-may-approve', {
7005 method: 'POST',
7006 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7007 });
7008 } catch (err) {
7009 chk.checked = !chk.checked;
7010 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7011 } finally {
7012 chk.disabled = false;
7013 }
7014 });
7015 const lab = document.createElement('label');
7016 lab.className = 'team-evaluator-approve-inline';
7017 lab.appendChild(chk);
7018 const sp = document.createElement('span');
7019 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7020 lab.appendChild(sp);
7021 row.appendChild(lab);
7022 }
7023 listEl.appendChild(row);
7024 }
7025 } catch (e) {
7026 listEl.textContent = 'Could not load: ' + (e.message || '');
7027 }
7028 }
7029
7030 const btnTeamUserUseMe = el('btn-team-user-use-me');
7031 if (btnTeamUserUseMe) {
7032 btnTeamUserUseMe.onclick = async () => {
7033 const userIdInput = el('team-user-id');
7034 const msgEl = el('team-save-msg');
7035 let uid =
7036 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7037 ? String(lastBackupSettingsPayload.user_id)
7038 : '';
7039 if (!uid) {
7040 try {
7041 const s = await api('/api/v1/settings');
7042 lastBackupSettingsPayload = s;
7043 uid = s.user_id != null ? String(s.user_id) : '';
7044 } catch (e) {
7045 if (msgEl) {
7046 msgEl.textContent = e.message || 'Could not load your User ID.';
7047 msgEl.className = 'settings-msg err';
7048 }
7049 return;
7050 }
7051 }
7052 if (userIdInput) userIdInput.value = uid;
7053 if (msgEl) {
7054 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
7055 msgEl.className = 'settings-msg';
7056 }
7057 };
7058 }
7059
7060 const btnTeamSave = el('btn-team-save');
7061 if (btnTeamSave) {
7062 btnTeamSave.onclick = async () => {
7063 const userIdInput = el('team-user-id');
7064 const roleSelect = el('team-role');
7065 const msgEl = el('team-save-msg');
7066 const userId = (userIdInput && userIdInput.value || '').trim();
7067 const role = (roleSelect && roleSelect.value) || 'editor';
7068 if (!userId) {
7069 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
7070 return;
7071 }
7072 if (msgEl) msgEl.textContent = '';
7073 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
7074 try {
7075 const body = { user_id: userId, role };
7076 if (role === 'evaluator') {
7077 const cb = el('team-add-evaluator-may-approve');
7078 body.evaluator_may_approve = Boolean(cb && cb.checked);
7079 }
7080 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
7081 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
7082 userIdInput.value = '';
7083 loadTeamRolesList();
7084 } catch (e) {
7085 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
7086 }
7087 });
7088 };
7089 }
7090
7091 const currentAccent = () => {
7092 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
7093 if (inline) return inline;
7094 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
7095 return fromCss || DEFAULT_ACCENT;
7096 };
7097 function accentStringToHex6(str) {
7098 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
7099 const t = str.trim();
7100 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
7101 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
7102 const a = t.slice(1);
7103 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
7104 }
7105 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
7106 if (m) {
7107 return (
7108 '#' +
7109 [1, 2, 3]
7110 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
7111 .join('')
7112 ).toLowerCase();
7113 }
7114 return DEFAULT_ACCENT;
7115 }
7116 function updateAccentCustomHexLabel(hex6) {
7117 const out = el('accent-custom-hex');
7118 if (out && hex6) out.textContent = String(hex6).toUpperCase();
7119 }
7120 function setAccentRuntimeOnly(hex) {
7121 if (!hex) return;
7122 document.documentElement.style.setProperty('--accent', hex);
7123 updateAccentCustomHexLabel(accentStringToHex6(hex));
7124 }
7125 let accentIroPicker = null;
7126 let accentIroSuppressChange = false;
7127 function ensureAccentIroPicker() {
7128 if (accentIroPicker) return accentIroPicker;
7129 const mount = el('accent-iro-root');
7130 const Iro = typeof window !== 'undefined' && window.iro;
7131 if (!mount || !Iro || !Iro.ColorPicker) return null;
7132 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
7133 const br = (brRaw && brRaw.trim()) || '';
7134 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
7135 accentIroPicker = new Iro.ColorPicker(mount, {
7136 width: 280,
7137 color: accentStringToHex6(currentAccent()),
7138 borderWidth: 1,
7139 borderColor,
7140 layout: [
7141 { component: Iro.ui.Box, options: {} },
7142 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
7143 ],
7144 });
7145 accentIroPicker.on('color:change', (color) => {
7146 if (accentIroSuppressChange) return;
7147 setAccentRuntimeOnly(color.hexString);
7148 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
7149 });
7150 accentIroPicker.on('input:end', () => {
7151 if (accentIroSuppressChange) return;
7152 const h = accentIroPicker.color.hexString;
7153 if (h) applyAccent(h);
7154 });
7155 return accentIroPicker;
7156 }
7157 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
7158 function setAccentPickerColor(picker, hexNorm) {
7159 if (!picker || !hexNorm) return;
7160 const col = picker.color;
7161 if (col && typeof col.set === 'function') {
7162 col.set(hexNorm);
7163 return;
7164 }
7165 if (typeof picker.setColor === 'function') {
7166 try {
7167 picker.setColor(hexNorm, { silent: true });
7168 } catch (_) {
7169 picker.setColor(hexNorm);
7170 }
7171 }
7172 }
7173 function paintAccentSwatches() {
7174 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7175 const hex = btn.dataset.accent;
7176 if (hex) btn.style.backgroundColor = hex;
7177 });
7178 }
7179 paintAccentSwatches();
7180 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7181 btn.addEventListener('click', () => {
7182 const hex = btn.dataset.accent;
7183 if (hex) {
7184 applyAccent(hex);
7185 const norm = accentStringToHex6(hex);
7186 document.querySelectorAll('.accent-swatch').forEach((b) => {
7187 const bh = b.dataset.accent;
7188 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7189 });
7190 ensureAccentIroPicker();
7191 if (accentIroPicker) {
7192 accentIroSuppressChange = true;
7193 try {
7194 setAccentPickerColor(accentIroPicker, norm);
7195 } finally {
7196 accentIroSuppressChange = false;
7197 }
7198 }
7199 updateAccentCustomHexLabel(norm);
7200 }
7201 });
7202 });
7203 ensureAccentIroPicker();
7204 function syncAccentUI() {
7205 const norm = accentStringToHex6(currentAccent());
7206 document.querySelectorAll('.accent-swatch').forEach((b) => {
7207 const bh = b.dataset.accent;
7208 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7209 });
7210 ensureAccentIroPicker();
7211 if (accentIroPicker) {
7212 accentIroSuppressChange = true;
7213 try {
7214 setAccentPickerColor(accentIroPicker, norm);
7215 } finally {
7216 accentIroSuppressChange = false;
7217 }
7218 }
7219 updateAccentCustomHexLabel(norm);
7220 }
7221 function currentTheme() {
7222 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
7223 }
7224 function syncThemeUI() {
7225 const theme = currentTheme();
7226 document.querySelectorAll('.theme-btn').forEach((btn) => {
7227 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
7228 });
7229 }
7230 function syncColorPaletteUI() {
7231 const p = currentColorPalette();
7232 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
7233 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
7234 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
7235 });
7236 }
7237 const dashboardThemeGrid = el('dashboard-theme-grid');
7238 if (dashboardThemeGrid) {
7239 dashboardThemeGrid.addEventListener('click', (ev) => {
7240 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
7241 if (!card || !dashboardThemeGrid.contains(card)) return;
7242 const pid = card.dataset.palette;
7243 if (pid == null) return;
7244 applyColorPalette(pid);
7245 syncColorPaletteUI();
7246 });
7247 }
7248 document.querySelectorAll('.theme-btn').forEach((btn) => {
7249 btn.addEventListener('click', () => {
7250 const theme = btn.dataset.theme;
7251 if (theme) {
7252 applyTheme(theme);
7253 syncThemeUI();
7254 }
7255 });
7256 });
7257 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
7258 if (scrollDashColorsBtn) {
7259 scrollDashColorsBtn.addEventListener('click', () => {
7260 const target = el('settings-dashboard-color-theme');
7261 if (target && target.scrollIntoView) {
7262 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7263 }
7264 });
7265 }
7266
7267 el('btn-settings-sync').onclick = async () => {
7268 const syncBtn = el('btn-settings-sync');
7269 const msg = el('settings-sync-msg');
7270 msg.textContent = 'Syncing…';
7271 msg.className = 'settings-msg';
7272 const s = lastBackupSettingsPayload;
7273 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
7274 const hostedPath = isHosted && s.github_connect_available;
7275 let opts = { method: 'POST' };
7276 if (hostedPath) {
7277 const slug =
7278 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
7279 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
7280 normalizeGithubRepoSlug(s.repo);
7281 if (!slug) {
7282 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
7283 msg.className = 'settings-msg err';
7284 return;
7285 }
7286 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
7287 opts.body = JSON.stringify({ repo: slug });
7288 }
7289 setButtonBusy(syncBtn, true, 'Backing up…');
7290 try {
7291 const result = await api('/api/v1/vault/sync', opts);
7292 msg.textContent = result.message || 'Done.';
7293 const initBtnOk = el('btn-vault-git-init');
7294 if (initBtnOk) initBtnOk.classList.add('hidden');
7295 if (hostedPath && s) {
7296 const refreshed = await api('/api/v1/settings');
7297 lastBackupSettingsPayload = refreshed;
7298 const vg = refreshed.vault_git || {};
7299 let gitText = 'Not configured';
7300 if (vg.enabled && vg.has_remote) {
7301 gitText = 'Configured';
7302 if (vg.auto_commit) gitText += ' (auto-commit on)';
7303 if (vg.auto_push) gitText += ', auto-push on';
7304 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7305 el('settings-git-status').textContent = gitText;
7306 const step4 = document.getElementById('setup-step-4');
7307 if (step4) {
7308 const done = !!(vg.enabled && vg.has_remote);
7309 step4.classList.toggle('setup-step-done', done);
7310 const icon = step4.querySelector('.setup-step-icon');
7311 if (icon) icon.textContent = done ? '✓' : '';
7312 }
7313 }
7314 } catch (e) {
7315 msg.textContent = e.message || 'Sync failed';
7316 msg.className = 'settings-msg err';
7317 const initBtn = el('btn-vault-git-init');
7318 if (initBtn) {
7319 const st = lastBackupSettingsPayload;
7320 const hosted =
7321 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
7322 const needInit =
7323 e.code === 'GIT_NOT_INITIALIZED' ||
7324 /not a Git repository/i.test(e.message || '');
7325 initBtn.classList.toggle('hidden', hosted || !needInit);
7326 }
7327 } finally {
7328 setButtonBusy(syncBtn, false);
7329 const st = lastBackupSettingsPayload;
7330 if (syncBtn && st) {
7331 const vg = st.vault_git || {};
7332 const vd = st.vault_path_display || '';
7333 const ih = (vd + '').toLowerCase() === 'canister';
7334 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
7335 }
7336 }
7337 };
7338 const btnVaultGitInit = el('btn-vault-git-init');
7339 if (btnVaultGitInit) {
7340 btnVaultGitInit.onclick = async () => {
7341 const msg = el('settings-sync-msg');
7342 msg.textContent = 'Initializing Git…';
7343 msg.className = 'settings-msg';
7344 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
7345 try {
7346 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
7347 msg.textContent = out.message || 'Git initialized. Try Back up now.';
7348 msg.className = 'settings-msg ok';
7349 btnVaultGitInit.classList.add('hidden');
7350 } catch (e) {
7351 msg.textContent = e.message || 'Git init failed';
7352 msg.className = 'settings-msg err';
7353 }
7354 });
7355 };
7356 }
7357 const saveSetupBtn = el('btn-settings-save');
7358 if (saveSetupBtn) {
7359 saveSetupBtn.onclick = async () => {
7360 const msg = el('settings-save-msg');
7361 if (msg) {
7362 msg.textContent = 'Saving…';
7363 msg.className = 'settings-msg';
7364 }
7365 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
7366 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
7367 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
7368 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
7369 try {
7370 await api('/api/v1/setup', {
7371 method: 'POST',
7372 body: JSON.stringify({
7373 vault_path: vault_path || undefined,
7374 vault_git: { enabled, remote: remote || undefined },
7375 }),
7376 });
7377 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.' : '');
7378 if (msg) {
7379 msg.textContent = successText;
7380 msg.className = 'settings-msg ok';
7381 }
7382 if (typeof showToast === 'function') showToast('Setup saved.');
7383 api('/api/v1/settings').then((s) => {
7384 const vd = s.vault_path_display || '—';
7385 const isHostedNow = (vd + '').toLowerCase() === 'canister';
7386 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
7387 el('settings-vault-display').textContent = vd;
7388 const configureSection = el('settings-configure-backup-section');
7389 const configureHr = el('settings-hr-configure');
7390 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
7391 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
7392 const vg = s.vault_git || {};
7393 let gitText = 'Not configured';
7394 if (vg.enabled && vg.has_remote) {
7395 gitText = 'Configured';
7396 if (vg.auto_commit) gitText += ' (auto-commit on)';
7397 if (vg.auto_push) gitText += ', auto-push on';
7398 } else if (vg.enabled) gitText = 'Enabled but no remote set';
7399 el('settings-git-status').textContent = gitText;
7400 const syncBtn = el('btn-settings-sync');
7401 const isAdmin = s.role === 'admin';
7402 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
7403 if (msg) {
7404 msg.textContent = successText;
7405 msg.className = 'settings-msg ok';
7406 }
7407 }).catch(() => {});
7408 } catch (e) {
7409 const errMsg = e.message || 'Save failed';
7410 if (msg) {
7411 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
7412 ? 'Only admins can save setup. Your role is shown under Status above.'
7413 : errMsg;
7414 msg.className = 'settings-msg err';
7415 }
7416 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
7417 }
7418 });
7419 };
7420 }
7421
7422 function defaultFullPath() {
7423 const sel = el('full-path-folder');
7424 const folder =
7425 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7426 return folder + '/note-' + Date.now() + '.md';
7427 }
7428
7429 let fullPathFolderLoadToken = 0;
7430 async function refreshFullPathFolderSelect() {
7431 const sel = el('full-path-folder');
7432 if (!sel || !token) return;
7433 const my = ++fullPathFolderLoadToken;
7434 let folders = ['inbox'];
7435 try {
7436 const data = await api('/api/v1/vault/folders');
7437 if (my !== fullPathFolderLoadToken) return;
7438 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7439 } catch (_) {
7440 if (my !== fullPathFolderLoadToken) return;
7441 }
7442 lastVaultFoldersForCreate = folders.slice();
7443 const preserve = sel.value;
7444 sel.innerHTML = '';
7445 for (const f of folders) {
7446 const o = document.createElement('option');
7447 o.value = f;
7448 o.textContent = f;
7449 sel.appendChild(o);
7450 }
7451 const custom = document.createElement('option');
7452 custom.value = '__custom__';
7453 custom.textContent = 'Custom (type path below)';
7454 sel.appendChild(custom);
7455 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7456 else sel.value = folders[0] || 'inbox';
7457 refreshFullCreateSubrootSelect();
7458 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
7459 }
7460
7461 let importVaultFolderLoadToken = 0;
7462 async function refreshImportVaultFolderSelect() {
7463 const sel = el('import-vault-folder');
7464 if (!sel || !token) return;
7465 const my = ++importVaultFolderLoadToken;
7466 let folders = ['inbox'];
7467 try {
7468 const data = await api('/api/v1/vault/folders');
7469 if (my !== importVaultFolderLoadToken) return;
7470 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
7471 } catch (_) {
7472 if (my !== importVaultFolderLoadToken) return;
7473 }
7474 lastVaultFoldersForCreate = folders.slice();
7475 const preserve = sel.value;
7476 sel.innerHTML = '';
7477 for (const f of folders) {
7478 const o = document.createElement('option');
7479 o.value = f;
7480 o.textContent = f;
7481 sel.appendChild(o);
7482 }
7483 const custom = document.createElement('option');
7484 custom.value = '__custom__';
7485 custom.textContent = 'Custom (type path below)';
7486 sel.appendChild(custom);
7487 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7488 else sel.value = folders[0] || 'inbox';
7489 refreshImportCreateSubrootSelect();
7490 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
7491 }
7492
7493 function syncFolderSelectToPathInput() {
7494 const pathInput = el('full-path');
7495 const sel = el('full-path-folder');
7496 if (!pathInput || !sel) return;
7497 const p = pathInput.value.trim();
7498 if (!p) return;
7499 let best = '__custom__';
7500 let bestLen = -1;
7501 for (const opt of sel.options) {
7502 const v = opt.value;
7503 if (v === '__custom__') continue;
7504 if (p === v || p.startsWith(v + '/')) {
7505 if (v.length > bestLen) {
7506 best = v;
7507 bestLen = v.length;
7508 }
7509 }
7510 }
7511 sel.value = bestLen >= 0 ? best : '__custom__';
7512 }
7513
7514 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
7515 function syncFullProjectFromPath() {
7516 const pi = el('full-path');
7517 const fp = el('full-project');
7518 if (!pi || !fp) return;
7519 const slug = projectSlugFromProjectsPath(pi.value.trim());
7520 if (slug) {
7521 fp.value = slug;
7522 fp.readOnly = true;
7523 fp.title = 'Derived from vault path projects/' + slug + '/';
7524 } else {
7525 fp.readOnly = false;
7526 fp.removeAttribute('title');
7527 }
7528 updateFullPathProjectTypoHint();
7529 }
7530
7531 function updateFullPathProjectTypoHint() {
7532 const pi = el('full-path');
7533 const hint = el('full-path-project-typo-hint');
7534 const fixBtn = el('btn-full-path-fix-typo');
7535 if (!pi || !hint) return;
7536 const raw = pi.value.trim();
7537 const sug = projectsPathTypoSuggestion(raw);
7538 if (sug) {
7539 hint.textContent =
7540 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
7541 hint.className = 'muted small detail-project-hint warn';
7542 hint.classList.remove('hidden');
7543 if (fixBtn) {
7544 fixBtn.classList.remove('hidden');
7545 fixBtn.onclick = () => {
7546 pi.value = sug;
7547 syncFolderSelectToPathInput();
7548 syncFullCreatePickersFromPath();
7549 syncFullProjectFromPath();
7550 scheduleFullCreateSimilarHint();
7551 };
7552 }
7553 } else {
7554 hint.textContent = '';
7555 hint.className = 'muted small detail-project-hint hidden';
7556 hint.classList.add('hidden');
7557 if (fixBtn) {
7558 fixBtn.classList.add('hidden');
7559 fixBtn.onclick = null;
7560 }
7561 }
7562 }
7563
7564 const fullPathFolderEl = () => el('full-path-folder');
7565 const fullPathInputEl = () => el('full-path');
7566 if (fullPathFolderEl() && fullPathInputEl()) {
7567 fullPathFolderEl().addEventListener('change', () => {
7568 const sel = fullPathFolderEl();
7569 if (!sel || sel.value === '__custom__') return;
7570 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
7571 syncFullCreatePickersFromPath();
7572 syncFullProjectFromPath();
7573 updateFullPathProjectTypoHint();
7574 scheduleFullCreateSimilarHint();
7575 });
7576 fullPathInputEl().addEventListener('input', () => {
7577 syncFolderSelectToPathInput();
7578 syncFullCreatePickersFromPath();
7579 syncFullProjectFromPath();
7580 updateFullPathProjectTypoHint();
7581 scheduleFullCreateSimilarHint();
7582 });
7583 fullPathInputEl().addEventListener('change', () => {
7584 syncFullCreatePickersFromPath();
7585 updateFullCreateSimilarInlineHint();
7586 });
7587 }
7588
7589 const fullCreateProjectSlugEl = el('full-create-project-slug');
7590 const fullCreateProjectSubEl = el('full-create-project-subroot');
7591 if (fullCreateProjectSlugEl) {
7592 fullCreateProjectSlugEl.addEventListener('change', () => {
7593 refreshFullCreateSubrootSelect();
7594 updateFullCreatePathLayoutVisibility();
7595 const v = fullCreateProjectSlugEl.value;
7596 const pi = el('full-path');
7597 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
7598 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
7599 syncFolderSelectToPathInput();
7600 syncFullProjectFromPath();
7601 updateFullPathProjectTypoHint();
7602 scheduleFullCreateSimilarHint();
7603 });
7604 }
7605 if (fullCreateProjectSubEl) {
7606 fullCreateProjectSubEl.addEventListener('change', () => {
7607 composeFullPathFromCreatePickers();
7608 syncFolderSelectToPathInput();
7609 syncFullProjectFromPath();
7610 updateFullPathProjectTypoHint();
7611 scheduleFullCreateSimilarHint();
7612 });
7613 }
7614
7615 const importCreateProjectSlugEl = el('import-create-project-slug');
7616 const importCreateProjectSubEl = el('import-create-project-subroot');
7617 const importVaultFolderEl = el('import-vault-folder');
7618 const importOutputDirEl = el('import-output-dir');
7619 if (importVaultFolderEl) {
7620 importVaultFolderEl.addEventListener('change', () => {
7621 const sel = importVaultFolderEl;
7622 const out = el('import-output-dir');
7623 if (!sel || !out || sel.value === '__custom__') return;
7624 out.value = sel.value;
7625 syncImportPickersFromOutputDir();
7626 });
7627 }
7628 if (importOutputDirEl) {
7629 importOutputDirEl.addEventListener('input', () => {
7630 syncImportFolderSelectToOutputDir();
7631 syncImportPickersFromOutputDir();
7632 });
7633 }
7634 if (importCreateProjectSlugEl) {
7635 importCreateProjectSlugEl.addEventListener('change', () => {
7636 refreshImportCreateSubrootSelect();
7637 updateImportPathLayoutVisibility();
7638 const v = importCreateProjectSlugEl.value;
7639 const out = el('import-output-dir');
7640 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
7641 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
7642 const sel = el('import-vault-folder');
7643 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
7644 }
7645 syncImportFolderSelectToOutputDir();
7646 syncImportPickersFromOutputDir();
7647 });
7648 }
7649 if (importCreateProjectSubEl) {
7650 importCreateProjectSubEl.addEventListener('change', () => {
7651 composeImportOutputDirFromPickers();
7652 syncImportFolderSelectToOutputDir();
7653 syncImportPickersFromOutputDir();
7654 });
7655 }
7656
7657 document.querySelectorAll('.modal-tab').forEach((t) => {
7658 t.onclick = () => {
7659 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
7660 t.classList.add('active');
7661 const tab = t.dataset.createTab;
7662 el('create-quick').classList.toggle('hidden', tab !== 'quick');
7663 el('create-full').classList.toggle('hidden', tab !== 'full');
7664 if (tab === 'full') {
7665 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
7666 void (async () => {
7667 await refreshFullPathFolderSelect();
7668 if (!lastHubFacets) {
7669 try {
7670 lastHubFacets = await fetchFacetsResolved();
7671 } catch (_) {}
7672 }
7673 hydrateFullCreateProjectSlugSelect(lastHubFacets);
7674 const pi = el('full-path');
7675 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
7676 else syncFolderSelectToPathInput();
7677 syncFullCreatePickersFromPath();
7678 syncFullProjectFromPath();
7679 updateFullPathProjectTypoHint();
7680 updateFullCreateSimilarInlineHint();
7681 })();
7682 }
7683 };
7684 });
7685
7686 el('btn-quick-save').onclick = async () => {
7687 const quickBtn = el('btn-quick-save');
7688 const body = el('quick-body').value.trim();
7689 const msg = el('create-msg-quick');
7690 if (!body) {
7691 msg.textContent = 'Enter some text.';
7692 msg.className = 'create-msg err';
7693 return;
7694 }
7695 const projectRaw = el('quick-project').value.trim();
7696 const pslug = normSlug(projectRaw);
7697 const today = ymd(new Date());
7698 const slug = 'hub_' + Date.now();
7699 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
7700 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
7701 await withButtonBusy(quickBtn, 'Saving…', async () => {
7702 try {
7703 await api('/api/v1/notes', {
7704 method: 'POST',
7705 body: stringifyNotePostPayload(path, body, {
7706 source: 'hub',
7707 date: today,
7708 title,
7709 ...(pslug && { project: pslug }),
7710 }),
7711 });
7712 hubMarkSemanticIndexStale();
7713 msg.textContent = 'Saved: ' + path;
7714 msg.className = 'create-msg ok';
7715 el('quick-body').value = '';
7716 loadFacets();
7717 loadNotes();
7718 closeCreateModal();
7719 } catch (e) {
7720 msg.textContent = e.message;
7721 msg.className = 'create-msg err';
7722 }
7723 });
7724 };
7725
7726 async function submitFullCreateNote() {
7727 const fullBtn = el('btn-full-save');
7728 const notePath = el('full-path').value.trim();
7729 const pathProjFull = projectSlugFromProjectsPath(notePath);
7730 const msg = el('create-msg-full');
7731 if (!notePath) {
7732 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
7733 msg.className = 'create-msg err';
7734 return;
7735 }
7736 const pathTypoSug = projectsPathTypoSuggestion(notePath);
7737 if (pathTypoSug) {
7738 msg.textContent =
7739 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
7740 pathTypoSug;
7741 msg.className = 'create-msg err';
7742 return;
7743 }
7744 if (!notePath.endsWith('.md')) {
7745 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
7746 msg.className = 'create-msg err';
7747 return;
7748 }
7749 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
7750 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
7751 const dest = notePath.replace(/\\/g, '/');
7752 if (src === dest) {
7753 msg.textContent =
7754 'When duplicating, pick a different path than the original (same path would overwrite the original).';
7755 msg.className = 'create-msg err';
7756 return;
7757 }
7758 }
7759 const slugFromPath = projectSlugFromProjectsPath(notePath);
7760 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
7761 const similarGuess =
7762 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
7763 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
7764 : null;
7765 if (similarGuess) {
7766 openFullCreateSimilarModal(notePath, similarGuess);
7767 return;
7768 }
7769 fullCreateSimilarOverrideOnce = false;
7770 const title = el('full-title').value.trim();
7771 const body = el('full-body').value;
7772 const project = pathProjFull || el('full-project').value.trim();
7773 const tags = el('full-tags').value.trim();
7774 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
7775 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
7776 const entityRaw = el('full-entity') && el('full-entity').value.trim();
7777 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
7778 const episode = el('full-episode') && el('full-episode').value.trim();
7779 const followsRaw = el('full-follows') && el('full-follows').value.trim();
7780 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
7781 const fm = {
7782 date: dateVal,
7783 ...(title && { title }),
7784 ...(project && { project }),
7785 ...(tags && { tags }),
7786 ...(causalChain && { causal_chain_id: causalChain }),
7787 ...(entity && entity.length && { entity }),
7788 ...(episode && { episode_id: episode }),
7789 ...(follows && { follows }),
7790 };
7791 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
7792 await withButtonBusy(fullBtn, savingLabel, async () => {
7793 try {
7794 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
7795 hubMarkSemanticIndexStale();
7796 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
7797 msg.className = 'create-msg ok';
7798 const dupSrc = pendingDuplicateDeleteSource;
7799 const delChk = el('duplicate-delete-after-save');
7800 const shouldDeleteOriginal =
7801 dupSrc &&
7802 dupSrc.path &&
7803 delChk &&
7804 delChk.checked &&
7805 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
7806 if (shouldDeleteOriginal) {
7807 try {
7808 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
7809 if (typeof showToast === 'function') showToast('Original note deleted');
7810 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
7811 const bcb = el('btn-detail-copy-body');
7812 if (bcb) bcb.classList.add('hidden');
7813 } catch (delErr) {
7814 if (typeof showToast === 'function') {
7815 showToast(
7816 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
7817 true,
7818 );
7819 }
7820 }
7821 }
7822 void refreshFullPathFolderSelect().then(() => {
7823 el('full-path').value = defaultFullPath();
7824 syncFolderSelectToPathInput();
7825 syncFullCreatePickersFromPath();
7826 syncFullProjectFromPath();
7827 updateFullCreateSimilarInlineHint();
7828 });
7829 el('full-title').value = '';
7830 el('full-body').value = '';
7831 el('full-project').value = '';
7832 el('full-tags').value = '';
7833 if (el('full-date')) el('full-date').value = '';
7834 if (el('full-causal-chain')) el('full-causal-chain').value = '';
7835 if (el('full-entity')) el('full-entity').value = '';
7836 if (el('full-episode')) el('full-episode').value = '';
7837 if (el('full-follows')) el('full-follows').value = '';
7838 loadFacets();
7839 loadNotes();
7840 closeCreateModal();
7841 } catch (e) {
7842 msg.textContent = e.message;
7843 msg.className = 'create-msg err';
7844 }
7845 });
7846 }
7847
7848 el('btn-full-save').onclick = () => {
7849 void submitFullCreateNote();
7850 };
7851
7852 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
7853 const modalSimilarClose = el('modal-create-similar-project-close');
7854 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
7855 const btnSimilarKeep = el('btn-modal-create-similar-keep');
7856 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
7857 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
7858 if (btnSimilarUseExisting) {
7859 btnSimilarUseExisting.onclick = () => {
7860 const path = fullCreateSimilarModalPendingPath;
7861 const slug = fullCreateSimilarModalSuggestedSlug;
7862 closeFullCreateSimilarModal();
7863 if (path && slug) {
7864 const pi = el('full-path');
7865 if (pi) {
7866 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
7867 syncFolderSelectToPathInput();
7868 syncFullCreatePickersFromPath();
7869 syncFullProjectFromPath();
7870 updateFullPathProjectTypoHint();
7871 updateFullCreateSimilarInlineHint();
7872 }
7873 }
7874 fullCreateSimilarOverrideOnce = false;
7875 void submitFullCreateNote();
7876 };
7877 }
7878 if (btnSimilarKeep) {
7879 btnSimilarKeep.onclick = () => {
7880 closeFullCreateSimilarModal();
7881 fullCreateSimilarOverrideOnce = true;
7882 void submitFullCreateNote();
7883 };
7884 }
7885
7886 function formatDetailReadBody(body, fm) {
7887 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
7888 const keys = Object.keys(o);
7889 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
7890 if (keys.length === 0 && hubUserCanWriteNotes()) {
7891 text +=
7892 '\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).';
7893 }
7894 return text;
7895 }
7896
7897 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
7898 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
7899
7900 function videoExtToMime(url) {
7901 try {
7902 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
7903 return VIDEO_MIME_MAP[ext] || 'video/mp4';
7904 } catch (_) {
7905 var clean = url.split('?')[0].split('#')[0];
7906 var ext2 = clean.split('.').pop().toLowerCase();
7907 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
7908 }
7909 }
7910
7911 /**
7912 * Ensure standalone video URL lines are surrounded by blank lines in the raw
7913 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
7914 * mode joins adjacent lines (e.g. a video URL followed immediately by image
7915 * markdown) into a single <p>, which prevents the video-URL regex from matching.
7916 */
7917 function isolateVideoUrlLines(md) {
7918 // Match any line whose entire content is a bare https video URL.
7919 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
7920 // and after so marked always puts the URL in its own paragraph.
7921 return md.replace(
7922 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
7923 '\n$1$2\n'
7924 );
7925 }
7926
7927 /**
7928 * Replace bare video URLs (on their own line) with <video> elements.
7929 * Handles two forms that marked produces for a bare URL on its own paragraph:
7930 * 1. GFM autolink: <p><a href="URL">URL</a></p>
7931 * 2. Plain text: <p>URL</p>
7932 * Runs before DOMPurify so the sanitiser validates the output.
7933 */
7934 function expandVideoUrls(html) {
7935 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
7936
7937 // GFM autolink form: <p><a href="URL">...</a></p>
7938 var result = html.replace(
7939 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
7940 function (match, url) {
7941 if (!VIDEO_EXT_PAT.test(url)) return match;
7942 var mime = videoExtToMime(url);
7943 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
7944 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
7945 'Your browser does not support embedded video.</video>';
7946 }
7947 );
7948
7949 // Plain text form: <p>URL</p>
7950 result = result.replace(
7951 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
7952 function (match, url) {
7953 if (!VIDEO_EXT_PAT.test(url)) return match;
7954 var mime = videoExtToMime(url);
7955 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
7956 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
7957 'Your browser does not support embedded video.</video>';
7958 }
7959 );
7960
7961 return result;
7962 }
7963
7964 var SANITIZE_OPTS_NOTE = {
7965 ADD_TAGS: ['details', 'summary', 'video', 'source'],
7966 ADD_ATTR: ['controls', 'preload', 'type'],
7967 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
7968 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
7969 };
7970
7971 /**
7972 * Render markdown text as sanitised HTML.
7973 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
7974 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
7975 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
7976 */
7977 var _imageProxyToken = null;
7978 var _imageProxyTokenExp = 0;
7979
7980 async function getImageProxyToken() {
7981 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
7982 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
7983 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
7984 if (!jwt) return '';
7985 try {
7986 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
7987 headers: { authorization: 'Bearer ' + jwt },
7988 });
7989 if (!res.ok) return '';
7990 var data = await res.json();
7991 _imageProxyToken = data.token || '';
7992 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
7993 return _imageProxyToken;
7994 } catch (_) { return ''; }
7995 }
7996
7997 /**
7998 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
7999 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
8000 * Falls back to no rewrite if no cached image token is available yet.
8001 */
8002 function rewriteGitHubImageUrls(html) {
8003 var tok = _imageProxyToken || '';
8004 if (!tok) {
8005 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8006 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8007 }
8008 if (!tok) return html;
8009 var encodedTok = encodeURIComponent(tok);
8010 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8011 return html.replace(
8012 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8013 function (match, pre, rest) {
8014 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8015 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8016 }
8017 );
8018 }
8019
8020 function renderNoteMarkdownHtml(md) {
8021 try {
8022 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8023 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8024 var withVideo = expandVideoUrls(raw);
8025 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8026 return rewriteGitHubImageUrls(sanitised);
8027 }
8028 } catch (_) { /* fall through */ }
8029 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8030 }
8031
8032 /**
8033 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8034 */
8035 function buildNoteReadHtml(body, fm) {
8036 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8037 const keys = Object.keys(o);
8038 const bodyHtml = renderNoteMarkdownHtml(body || '');
8039 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8040 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8041 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
8042 : '';
8043 return (
8044 bodyHtml +
8045 '<details class="note-meta-block">' +
8046 '<summary>Metadata</summary>' +
8047 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
8048 emptyNote +
8049 '</details>'
8050 );
8051 }
8052
8053 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
8054 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
8055 'absolute_path',
8056 'body',
8057 'body_length',
8058 'byte_offset',
8059 'byte_offsets',
8060 'frontmatter',
8061 'line_range',
8062 'line_ranges',
8063 'mcp_resource_uri',
8064 'provider_payload',
8065 'raw_canister_payload',
8066 'resource_uri',
8067 'section_body',
8068 'section_body_length',
8069 'snippet',
8070 'snippets',
8071 ]);
8072
8073 function normalizeSectionSourcePathForUi(path) {
8074 const value = String(path || '').trim();
8075 if (!value) return '';
8076 if (value.includes('\\') || value.includes('\0')) return '';
8077 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
8078 if (value.split('/').some((part) => part === '..')) return '';
8079 return value;
8080 }
8081
8082 function sectionSourceEndpointForPath(path) {
8083 return '/api/v1/section-source?path=' + encodeURIComponent(path);
8084 }
8085
8086 function sectionSourcePayloadHasForbiddenKeys(value) {
8087 if (!value || typeof value !== 'object') return false;
8088 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
8089 for (const [key, child] of Object.entries(value)) {
8090 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
8091 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
8092 }
8093 return false;
8094 }
8095
8096 function normalizeSectionSourceForRender(data) {
8097 if (!data || typeof data !== 'object' || Array.isArray(data)) {
8098 throw new Error('INVALID_SECTION_SOURCE');
8099 }
8100 if (sectionSourcePayloadHasForbiddenKeys(data)) {
8101 throw new Error('INVALID_SECTION_SOURCE');
8102 }
8103 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
8104 throw new Error('INVALID_SECTION_SOURCE');
8105 }
8106 return {
8107 schema: SECTION_SOURCE_SCHEMA,
8108 path: String(data.path || ''),
8109 title: String(data.title || ''),
8110 truncated: data.truncated === true,
8111 sections: data.sections.map((section) => {
8112 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
8113 const normalized = {
8114 section_id: String(item.section_id || ''),
8115 heading_id: String(item.heading_id || ''),
8116 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
8117 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
8118 heading_text: String(item.heading_text || ''),
8119 child_section_ids: Array.isArray(item.child_section_ids)
8120 ? item.child_section_ids.map((childId) => String(childId))
8121 : [],
8122 body_available: item.body_available === true,
8123 body_returned: item.body_returned === true,
8124 snippet_returned: item.snippet_returned === true,
8125 };
8126 if (normalized.body_returned || normalized.snippet_returned) {
8127 throw new Error('INVALID_SECTION_SOURCE');
8128 }
8129 return normalized;
8130 }),
8131 };
8132 }
8133
8134 function resetDetailSectionSourceState() {
8135 hubSectionSourceSeq += 1;
8136 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
8137 }
8138
8139 function setSectionSourcePanelState(panel, state, message) {
8140 panel.className = 'section-source-panel section-source-panel-' + state;
8141 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
8142 panel.setAttribute('aria-label', 'Body-free section list');
8143 panel.setAttribute('aria-live', 'polite');
8144 panel.replaceChildren();
8145 const text = document.createElement('p');
8146 text.className = 'section-source-state';
8147 text.textContent = message;
8148 panel.appendChild(text);
8149 }
8150
8151 function sectionSourceErrorMessage(error) {
8152 const code = error && error.code ? String(error.code) : '';
8153 const message = error && error.message ? String(error.message) : '';
8154 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
8155 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
8156 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
8157 if (message === 'Unauthorized') return 'Sign in to view sections.';
8158 return 'Sections are unavailable right now.';
8159 }
8160
8161 function appendSectionSourceDebugRow(list, labelText, valueText) {
8162 const label = document.createElement('dt');
8163 label.textContent = labelText;
8164 const value = document.createElement('dd');
8165 value.textContent = valueText;
8166 list.append(label, value);
8167 }
8168
8169 function renderSectionSourceData(panel, source) {
8170 panel.className = 'section-source-panel';
8171 panel.setAttribute('role', 'region');
8172 panel.setAttribute('aria-label', 'Body-free section list');
8173 panel.setAttribute('aria-live', 'polite');
8174 panel.replaceChildren();
8175
8176 const header = document.createElement('div');
8177 header.className = 'section-source-header';
8178 const title = document.createElement('h3');
8179 title.textContent = 'Sections';
8180 const meta = document.createElement('p');
8181 meta.className = 'muted small';
8182 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
8183 header.append(title, meta);
8184 panel.appendChild(header);
8185
8186 if (source.truncated) {
8187 const truncated = document.createElement('p');
8188 truncated.className = 'section-source-state section-source-truncated';
8189 truncated.textContent = 'Section list is capped for display.';
8190 panel.appendChild(truncated);
8191 }
8192
8193 if (source.sections.length === 0) {
8194 const empty = document.createElement('p');
8195 empty.className = 'section-source-state';
8196 empty.textContent = 'No headings are available for this note.';
8197 panel.appendChild(empty);
8198 return;
8199 }
8200
8201 const list = document.createElement('ol');
8202 list.className = 'section-source-list';
8203 for (const section of source.sections) {
8204 const item = document.createElement('li');
8205 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
8206
8207 const heading = document.createElement('p');
8208 heading.className = 'section-source-heading';
8209 const levelBadge = document.createElement('span');
8210 levelBadge.className = 'section-source-level-label';
8211 levelBadge.textContent = 'H' + section.level;
8212 const headingText = document.createElement('span');
8213 headingText.className = 'section-source-heading-text';
8214 headingText.textContent = section.heading_text || '(Untitled section)';
8215 heading.append(levelBadge, headingText);
8216 item.appendChild(heading);
8217
8218 const detail = document.createElement('p');
8219 detail.className = 'section-source-detail muted small';
8220 detail.textContent = 'Heading level: H' + section.level;
8221 item.appendChild(detail);
8222
8223 const pathLine = document.createElement('p');
8224 pathLine.className = 'section-source-path muted small';
8225 pathLine.textContent =
8226 'Heading path: ' +
8227 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
8228 item.appendChild(pathLine);
8229
8230 const childLine = document.createElement('p');
8231 childLine.className = 'section-source-children muted small';
8232 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
8233 item.appendChild(childLine);
8234
8235 const debugDetails = document.createElement('details');
8236 debugDetails.className = 'section-source-debug muted small';
8237 const debugSummary = document.createElement('summary');
8238 debugSummary.textContent = 'IDs';
8239 const debugList = document.createElement('dl');
8240 debugList.className = 'section-source-debug-list';
8241 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
8242 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
8243 appendSectionSourceDebugRow(
8244 debugList,
8245 'Child IDs',
8246 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
8247 );
8248 debugDetails.append(debugSummary, debugList);
8249 item.appendChild(debugDetails);
8250
8251 list.appendChild(item);
8252 }
8253 panel.appendChild(list);
8254 }
8255
8256 async function loadSectionSourceForCurrentNote(actionsEl, button) {
8257 let panel = actionsEl.querySelector('[data-section-source-panel]');
8258 if (!panel) {
8259 panel = document.createElement('div');
8260 panel.dataset.sectionSourcePanel = 'true';
8261 actionsEl.appendChild(panel);
8262 }
8263 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
8264 if (!path) {
8265 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
8266 return;
8267 }
8268 const seq = ++hubSectionSourceSeq;
8269 const openPath = currentOpenNote.path;
8270 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
8271 if (button) {
8272 button.disabled = true;
8273 button.setAttribute('aria-expanded', 'true');
8274 }
8275 try {
8276 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
8277 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8278 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
8279 } catch (error) {
8280 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
8281 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
8282 } finally {
8283 if (button && currentOpenNote && currentOpenNote.path === openPath) {
8284 button.disabled = false;
8285 }
8286 }
8287 }
8288
8289 function toggleSectionSourcePanel(actionsEl, button) {
8290 const panel = actionsEl.querySelector('[data-section-source-panel]');
8291 if (panel) {
8292 hubSectionSourceSeq += 1;
8293 panel.remove();
8294 if (button) button.setAttribute('aria-expanded', 'false');
8295 return;
8296 }
8297 void loadSectionSourceForCurrentNote(actionsEl, button);
8298 }
8299
8300 function createSectionSourceButton(actionsEl) {
8301 const sectionBtn = document.createElement('button');
8302 sectionBtn.type = 'button';
8303 sectionBtn.textContent = 'Sections';
8304 sectionBtn.className = 'btn-section-source';
8305 sectionBtn.setAttribute('aria-expanded', 'false');
8306 sectionBtn.setAttribute('aria-controls', 'detail-actions');
8307 sectionBtn.title = 'Show body-free section headings for this note';
8308 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
8309 return sectionBtn;
8310 }
8311
8312 function switchNoteToReadMode() {
8313 if (!currentOpenNote) return;
8314 resetDetailSectionSourceState();
8315 teardownDetailEditBodyLayout();
8316 const bodyEl = el('detail-body');
8317 const actionsEl = el('detail-actions');
8318 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
8319 bodyEl.className = 'note-rendered-body';
8320 actionsEl.innerHTML = '';
8321 attachNoteDetailReadActions(actionsEl);
8322 const bcbRead = el('btn-detail-copy-body');
8323 if (bcbRead) bcbRead.classList.remove('hidden');
8324 }
8325
8326 async function deleteOpenNote() {
8327 if (!currentOpenNote) return;
8328 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
8329 const p = currentOpenNote.path;
8330 try {
8331 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
8332 if (typeof showToast === 'function') showToast('Note deleted');
8333 hubMarkSemanticIndexStale();
8334 currentOpenNote = null;
8335 currentNotePathForCopy = '';
8336 resetDetailSectionSourceState();
8337 teardownDetailEditBodyLayout();
8338 hideDetailPanelChrome();
8339 el('btn-copy-path').classList.add('hidden');
8340 const bcbDel = el('btn-detail-copy-body');
8341 if (bcbDel) bcbDel.classList.add('hidden');
8342 loadNotes();
8343 loadFacets();
8344 } catch (e) {
8345 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
8346 }
8347 }
8348
8349 function attachNoteDetailReadActions(actionsEl) {
8350 const exportBtn = document.createElement('button');
8351 exportBtn.type = 'button';
8352 exportBtn.textContent = 'Export';
8353 exportBtn.onclick = () => exportCurrentNote('md');
8354 const sectionBtn = createSectionSourceButton(actionsEl);
8355
8356 if (hubUserCanWriteNotes()) {
8357 const editBtn = document.createElement('button');
8358 editBtn.type = 'button';
8359 editBtn.textContent = 'Edit';
8360 editBtn.onclick = () => switchNoteToEditMode();
8361 const dupBtn = document.createElement('button');
8362 dupBtn.type = 'button';
8363 dupBtn.textContent = 'Duplicate…';
8364 dupBtn.title =
8365 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
8366 dupBtn.onclick = () => {
8367 void openDuplicateNoteModal();
8368 };
8369 const proposeBtn = document.createElement('button');
8370 proposeBtn.type = 'button';
8371 proposeBtn.textContent = 'Propose change';
8372 proposeBtn.onclick = () => {
8373 if (!currentOpenNote) return;
8374 openCreateProposalModal({
8375 path: currentOpenNote.path,
8376 body: currentOpenNote.body || '',
8377 fromNote: true,
8378 });
8379 };
8380 const delBtn = document.createElement('button');
8381 delBtn.type = 'button';
8382 delBtn.textContent = 'Delete';
8383 delBtn.onclick = () => deleteOpenNote();
8384 if (hubHasMultipleVaultsForCopy()) {
8385 const copyVaultBtn = document.createElement('button');
8386 copyVaultBtn.type = 'button';
8387 copyVaultBtn.textContent = 'Copy to vault…';
8388 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
8389 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
8390 } else {
8391 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
8392 }
8393 return;
8394 }
8395
8396 if (hubUserMayProposeFromNote()) {
8397 const proposeBtn = document.createElement('button');
8398 proposeBtn.type = 'button';
8399 proposeBtn.textContent = 'Propose change';
8400 proposeBtn.onclick = () => {
8401 if (!currentOpenNote) return;
8402 openCreateProposalModal({
8403 path: currentOpenNote.path,
8404 body: currentOpenNote.body || '',
8405 fromNote: true,
8406 });
8407 };
8408 actionsEl.appendChild(proposeBtn);
8409 }
8410 actionsEl.appendChild(sectionBtn);
8411 if (hubUserCanExportNote()) {
8412 actionsEl.appendChild(exportBtn);
8413 }
8414 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
8415 const hint = document.createElement('p');
8416 hint.className = 'muted small';
8417 hint.style.marginTop = '0.5rem';
8418 hint.textContent =
8419 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
8420 actionsEl.appendChild(hint);
8421 }
8422 }
8423
8424 function openCopyNoteToVaultModal() {
8425 if (!currentOpenNote || !token) return;
8426 if (!hubHasMultipleVaultsForCopy()) {
8427 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
8428 return;
8429 }
8430 const existing = document.getElementById('modal-copy-note-vault');
8431 if (existing) existing.remove();
8432 const s = lastBackupSettingsPayload;
8433 const allowed = new Set((s.allowed_vault_ids || []).map(String));
8434 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
8435 const fromId = String(getCurrentVaultId() || 'default');
8436 const targets = vaultList.filter((v) => String(v.id) !== fromId);
8437 if (targets.length === 0) {
8438 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
8439 return;
8440 }
8441 const wrap = document.createElement('div');
8442 wrap.id = 'modal-copy-note-vault';
8443 wrap.className = 'modal';
8444 wrap.setAttribute('role', 'dialog');
8445 wrap.setAttribute('aria-modal', 'true');
8446 wrap.setAttribute('aria-label', 'Copy note to another vault');
8447 const backdrop = document.createElement('div');
8448 backdrop.className = 'modal-backdrop';
8449 const card = document.createElement('div');
8450 card.className = 'modal-card';
8451 card.style.maxWidth = '480px';
8452 const header = document.createElement('div');
8453 header.className = 'modal-header';
8454 const h2 = document.createElement('h2');
8455 h2.textContent = 'Copy to vault';
8456 const btnClose = document.createElement('button');
8457 btnClose.type = 'button';
8458 btnClose.className = 'modal-close';
8459 btnClose.textContent = '×';
8460 btnClose.setAttribute('aria-label', 'Close');
8461 header.appendChild(h2);
8462 header.appendChild(btnClose);
8463 const body = document.createElement('div');
8464 body.style.padding = '1rem 1.25rem';
8465 const lbl = document.createElement('label');
8466 lbl.className = 'detail-field-label';
8467 lbl.textContent = 'Target vault';
8468 lbl.setAttribute('for', 'copy-note-vault-select');
8469 const sel = document.createElement('select');
8470 sel.id = 'copy-note-vault-select';
8471 sel.className = 'vault-switcher-select';
8472 sel.style.width = '100%';
8473 sel.style.marginTop = '0.35rem';
8474 for (const v of targets) {
8475 const id = String(v.id);
8476 const opt = document.createElement('option');
8477 opt.value = id;
8478 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
8479 sel.appendChild(opt);
8480 }
8481 const moveRow = document.createElement('label');
8482 moveRow.style.display = 'flex';
8483 moveRow.style.alignItems = 'center';
8484 moveRow.style.gap = '0.5rem';
8485 moveRow.style.marginTop = '1rem';
8486 moveRow.style.cursor = 'pointer';
8487 const moveChk = document.createElement('input');
8488 moveChk.type = 'checkbox';
8489 moveChk.id = 'copy-note-delete-source';
8490 const moveSpan = document.createElement('span');
8491 moveSpan.textContent = 'Delete from this vault (move)';
8492 moveRow.appendChild(moveChk);
8493 moveRow.appendChild(moveSpan);
8494 const hint = document.createElement('p');
8495 hint.className = 'muted small';
8496 hint.style.marginTop = '0.75rem';
8497 hint.style.fontSize = '0.85rem';
8498 hint.textContent =
8499 '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).';
8500 const actions = document.createElement('div');
8501 actions.style.display = 'flex';
8502 actions.style.justifyContent = 'flex-end';
8503 actions.style.gap = '0.5rem';
8504 actions.style.marginTop = '1.25rem';
8505 const btnCancel = document.createElement('button');
8506 btnCancel.type = 'button';
8507 btnCancel.className = 'btn-secondary';
8508 btnCancel.textContent = 'Cancel';
8509 const btnGo = document.createElement('button');
8510 btnGo.type = 'button';
8511 btnGo.className = 'btn-primary';
8512 btnGo.textContent = 'Copy';
8513 actions.appendChild(btnCancel);
8514 actions.appendChild(btnGo);
8515 body.appendChild(lbl);
8516 body.appendChild(sel);
8517 body.appendChild(moveRow);
8518 body.appendChild(hint);
8519 body.appendChild(actions);
8520 card.appendChild(header);
8521 card.appendChild(body);
8522 wrap.appendChild(backdrop);
8523 wrap.appendChild(card);
8524 function close() {
8525 wrap.remove();
8526 }
8527 backdrop.onclick = close;
8528 btnClose.onclick = close;
8529 btnCancel.onclick = close;
8530 btnGo.onclick = async () => {
8531 const toId = sel.value;
8532 if (!toId || !currentOpenNote) return;
8533 await withButtonBusy(btnGo, 'Copying…', async () => {
8534 try {
8535 const res = await api('/api/v1/notes/copy', {
8536 method: 'POST',
8537 body: JSON.stringify({
8538 from_vault_id: fromId,
8539 to_vault_id: toId,
8540 path: currentOpenNote.path,
8541 delete_source: moveChk.checked,
8542 }),
8543 });
8544 hubMarkSemanticIndexStaleForVault(toId);
8545 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
8546 close();
8547 if (typeof showToast === 'function') {
8548 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
8549 }
8550 if (res.moved) {
8551 currentOpenNote = null;
8552 currentNotePathForCopy = '';
8553 resetDetailSectionSourceState();
8554 hideDetailPanelChrome();
8555 const bcp = el('btn-copy-path');
8556 if (bcp) bcp.classList.add('hidden');
8557 loadNotes();
8558 loadFacets();
8559 }
8560 } catch (e) {
8561 if (typeof showToast === 'function') showToast(e.message || String(e), true);
8562 }
8563 });
8564 };
8565 document.body.appendChild(wrap);
8566 }
8567
8568 async function exportCurrentNote(format) {
8569 if (!currentOpenNote) return;
8570 try {
8571 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
8572 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
8573 const a = document.createElement('a');
8574 a.href = URL.createObjectURL(blob);
8575 a.download = res.filename || 'export.md';
8576 a.click();
8577 URL.revokeObjectURL(a.href);
8578 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
8579 } catch (e) {
8580 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
8581 }
8582 }
8583
8584 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
8585 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
8586 var MEDIA_URL_SAFE = /^https?:\/\//i;
8587
8588 function teardownDetailEditBodyLayout() {
8589 if (detailEditBodyLayoutAbort) {
8590 detailEditBodyLayoutAbort.abort();
8591 detailEditBodyLayoutAbort = null;
8592 }
8593 }
8594
8595 function detailEditBodyMaxTextareaPx() {
8596 var wrap = el('detail-edit-body-wrap');
8597 var ta = el('detail-edit-body');
8598 if (!wrap || !ta) return 400;
8599 var toolbar = el('media-toolbar');
8600 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8601 var tb = toolbar ? toolbar.offsetHeight : 0;
8602 var gh = grip ? grip.offsetHeight : 0;
8603 var slack = 10;
8604 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
8605 var fallback = Math.round(window.innerHeight * 0.28);
8606 var wr = wrap.getBoundingClientRect();
8607 var next = wrap.nextElementSibling;
8608 var slice = 0;
8609 if (next && next.nodeType === 1) {
8610 var nr = next.getBoundingClientRect();
8611 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
8612 } else {
8613 var body = el('detail-body');
8614 if (body) {
8615 var br = body.getBoundingClientRect();
8616 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
8617 }
8618 }
8619 if (!Number.isFinite(slice) || slice < 120) {
8620 slice = fallback;
8621 }
8622 return Math.max(160, Math.min(hard, slice));
8623 }
8624
8625 function sizeDetailEditBodyToFill() {
8626 var ta = el('detail-edit-body');
8627 if (!ta) return;
8628 ta.style.removeProperty('height');
8629 }
8630
8631 function wireDetailEditBodyLayout() {
8632 teardownDetailEditBodyLayout();
8633 var ta = el('detail-edit-body');
8634 var wrap = el('detail-edit-body-wrap');
8635 if (!ta || !wrap) return;
8636 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
8637 if (!grip) {
8638 grip = document.createElement('div');
8639 grip.className = 'detail-edit-body-resize-handle';
8640 grip.setAttribute('role', 'separator');
8641 grip.setAttribute('aria-orientation', 'horizontal');
8642 grip.setAttribute('aria-label', 'Resize editor height');
8643 var next = ta.nextSibling;
8644 if (next && next.id === 'media-toolbar') {
8645 wrap.insertBefore(grip, next);
8646 } else {
8647 wrap.appendChild(grip);
8648 }
8649 }
8650 if (grip.dataset.wired !== '1') {
8651 grip.dataset.wired = '1';
8652 function startDrag(clientY) {
8653 var startY = clientY;
8654 var startH = ta.offsetHeight;
8655 document.body.style.userSelect = 'none';
8656 function onMove(e2) {
8657 if (e2.touches && e2.cancelable) e2.preventDefault();
8658 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
8659 var dy = y - startY;
8660 var cap = detailEditBodyMaxTextareaPx();
8661 var nh = Math.max(160, Math.min(cap, startH + dy));
8662 ta.style.height = nh + 'px';
8663 }
8664 function onUp() {
8665 document.body.style.userSelect = '';
8666 document.removeEventListener('mousemove', onMove);
8667 document.removeEventListener('mouseup', onUp);
8668 document.removeEventListener('touchmove', onMove);
8669 document.removeEventListener('touchend', onUp);
8670 }
8671 document.addEventListener('mousemove', onMove);
8672 document.addEventListener('mouseup', onUp);
8673 document.addEventListener('touchmove', onMove, { passive: false });
8674 document.addEventListener('touchend', onUp);
8675 }
8676 grip.addEventListener('mousedown', function (e) {
8677 e.preventDefault();
8678 startDrag(e.clientY);
8679 });
8680 grip.addEventListener('touchstart', function (e) {
8681 if (!e.touches || !e.touches[0]) return;
8682 e.preventDefault();
8683 startDrag(e.touches[0].clientY);
8684 }, { passive: false });
8685 }
8686 window.requestAnimationFrame(function () {
8687 sizeDetailEditBodyToFill();
8688 });
8689 detailEditBodyLayoutAbort = new AbortController();
8690 window.addEventListener(
8691 'resize',
8692 function () {
8693 if (!el('detail-edit-body-wrap')) return;
8694 sizeDetailEditBodyToFill();
8695 },
8696 { signal: detailEditBodyLayoutAbort.signal }
8697 );
8698 }
8699
8700 function attachMediaToolbar() {
8701 var textarea = el('detail-edit-body');
8702 if (!textarea) return;
8703 var existing = document.getElementById('media-toolbar');
8704 if (existing) existing.remove();
8705
8706 var toolbar = document.createElement('div');
8707 toolbar.id = 'media-toolbar';
8708 toolbar.className = 'media-toolbar';
8709
8710 var insertBtn = document.createElement('button');
8711 insertBtn.type = 'button';
8712 insertBtn.textContent = 'Insert Media URL';
8713 insertBtn.className = 'btn-small';
8714 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.';
8715 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
8716 toolbar.appendChild(insertBtn);
8717
8718 var s = lastBackupSettingsPayload;
8719 if (s && s.github_connected && hubUserCanWriteNotes()) {
8720 var uploadBtn = document.createElement('button');
8721 uploadBtn.type = 'button';
8722 uploadBtn.textContent = 'Upload Image';
8723 uploadBtn.className = 'btn-small';
8724 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.';
8725 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
8726 toolbar.appendChild(uploadBtn);
8727 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
8728 var connectHint = document.createElement('span');
8729 connectHint.className = 'media-toolbar-hint';
8730 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
8731 connectHint.textContent = 'Connect GitHub to upload images';
8732 toolbar.appendChild(connectHint);
8733 }
8734
8735 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
8736 }
8737
8738 function toggleMediaUrlDialog(toolbar, textarea) {
8739 var existing = document.getElementById('media-url-dialog');
8740 if (existing) { existing.remove(); return; }
8741
8742 var dialog = document.createElement('div');
8743 dialog.id = 'media-url-dialog';
8744 dialog.className = 'media-url-dialog';
8745
8746 var input = document.createElement('input');
8747 input.type = 'text';
8748 input.placeholder = 'Paste image or video URL (https://...)';
8749 input.className = 'media-url-input';
8750
8751 var preview = document.createElement('div');
8752 preview.className = 'media-preview';
8753
8754 var actions = document.createElement('div');
8755 actions.className = 'media-url-actions';
8756
8757 var doInsert = document.createElement('button');
8758 doInsert.type = 'button';
8759 doInsert.textContent = 'Insert';
8760 doInsert.className = 'btn-primary btn-small';
8761 doInsert.disabled = true;
8762
8763 var doCancel = document.createElement('button');
8764 doCancel.type = 'button';
8765 doCancel.textContent = 'Cancel';
8766 doCancel.className = 'btn-small';
8767 doCancel.onclick = function () { dialog.remove(); };
8768
8769 actions.appendChild(doInsert);
8770 actions.appendChild(doCancel);
8771
8772 var detectedType = null;
8773
8774 function onUrlChange() {
8775 var url = input.value.trim();
8776 preview.innerHTML = '';
8777 doInsert.disabled = true;
8778 detectedType = null;
8779 if (!url || !MEDIA_URL_SAFE.test(url)) return;
8780 if (MEDIA_IMAGE_EXTS.test(url)) {
8781 detectedType = 'image';
8782 var img = document.createElement('img');
8783 img.src = url;
8784 img.style.maxHeight = '200px';
8785 img.style.maxWidth = '100%';
8786 img.crossOrigin = 'anonymous';
8787 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8788 preview.appendChild(img);
8789 doInsert.disabled = false;
8790 } else if (MEDIA_VIDEO_EXTS.test(url)) {
8791 detectedType = 'video';
8792 var vid = document.createElement('video');
8793 vid.controls = true;
8794 vid.preload = 'metadata';
8795 vid.style.maxHeight = '200px';
8796 vid.style.maxWidth = '100%';
8797 vid.src = url;
8798 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
8799 preview.appendChild(vid);
8800 doInsert.disabled = false;
8801 } else {
8802 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>';
8803 }
8804 }
8805
8806 input.addEventListener('input', onUrlChange);
8807 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
8808
8809 doInsert.onclick = function () {
8810 var url = input.value.trim();
8811 if (!url) return;
8812 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
8813 insertAtCursor(textarea, insertion);
8814 dialog.remove();
8815 };
8816
8817 dialog.appendChild(input);
8818 dialog.appendChild(preview);
8819 dialog.appendChild(actions);
8820 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
8821 input.focus();
8822 }
8823
8824 function insertAtCursor(textarea, text) {
8825 var start = textarea.selectionStart;
8826 var end = textarea.selectionEnd;
8827 var val = textarea.value;
8828 var before = val.substring(0, start);
8829 var needsNewline = before.length > 0 && !before.endsWith('\n');
8830 var insertion = (needsNewline ? '\n' : '') + text + '\n';
8831 textarea.value = before + insertion + val.substring(end);
8832 var newPos = start + insertion.length;
8833 textarea.setSelectionRange(newPos, newPos);
8834 textarea.focus();
8835 }
8836
8837 /**
8838 * Compress an image File/Blob using the Canvas API so it fits within the
8839 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
8840 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
8841 * Falls back to the original file if Canvas is unavailable or the image is
8842 * already small enough.
8843 */
8844 function compressImageIfNeeded(file) {
8845 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
8846 var MAX_DIM = 2048;
8847 return new Promise(function (resolve) {
8848 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
8849 return resolve(file);
8850 }
8851 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
8852 return resolve(file);
8853 }
8854 var img = new window.Image();
8855 var objectUrl = URL.createObjectURL(file);
8856 img.onload = function () {
8857 URL.revokeObjectURL(objectUrl);
8858 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
8859 var w = Math.round(img.width * ratio);
8860 var h = Math.round(img.height * ratio);
8861 var canvas = document.createElement('canvas');
8862 canvas.width = w;
8863 canvas.height = h;
8864 var ctx = canvas.getContext('2d');
8865 ctx.drawImage(img, 0, 0, w, h);
8866 var tryQuality = function (quality, attempt) {
8867 canvas.toBlob(function (blob) {
8868 if (!blob) return resolve(file); // Canvas failed — upload original
8869 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
8870 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
8871 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
8872 } else {
8873 tryQuality(quality - 0.2, attempt + 1);
8874 }
8875 }, 'image/jpeg', quality);
8876 };
8877 tryQuality(0.82, 0);
8878 };
8879 img.onerror = function () {
8880 URL.revokeObjectURL(objectUrl);
8881 resolve(file);
8882 };
8883 img.src = objectUrl;
8884 });
8885 }
8886
8887 function triggerImageUpload(textarea) {
8888 var fileInput = document.createElement('input');
8889 fileInput.type = 'file';
8890 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
8891 fileInput.onchange = async function () {
8892 var file = fileInput.files && fileInput.files[0];
8893 if (!file || !currentOpenNote) return;
8894 try {
8895 if (typeof showToast === 'function') showToast('Uploading image…');
8896 // Compress before uploading to stay within the Netlify Lambda 6 MB
8897 // payload limit (~4.5 MB binary after base64 overhead).
8898 var uploadFile = await compressImageIfNeeded(file);
8899 var form = new FormData();
8900 form.append('image', uploadFile);
8901 var notePath = encodeURIComponent(currentOpenNote.path);
8902 var vaultIdParam = '';
8903 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
8904 // Build auth headers from the shared helper (omit Content-Type so the
8905 // browser sets the correct multipart/form-data boundary automatically).
8906 var uploadHeaders = headers();
8907 delete uploadHeaders['Content-Type'];
8908 // Use apiBase so this request reaches the gateway when the frontend is served
8909 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
8910 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8911 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
8912 method: 'POST',
8913 headers: uploadHeaders,
8914 body: form,
8915 });
8916 if (!res.ok) {
8917 var errData = await res.json().catch(function () { return {}; });
8918 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
8919 }
8920 var data = await res.json();
8921 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
8922 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
8923 } catch (e) {
8924 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
8925 }
8926 };
8927 fileInput.click();
8928 }
8929
8930 function switchNoteToEditMode() {
8931 if (!currentOpenNote) return;
8932 closeCreateModal();
8933 resetDetailSectionSourceState();
8934 const bcbEdit = el('btn-detail-copy-body');
8935 if (bcbEdit) bcbEdit.classList.add('hidden');
8936 const bodyEl = el('detail-body');
8937 const actionsEl = el('detail-actions');
8938 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
8939 bodyEl.className = 'detail-edit-container create-panel';
8940 bodyEl.innerHTML =
8941 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
8942 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
8943 '<label for="detail-edit-title">Title</label>' +
8944 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
8945 '<label for="detail-edit-body">Body (Markdown)</label>' +
8946 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
8947 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
8948 '</div>' +
8949 '<label for="detail-edit-date">Date</label>' +
8950 '<input type="date" id="detail-edit-date" />' +
8951 '<label for="detail-edit-project">Project (slug)</label>' +
8952 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
8953 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
8954 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
8955 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
8956 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
8957 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
8958 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
8959 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
8960 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
8961 '<label for="detail-edit-episode">Episode ID</label>' +
8962 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
8963 '<label for="detail-edit-follows">Follows (vault path)</label>' +
8964 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
8965 const pathDisp = el('detail-edit-path-display');
8966 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
8967 fillDetailEditFieldsFromFrontmatter(fm);
8968 attachMediaToolbar();
8969 wireDetailEditBodyLayout();
8970 actionsEl.innerHTML = '';
8971 const saveBtn = document.createElement('button');
8972 saveBtn.textContent = 'Save';
8973 saveBtn.className = 'btn-primary';
8974 saveBtn.onclick = async () => {
8975 closeCreateModal();
8976 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
8977 const frontmatter = mergedFrontmatterForDetailSave();
8978 await withButtonBusy(saveBtn, 'Saving…', async () => {
8979 try {
8980 await api('/api/v1/notes', {
8981 method: 'POST',
8982 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
8983 });
8984 hubMarkSemanticIndexStale();
8985 if (typeof showToast === 'function') showToast('Note saved');
8986 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
8987 const nfm = materializeFrontmatter(refreshed.frontmatter);
8988 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
8989 switchNoteToReadMode();
8990 if (typeof loadNotes === 'function') loadNotes();
8991 if (typeof loadFacets === 'function') loadFacets();
8992 } catch (e) {
8993 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
8994 }
8995 });
8996 };
8997 const cancelBtn = document.createElement('button');
8998 cancelBtn.textContent = 'Cancel';
8999 cancelBtn.onclick = () => switchNoteToReadMode();
9000 const delBtn = document.createElement('button');
9001 delBtn.type = 'button';
9002 delBtn.textContent = 'Delete';
9003 delBtn.onclick = () => deleteOpenNote();
9004 actionsEl.append(saveBtn, delBtn, cancelBtn);
9005 }
9006
9007 function openNote(path) {
9008 const seq = ++hubOpenNoteSeq;
9009 resetDetailSectionSourceState();
9010 teardownDetailEditBodyLayout();
9011 closeCreateModal();
9012 currentNotePathForCopy = path;
9013 currentOpenNote = null;
9014 const panel = el('detail-panel');
9015 panel.classList.remove('detail-panel-proposal-wide');
9016 const title = el('detail-title');
9017 const bodyEl = el('detail-body');
9018 const actionsEl = el('detail-actions');
9019 const btnCopy = el('btn-copy-path');
9020 const btnCopyBody = el('btn-detail-copy-body');
9021 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9022 title.textContent = path;
9023 bodyEl.textContent = 'Loading…';
9024 bodyEl.className = '';
9025 actionsEl.innerHTML = '';
9026 btnCopy.classList.remove('hidden');
9027 panel.classList.remove('hidden');
9028 api('/api/v1/notes/' + encodeURIComponent(path))
9029 .then((note) => {
9030 if (seq !== hubOpenNoteSeq) return;
9031 const fm = materializeFrontmatter(note.frontmatter);
9032 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9033 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9034 bodyEl.className = 'note-rendered-body';
9035 actionsEl.innerHTML = '';
9036 attachNoteDetailReadActions(actionsEl);
9037 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9038 })
9039 .catch((e) => {
9040 if (seq !== hubOpenNoteSeq) return;
9041 bodyEl.textContent = 'Error: ' + e.message;
9042 bodyEl.className = '';
9043 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9044 });
9045 }
9046
9047 el('btn-copy-path').onclick = () => {
9048 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
9049 };
9050
9051 const btnDetailCopyBody = el('btn-detail-copy-body');
9052 if (btnDetailCopyBody) {
9053 btnDetailCopyBody.onclick = () => {
9054 if (!currentOpenNote) {
9055 if (typeof showToast === 'function') showToast('Open a note first.', true);
9056 return;
9057 }
9058 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
9059 if (navigator.clipboard && navigator.clipboard.writeText) {
9060 navigator.clipboard.writeText(text).then(
9061 () => {
9062 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
9063 },
9064 () => {
9065 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
9066 },
9067 );
9068 } else if (typeof showToast === 'function') {
9069 showToast('Clipboard not available in this browser.', true);
9070 }
9071 };
9072 }
9073
9074 const btnCopyUserId = el('btn-copy-user-id');
9075 if (btnCopyUserId) {
9076 btnCopyUserId.onclick = () => {
9077 const idEl = el('settings-user-id');
9078 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
9079 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9080 navigator.clipboard.writeText(text).then(() => {
9081 if (typeof showToast === 'function') showToast('User ID copied.');
9082 }).catch(() => {});
9083 }
9084 };
9085 }
9086 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
9087 if (btnCopyAgentceptionEnv) {
9088 btnCopyAgentceptionEnv.onclick = () => {
9089 const envEl = el('integrations-agentception-env');
9090 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
9091 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9092 navigator.clipboard.writeText(text).then(() => {
9093 if (typeof showToast === 'function') showToast('Env snippet copied.');
9094 }).catch(() => {});
9095 }
9096 };
9097 }
9098 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
9099 if (btnIntegrationsHowToAgentception) {
9100 btnIntegrationsHowToAgentception.onclick = () => {
9101 closeSettings();
9102 openHowToUse('setup');
9103 };
9104 }
9105 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
9106 if (btnHowToFlexibleNetwork) {
9107 btnHowToFlexibleNetwork.onclick = () => {
9108 closeSettings();
9109 openHowToUse('setup', 'how-to-flexible-network');
9110 };
9111 }
9112
9113 function renderProposalMarkdownHtml(md) {
9114 try {
9115 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9116 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9117 var withVideo = expandVideoUrls(raw);
9118 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9119 return rewriteGitHubImageUrls(sanitised);
9120 }
9121 } catch (_) {
9122 /* fall through */
9123 }
9124 return escapeHtml(md || '');
9125 }
9126
9127 /** Canister stores checklist as JSON text; Node may return an array. */
9128 function parseProposalEvaluationChecklist(raw) {
9129 if (Array.isArray(raw)) return raw;
9130 if (raw == null || raw === '') return [];
9131 const s = String(raw).trim();
9132 if (!s) return [];
9133 try {
9134 const j = JSON.parse(s);
9135 return Array.isArray(j) ? j : [];
9136 } catch (_) {
9137 return [];
9138 }
9139 }
9140
9141 /**
9142 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
9143 */
9144 function buildProposalEvaluationRecordHtml(p, rubricItems) {
9145 const st = p.status;
9146 if (st !== 'approved' && st !== 'discarded') return '';
9147 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9148 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
9149 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
9150 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
9151 const meaningfulStatus = es && es !== 'none';
9152 let waiverText = '';
9153 const w = p.evaluation_waiver;
9154 if (w != null && w !== '') {
9155 try {
9156 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
9157 if (o && typeof o === 'object') {
9158 const r1 = o.reason != null ? String(o.reason).trim() : '';
9159 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
9160 waiverText = r1 || r2;
9161 }
9162 } catch (_) {
9163 /* ignore */
9164 }
9165 }
9166 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
9167 const rubricById = new Map(
9168 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
9169 String(it.id || '').trim(),
9170 String(it.label || it.id || '').trim(),
9171 ]),
9172 );
9173 const rows = checklist
9174 .map((c) => {
9175 const rid = c && c.id != null ? String(c.id) : '';
9176 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
9177 const pass = c && c.passed === true;
9178 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
9179 })
9180 .join('');
9181 return (
9182 '<div class="proposal-eval proposal-eval-readonly">' +
9183 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
9184 '<p class="small">' +
9185 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
9186 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
9187 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
9188 (p.evaluated_at
9189 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
9190 : '') +
9191 '</p>' +
9192 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
9193 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
9194 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
9195 '</div>'
9196 );
9197 }
9198
9199 function openProposal(id) {
9200 resetDetailSectionSourceState();
9201 currentNotePathForCopy = '';
9202 currentOpenNote = null;
9203 el('btn-copy-path').classList.add('hidden');
9204 const bcbProp = el('btn-detail-copy-body');
9205 if (bcbProp) bcbProp.classList.add('hidden');
9206 const panel = el('detail-panel');
9207 panel.classList.add('detail-panel-proposal-wide');
9208 const title = el('detail-title');
9209 const body = el('detail-body');
9210 const actions = el('detail-actions');
9211 body.className = 'detail-body-proposal';
9212 panel.classList.remove('hidden');
9213 body.innerHTML = '<p class="muted">Loading…</p>';
9214 actions.innerHTML = '';
9215 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
9216 api('/api/v1/proposals/' + encodeURIComponent(id))
9217 .then((p) =>
9218 api('/api/v1/notes/' + pathEnc(p.path)).then(
9219 (note) => ({ p, note }),
9220 () => ({ p, note: null }),
9221 ),
9222 )
9223 .then(({ p, note }) => {
9224 title.textContent = p.path + ' (' + p.status + ')';
9225 const pFm = materializeFrontmatter(p.frontmatter);
9226 const currentBlock = note
9227 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
9228 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
9229 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
9230 const mdHtml = renderProposalMarkdownHtml(p.body || '');
9231 const chips = [];
9232 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
9233 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
9234 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
9235 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
9236 });
9237 if (p.external_ref) {
9238 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
9239 }
9240 const role = window.__hubUserRole || 'member';
9241 const isAdmin = role === 'admin';
9242 const isEvaluator = role === 'evaluator';
9243 const canEvaluate = isAdmin || isEvaluator;
9244 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
9245 const canDiscard = isAdmin;
9246 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
9247 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9248 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
9249 function prevEvalPassed(rid) {
9250 const row = prevChecklist.find((c) => c && c.id === rid);
9251 return Boolean(row && row.passed === true);
9252 }
9253 let evalHtml = '';
9254 let waiverHtml = '';
9255 if (canEvaluate && p.status === 'proposed') {
9256 const es = p.evaluation_status || 'none';
9257 let evalIntro = '';
9258 if (es && es !== 'none' && es !== 'pending') {
9259 evalIntro =
9260 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
9261 escapeHtml(es) +
9262 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
9263 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
9264 (p.evaluation_comment
9265 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
9266 : '') +
9267 '</div>';
9268 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
9269 evalIntro =
9270 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
9271 }
9272 const checks = rubricItems.length
9273 ? rubricItems
9274 .map((it) => {
9275 const rid = String(it.id || '').trim();
9276 if (!rid) return '';
9277 const lab = String(it.label || rid);
9278 const ck = prevEvalPassed(rid) ? ' checked' : '';
9279 return (
9280 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
9281 escapeHtml(rid) +
9282 '"' +
9283 ck +
9284 ' /> ' +
9285 escapeHtml(lab) +
9286 '</label>'
9287 );
9288 })
9289 .join('')
9290 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
9291 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
9292 evalHtml =
9293 '<div class="proposal-eval">' +
9294 '<h4 class="proposal-md-heading">Evaluation</h4>' +
9295 evalIntro +
9296 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
9297 '<option value="pass">Pass</option>' +
9298 '<option value="fail">Fail</option>' +
9299 '<option value="needs_changes">Needs changes</option>' +
9300 '</select></label>' +
9301 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
9302 gradeVal +
9303 '" placeholder="e.g. A or 4" /></label>' +
9304 '<div class="proposal-eval-checklist">' +
9305 checks +
9306 '</div>' +
9307 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
9308 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
9309 '</textarea></label>' +
9310 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
9311 '</div>';
9312 }
9313 if (canApprove && p.status === 'proposed') {
9314 waiverHtml =
9315 '<div class="proposal-eval-waiver">' +
9316 '<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>' +
9317 '</div>';
9318 }
9319 let autoFlagHtml = '';
9320 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
9321 autoFlagHtml =
9322 '<p class="small muted">Auto-flagged: ' +
9323 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
9324 '</p>';
9325 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
9326 try {
9327 const ar = JSON.parse(String(p.auto_flag_reasons_json));
9328 if (Array.isArray(ar) && ar.length) {
9329 autoFlagHtml =
9330 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
9331 }
9332 } catch (_) {
9333 /* ignore */
9334 }
9335 }
9336 let hintsHtml = '';
9337 if (p.review_hints) {
9338 hintsHtml =
9339 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
9340 (p.review_hints_model
9341 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
9342 : '') +
9343 (p.review_hints_at
9344 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
9345 : '') +
9346 '<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>' +
9347 '<pre class="proposal-pre">' +
9348 escapeHtml(String(p.review_hints)) +
9349 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
9350 }
9351 let assistantHtml = '';
9352 if (p.assistant_notes) {
9353 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
9354 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
9355 .join('');
9356 assistantHtml =
9357 '<div class="proposal-assistant"><strong>Assistant</strong>' +
9358 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
9359 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
9360 '<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>' +
9361 '<p>' +
9362 escapeHtml(String(p.assistant_notes)) +
9363 '</p>' +
9364 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
9365 '</div>';
9366 }
9367 let suggestedFmHtml = '';
9368 {
9369 let fm = p.assistant_suggested_frontmatter;
9370 if (typeof fm === 'string') {
9371 try {
9372 fm = JSON.parse(fm);
9373 } catch {
9374 fm = null;
9375 }
9376 }
9377 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
9378 const keys = Object.keys(fm).filter((k) => {
9379 const v = fm[k];
9380 return v !== undefined && v !== null && v !== '';
9381 });
9382 if (keys.length) {
9383 const rows = keys
9384 .map((k) => {
9385 const v = fm[k];
9386 let cell;
9387 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
9388 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
9389 else cell = String(v);
9390 return (
9391 '<tr><th scope="row">' +
9392 escapeHtml(k) +
9393 '</th><td>' +
9394 escapeHtml(cell) +
9395 '</td></tr>'
9396 );
9397 })
9398 .join('');
9399 suggestedFmHtml =
9400 '<div class="proposal-suggested-fm">' +
9401 '<strong>Suggested frontmatter</strong> ' +
9402 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
9403 '<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>' +
9404 '<table class="proposal-suggested-fm-table"><tbody>' +
9405 rows +
9406 '</tbody></table></div>';
9407 }
9408 }
9409 }
9410 const openVaultNoteLine = note
9411 ? '<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>'
9412 : '<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>';
9413 body.innerHTML =
9414 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
9415 autoFlagHtml +
9416 '<p class="small muted">Intent: ' +
9417 escapeHtml(p.intent || '—') +
9418 ' · base_state_id: ' +
9419 escapeHtml(p.base_state_id || '—') +
9420 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
9421 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
9422 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
9423 '</p>' +
9424 openVaultNoteLine +
9425 '<div class="proposal-diff-grid">' +
9426 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
9427 escapeHtml(currentBlock) +
9428 '</pre></div>' +
9429 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
9430 escapeHtml(proposedBlock) +
9431 '</pre></div>' +
9432 '</div>' +
9433 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
9434 '<div class="proposal-md">' +
9435 mdHtml +
9436 '</div>' +
9437 evalRecordHtml +
9438 evalHtml +
9439 waiverHtml +
9440 assistantHtml +
9441 suggestedFmHtml +
9442 hintsHtml;
9443 actions.innerHTML = '';
9444 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
9445 if (openNoteBtn && note && p.path) {
9446 openNoteBtn.onclick = () => openNote(String(p.path));
9447 }
9448 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
9449 if (copyFmBtn) {
9450 let fmForCopy = p.assistant_suggested_frontmatter;
9451 if (typeof fmForCopy === 'string') {
9452 try {
9453 fmForCopy = JSON.parse(fmForCopy);
9454 } catch {
9455 fmForCopy = null;
9456 }
9457 }
9458 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
9459 copyFmBtn.onclick = async () => {
9460 try {
9461 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
9462 showToast('Copied suggested frontmatter JSON.');
9463 } catch (err) {
9464 showToast(err.message || 'Copy failed', true);
9465 }
9466 };
9467 }
9468 }
9469 const saveEvalBtn = body.querySelector('#proposal-eval-save');
9470 if (saveEvalBtn) {
9471 saveEvalBtn.onclick = async () => {
9472 const outcomeEl = body.querySelector('#proposal-eval-outcome');
9473 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
9474 const gradeEl = body.querySelector('#proposal-eval-grade');
9475 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
9476 const commentEl = body.querySelector('#proposal-eval-comment');
9477 const comment = commentEl ? String(commentEl.value || '').trim() : '';
9478 const checklist = [];
9479 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
9480 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
9481 });
9482 try {
9483 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
9484 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
9485 method: 'POST',
9486 body: JSON.stringify({
9487 outcome,
9488 grade: grade || undefined,
9489 comment: comment || undefined,
9490 checklist,
9491 }),
9492 });
9493 });
9494 showToast('Evaluation saved.');
9495 openProposal(id);
9496 loadProposals();
9497 } catch (err) {
9498 showToast(err.message || 'Evaluation failed', true);
9499 }
9500 };
9501 }
9502 if (p.status === 'proposed') {
9503 if (canApprove) {
9504 const approveBtn = document.createElement('button');
9505 approveBtn.textContent = 'Approve';
9506 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
9507 actions.append(approveBtn);
9508 }
9509 if (canDiscard) {
9510 const discardBtn = document.createElement('button');
9511 discardBtn.textContent = 'Discard';
9512 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
9513 actions.append(discardBtn);
9514 }
9515 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
9516 const enrichBtn = document.createElement('button');
9517 enrichBtn.type = 'button';
9518 enrichBtn.className = 'btn-secondary';
9519 enrichBtn.textContent = 'Enrich (AI)';
9520 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
9521 actions.append(enrichBtn);
9522 }
9523 if (isEvaluator && !canApprove) {
9524 const hintEv = document.createElement('p');
9525 hintEv.className = 'muted small';
9526 hintEv.textContent =
9527 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
9528 actions.append(hintEv);
9529 } else if (!canEvaluate) {
9530 const hint = document.createElement('p');
9531 hint.className = 'muted small';
9532 hint.textContent =
9533 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
9534 actions.append(hint);
9535 }
9536 }
9537 })
9538 .catch((e) => {
9539 body.className = 'detail-body-proposal';
9540 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
9541 });
9542 }
9543
9544 async function enrichProposal(id, panel, btn) {
9545 try {
9546 await withButtonBusy(btn, 'Enriching…', async () => {
9547 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
9548 });
9549 showToast('Proposal enriched.');
9550 openProposal(id);
9551 loadProposals();
9552 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
9553 // is visible instead of the browser staying at whatever scroll position it was at.
9554 const scrollHost = el('detail-body');
9555 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
9556 // Also highlight the matching row in the Suggested/Activity list so the user can see which
9557 // proposal was enriched.
9558 requestAnimationFrame(() => {
9559 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
9560 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
9561 });
9562 } catch (e) {
9563 showToast(e.message || 'Enrich failed', true);
9564 }
9565 }
9566
9567 async function approveProposal(id, panel, btn) {
9568 try {
9569 const db = el('detail-body');
9570 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
9571 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
9572 const approveBody = {};
9573 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
9574 let approveOut = null;
9575 await withButtonBusy(btn, 'Approving…', async () => {
9576 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
9577 method: 'POST',
9578 body: JSON.stringify(approveBody),
9579 });
9580 });
9581 if (approveOut && approveOut.approval_log_written === false) {
9582 showToast(
9583 approveOut.approval_log_error
9584 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
9585 : 'Approved, but approval log was not written. Check server logs and re-index.',
9586 true,
9587 );
9588 }
9589 hideDetailPanelChrome();
9590 hubMarkSemanticIndexStale();
9591 loadProposals();
9592 loadNotes();
9593 loadActivity();
9594 } catch (e) {
9595 const msg = e.message || String(e);
9596 showToast('Approve failed: ' + msg, true);
9597 }
9598 }
9599
9600 async function discardProposal(id, panel, btn) {
9601 try {
9602 await withButtonBusy(btn, 'Discarding…', async () => {
9603 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
9604 });
9605 hideDetailPanelChrome();
9606 loadProposals();
9607 loadActivity();
9608 } catch (e) {
9609 const msg = e.message || String(e);
9610 showToast('Discard failed: ' + msg, true);
9611 }
9612 }
9613
9614 el('detail-close').onclick = () => closeDetailPanel();
9615 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
9616 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
9617 (function wireDetailPanelFooterClose() {
9618 const panel = el('detail-panel');
9619 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
9620 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
9621 })();
9622
9623 // Resizable detail panel — drag the left edge to widen/narrow.
9624 (function initDetailPanelResize() {
9625 const panel = el('detail-panel');
9626 if (!panel) return;
9627 const handle = document.createElement('div');
9628 handle.className = 'detail-resize-handle';
9629 handle.title = 'Drag to resize panel';
9630 panel.prepend(handle);
9631 const MIN_W = 280;
9632 const MAX_W = Math.round(window.innerWidth * 0.92);
9633 let startX = 0, startW = 0, dragging = false;
9634 const onMove = (e) => {
9635 if (!dragging) return;
9636 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
9637 const delta = startX - clientX;
9638 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
9639 panel.style.width = newW + 'px';
9640 };
9641 const onUp = () => {
9642 if (!dragging) return;
9643 dragging = false;
9644 handle.classList.remove('dragging');
9645 document.removeEventListener('mousemove', onMove);
9646 document.removeEventListener('mouseup', onUp);
9647 document.removeEventListener('touchmove', onMove);
9648 document.removeEventListener('touchend', onUp);
9649 document.body.style.userSelect = '';
9650 };
9651 handle.addEventListener('mousedown', (e) => {
9652 e.preventDefault();
9653 dragging = true;
9654 startX = e.clientX;
9655 startW = panel.offsetWidth;
9656 handle.classList.add('dragging');
9657 document.body.style.userSelect = 'none';
9658 document.addEventListener('mousemove', onMove);
9659 document.addEventListener('mouseup', onUp);
9660 });
9661 handle.addEventListener('touchstart', (e) => {
9662 dragging = true;
9663 startX = e.touches[0].clientX;
9664 startW = panel.offsetWidth;
9665 handle.classList.add('dragging');
9666 document.addEventListener('touchmove', onMove, { passive: true });
9667 document.addEventListener('touchend', onUp);
9668 });
9669 })();
9670
9671 document.addEventListener('keydown', (e) => {
9672 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
9673 if (e.key === 'Escape') {
9674 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
9675 closeDetailPanel();
9676 e.preventDefault();
9677 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
9678 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
9679 closeHowToUse();
9680 e.preventDefault();
9681 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
9682 closeIntegGuideModal();
9683 e.preventDefault();
9684 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
9685 closeSettings();
9686 e.preventDefault();
9687 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
9688 closeProjectsHelpModal();
9689 e.preventDefault();
9690 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
9691 closeOnboardingWizardResume();
9692 e.preventDefault();
9693 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
9694 closeImportModal();
9695 e.preventDefault();
9696 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
9697 closeFullCreateSimilarModal();
9698 e.preventDefault();
9699 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
9700 closeCreateProposalModal();
9701 e.preventDefault();
9702 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
9703 closeCreateModal();
9704 e.preventDefault();
9705 } else if (el('search-key-help')?.open) {
9706 el('search-key-help').open = false;
9707 e.preventDefault();
9708 }
9709 return;
9710 }
9711 if (inInput && e.key !== 'Escape') return;
9712 if (e.key === '/') {
9713 searchQuery.focus();
9714 e.preventDefault();
9715 return;
9716 }
9717 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
9718 // run semantic search instead of opening the selected row (avoids "second search does nothing").
9719 if (e.key === 'Enter') {
9720 const q = (searchQuery.value || '').trim();
9721 if (q) {
9722 e.preventDefault();
9723 void runVaultSearch();
9724 return;
9725 }
9726 }
9727 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
9728 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
9729 const items = notesList.querySelectorAll('.list-item');
9730 if (notesTabActive && listViewVisible && items.length > 0) {
9731 if (e.key === 'j' || e.key === 'J') {
9732 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
9733 updateListSelection();
9734 e.preventDefault();
9735 } else if (e.key === 'k' || e.key === 'K') {
9736 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
9737 updateListSelection();
9738 e.preventDefault();
9739 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
9740 const node = items[listSelectedIndex];
9741 if (node.dataset.path) openNote(node.dataset.path);
9742 else if (node.dataset.id) openProposal(node.dataset.id);
9743 e.preventDefault();
9744 }
9745 }
9746 });
9747
9748 document.addEventListener('click', (e) => {
9749 const keyHelp = el('search-key-help');
9750 if (!keyHelp || !keyHelp.open) return;
9751 if (keyHelp.contains(e.target)) return;
9752 keyHelp.open = false;
9753 });
9754
9755 document.querySelectorAll('.tab').forEach((tab) => {
9756 tab.onclick = () => {
9757 switchHubMainTab(tab.dataset.tab);
9758 };
9759 });
9760 if (btnHeaderSuggested) {
9761 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
9762 }
9763
9764 function escapeHtml(s) {
9765 const div = document.createElement('div');
9766 div.textContent = s == null ? '' : String(s);
9767 return div.innerHTML;
9768 }
9769
9770 // ── Consolidation UI (Stream 2) ───────────────────────────────
9771
9772 function consolModeFromSettings(s) {
9773 if (!s || !s.daemon) return 'off';
9774 if (s.daemon.enabled) return 'daemon';
9775 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
9776 return 'off';
9777 }
9778
9779 function populateConsolSettingsForm(s) {
9780 if (!s || !s.daemon) return;
9781 const d = s.daemon;
9782 const mode = consolModeFromSettings(s);
9783 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
9784 applyConsolModeVisibility(mode);
9785 const iv = el('consol-interval');
9786 if (iv) iv.value = d.interval_minutes ?? 120;
9787 const idle = el('consol-idle-only');
9788 if (idle) idle.checked = d.idle_only !== false;
9789 const idleTh = el('consol-idle-threshold');
9790 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
9791 const ros = el('consol-run-on-start');
9792 if (ros) ros.checked = Boolean(d.run_on_start);
9793 const pc = el('pass-consolidate');
9794 if (pc) pc.checked = d.passes?.consolidate !== false;
9795 const pv = el('pass-verify');
9796 if (pv) pv.checked = d.passes?.verify !== false;
9797 const pd = el('pass-discover');
9798 if (pd) pd.checked = Boolean(d.passes?.discover);
9799 const lp = el('consol-llm-provider');
9800 if (lp) lp.value = d.llm?.provider || '';
9801 const lm = el('consol-llm-model');
9802 if (lm) lm.value = d.llm?.model || '';
9803 const lb = el('consol-llm-base-url');
9804 if (lb) lb.value = d.llm?.base_url || '';
9805 const lbh = el('consol-lookback-hours');
9806 if (lbh) lbh.value = d.lookback_hours ?? 24;
9807 const me = el('consol-max-events');
9808 if (me) me.value = d.max_events_per_pass ?? 200;
9809 const mt = el('consol-max-topics');
9810 if (mt) mt.value = d.max_topics_per_pass ?? 10;
9811 const lmt = el('consol-llm-max-tokens');
9812 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
9813 const cc = el('consol-cost-cap');
9814 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
9815 const chi = el('consol-hosted-interval');
9816 if (chi && d.interval_minutes != null) {
9817 const v = String(d.interval_minutes);
9818 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
9819 chi.value = allowed.includes(v) ? v : '120';
9820 }
9821 }
9822
9823 function buildConsolSettingsPayload() {
9824 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
9825 const mode = modeRadio ? modeRadio.value : 'off';
9826 const hostedSel = el('consol-hosted-interval');
9827 const intervalRaw =
9828 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
9829 const llm = {
9830 provider: el('consol-llm-provider')?.value || '',
9831 model: el('consol-llm-model')?.value || '',
9832 base_url: el('consol-llm-base-url')?.value || '',
9833 };
9834 if (mode === 'daemon') {
9835 llm.max_tokens = Math.max(
9836 64,
9837 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
9838 );
9839 }
9840 const payload = {
9841 mode,
9842 enabled: mode === 'daemon',
9843 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
9844 idle_only: Boolean(el('consol-idle-only')?.checked),
9845 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
9846 run_on_start: Boolean(el('consol-run-on-start')?.checked),
9847 passes: {
9848 consolidate: Boolean(el('pass-consolidate')?.checked),
9849 verify: Boolean(el('pass-verify')?.checked),
9850 discover: Boolean(el('pass-discover')?.checked),
9851 },
9852 llm,
9853 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
9854 };
9855 if (mode === 'daemon') {
9856 payload.lookback_hours = Math.max(
9857 1,
9858 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
9859 );
9860 payload.max_events_per_pass = Math.max(
9861 1,
9862 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
9863 );
9864 payload.max_topics_per_pass = Math.max(
9865 1,
9866 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
9867 );
9868 }
9869 return payload;
9870 }
9871
9872 function applyConsolModeVisibility(mode) {
9873 const daemonSection = el('consol-daemon-settings');
9874 const hostedSection = el('consol-hosted-settings');
9875 const llmSection = el('consol-llm-settings');
9876 const costGuard = el('consol-cost-guard');
9877 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
9878 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
9879 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
9880 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
9881 }
9882
9883 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
9884 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
9885 });
9886
9887 let lastChatKeyAvailable = {};
9888
9889 function chatProviderKeyHintText(provider, keyAvail) {
9890 const ka = keyAvail || {};
9891 switch (provider) {
9892 case '':
9893 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
9894 case 'ollama':
9895 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
9896 case 'openrouter':
9897 return ka.openrouter
9898 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
9899 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
9900 case 'openai':
9901 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
9902 case 'anthropic':
9903 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
9904 case 'deepinfra':
9905 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
9906 default:
9907 return '';
9908 }
9909 }
9910
9911 function applyChatProviderSettings(s) {
9912 const chat = (s && s.chat) || {};
9913 const sel = el('chat-provider-select');
9914 const keyHint = el('chat-provider-key-hint');
9915 const envHint = el('chat-provider-env-hint');
9916 const adminHint = el('chat-provider-admin-hint');
9917 const saveBtn = el('btn-chat-provider-save');
9918 const msg = el('chat-provider-msg');
9919 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
9920 if (!sel) return;
9921 lastChatKeyAvailable = chat.key_available || {};
9922 const isAdmin = String(s && s.role) === 'admin';
9923 const envLocked = Boolean(chat.env_locked);
9924 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
9925 sel.disabled = envLocked || !isAdmin;
9926 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
9927 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
9928 if (envHint) {
9929 if (envLocked) {
9930 envHint.textContent =
9931 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
9932 envHint.classList.remove('hidden');
9933 } else {
9934 envHint.classList.add('hidden');
9935 }
9936 }
9937 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
9938
9939 if (!sel.dataset.knowtationBound) {
9940 sel.dataset.knowtationBound = '1';
9941 sel.addEventListener('change', () => {
9942 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
9943 });
9944 }
9945 const btn = el('btn-chat-provider-save');
9946 if (btn && !btn.dataset.knowtationBound) {
9947 btn.dataset.knowtationBound = '1';
9948 btn.addEventListener('click', async () => {
9949 const m = el('chat-provider-msg');
9950 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
9951 try {
9952 const res = await api('/api/v1/settings/chat', {
9953 method: 'POST',
9954 body: JSON.stringify({ provider: sel.value }),
9955 });
9956 if (res && res.chat) sel.value = res.chat.provider || '';
9957 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
9958 } catch (e) {
9959 if (m) {
9960 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
9961 m.className = 'settings-msg err';
9962 }
9963 }
9964 });
9965 }
9966 }
9967
9968 async function loadConsolidationSettings() {
9969 const msg = el('consol-save-status');
9970 if (msg) msg.textContent = '';
9971 try {
9972 const s = await api('/api/v1/settings');
9973 populateConsolSettingsForm(s);
9974 } catch (e) {
9975 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
9976 }
9977 }
9978
9979 const btnConsolSave = el('btn-consol-save');
9980 if (btnConsolSave) {
9981 btnConsolSave.addEventListener('click', async () => {
9982 const msg = el('consol-save-status');
9983 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
9984 const payload = buildConsolSettingsPayload();
9985 if (payload.enabled && payload.interval_minutes < 30) {
9986 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
9987 return;
9988 }
9989 setButtonBusy(btnConsolSave, true, 'Saving…');
9990 try {
9991 await api('/api/v1/settings/consolidation', {
9992 method: 'POST',
9993 body: JSON.stringify(payload),
9994 });
9995 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
9996 } catch (e) {
9997 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
9998 }
9999 setButtonBusy(btnConsolSave, false);
10000 });
10001 }
10002
10003 const linkConsolHelp = el('link-consol-help');
10004 if (linkConsolHelp) {
10005 linkConsolHelp.addEventListener('click', (e) => {
10006 e.preventDefault();
10007 closeSettings();
10008 openHowToUse('consolidation');
10009 });
10010 }
10011
10012 // ── Consolidation Dashboard Card ──────────────────────────────
10013
10014 function formatCostMeter(costUsd, capUsd) {
10015 const cost = Math.max(0, Number(costUsd) || 0);
10016 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
10017 const display = '$' + cost.toFixed(3) + ' today';
10018 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
10019 const pct = Math.min(100, (cost / cap) * 100);
10020 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
10021 }
10022
10023 function renderConsolidationHistory(events, container) {
10024 if (!container) return;
10025 if (!events || events.length === 0) {
10026 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
10027 return;
10028 }
10029 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>';
10030 events.forEach((ev) => {
10031 const ts = ev.ts || ev.timestamp || ev.created_at;
10032 const date = ts ? new Date(ts).toLocaleString() : '—';
10033 const rawTopics = ev.data?.topics_count;
10034 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
10035 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
10036 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
10037 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
10038 });
10039 html += '</tbody></table>';
10040 container.innerHTML = html;
10041 }
10042
10043 async function refreshConsolidationCard() {
10044 const card = el('consolidation-card');
10045 const badge = el('consol-status-badge');
10046 const lastPass = el('consol-last-pass');
10047 const nextPass = el('consol-next-pass');
10048 const quotaMeter = el('consol-quota-meter');
10049 const quotaLabel = el('consol-quota-label');
10050 const quotaFill = el('consol-quota-fill');
10051 const btnNow = el('btn-consol-now');
10052 if (!card) return;
10053
10054 try {
10055 const s = await api('/api/v1/settings');
10056 const mode = consolModeFromSettings(s);
10057 if (mode === 'off') {
10058 card.style.display = 'none';
10059 return;
10060 }
10061 card.style.display = '';
10062
10063 if (mode === 'hosted') {
10064 try {
10065 const st = await api('/api/v1/memory/consolidate/status');
10066 if (badge) {
10067 badge.textContent = '● Active (hosted)';
10068 badge.className = 'consol-badge consol-badge-success';
10069 }
10070 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
10071 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
10072
10073 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
10074 const passUsed = st.pass_count_month ?? 0;
10075 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10076 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10077 if (quotaMeter) {
10078 if (passLimit === null) {
10079 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
10080 if (quotaFill) quotaFill.style.width = '0%';
10081 } else if (passLimit > 0) {
10082 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
10083 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
10084 if (quotaFill) quotaFill.style.width = pct + '%';
10085 }
10086 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
10087 }
10088
10089 // Disable "Consolidate Now" during cooldown; show time remaining.
10090 const cooldown = st.cooldown_minutes ?? 0;
10091 if (btnNow && cooldown > 0) {
10092 btnNow.disabled = true;
10093 btnNow.textContent = 'Available in ' + cooldown + ' min';
10094 } else if (btnNow) {
10095 btnNow.disabled = false;
10096 btnNow.textContent = 'Consolidate Now';
10097 }
10098 } catch (_) {
10099 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
10100 }
10101 } else {
10102 if (badge) {
10103 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
10104 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
10105 }
10106 if (lastPass) lastPass.textContent = 'Last pass: —';
10107 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
10108 if (quotaMeter) quotaMeter.style.display = 'none';
10109 }
10110 } catch (_) {
10111 card.style.display = 'none';
10112 }
10113 }
10114
10115 const btnConsolNow = el('btn-consol-now');
10116 if (btnConsolNow) {
10117 btnConsolNow.addEventListener('click', async () => {
10118 setButtonBusy(btnConsolNow, true, 'Previewing…');
10119 try {
10120 const preview = await api('/api/v1/memory/consolidate', {
10121 method: 'POST',
10122 body: JSON.stringify({ dry_run: true }),
10123 });
10124 setButtonBusy(btnConsolNow, false);
10125 const topicsRaw = preview.topics;
10126 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
10127 const events = preview.total_events ?? 0;
10128 // Fetch current quota to show remaining passes in the preview dialog.
10129 let quotaLine = '';
10130 try {
10131 const st = await api('/api/v1/memory/consolidate/status');
10132 const passUsed = st.pass_count_month ?? 0;
10133 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10134 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10135 if (passLimit === null) {
10136 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
10137 } else if (passLimit > 0) {
10138 const remaining = Math.max(0, passLimit - passUsed);
10139 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
10140 }
10141 } catch (_) {}
10142 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
10143 if (!ok) return;
10144 setButtonBusy(btnConsolNow, true, 'Consolidating…');
10145 await api('/api/v1/memory/consolidate', {
10146 method: 'POST',
10147 body: JSON.stringify({ dry_run: false }),
10148 });
10149 if (typeof showToast === 'function') showToast('Consolidation complete.');
10150 refreshConsolidationCard();
10151 } catch (e) {
10152 const msg = e?.message || 'Consolidation failed';
10153 if (typeof showToast === 'function') showToast(msg, true);
10154 // Re-check cooldown after a rate-limit response so the button state updates.
10155 refreshConsolidationCard();
10156 }
10157 setButtonBusy(btnConsolNow, false);
10158 });
10159 }
10160
10161 const btnConsolHistory = el('btn-consol-history');
10162 if (btnConsolHistory) {
10163 btnConsolHistory.addEventListener('click', async () => {
10164 setButtonBusy(btnConsolHistory, true, 'Loading…');
10165 try {
10166 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
10167 const events = res.events || res.history || [];
10168 setButtonBusy(btnConsolHistory, false);
10169 const modal = document.createElement('div');
10170 modal.className = 'modal';
10171 modal.setAttribute('aria-modal', 'true');
10172 modal.innerHTML =
10173 '<div class="modal-backdrop"></div>' +
10174 '<div class="modal-card consol-history-modal">' +
10175 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
10176 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
10177 document.body.appendChild(modal);
10178 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
10179 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
10180 modal.querySelector('.modal-close').onclick = () => modal.remove();
10181 } catch (e) {
10182 setButtonBusy(btnConsolHistory, false);
10183 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
10184 }
10185 });
10186 }
10187
10188 function openSettingsConsolidationTab() {
10189 openSettings();
10190 document.querySelectorAll('.settings-tab').forEach((t) => {
10191 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
10192 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
10193 });
10194 document.querySelectorAll('.settings-panel').forEach((p) => {
10195 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
10196 });
10197 loadConsolidationSettings();
10198 }
10199
10200 const btnConsolSettings = el('btn-consol-settings');
10201 if (btnConsolSettings) {
10202 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
10203 }
10204
10205 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
10206 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
10207 // Billing consolidation row is populated inline in loadBillingPanel's try block.
10208 // We add to the existing billing flow by hooking the billing API response.
10209
10210 // Refresh consolidation card when dashboard renders
10211 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
10212 })();
File History 6 commits
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 3 hours ago
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor 7 days ago
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2 feat(auth): persistent login system + C7 session introspection Human minor 7 days ago
sha256:e62163e32009fea817c9bff5d48cc4022f79e142fa31c69c4985a4afd0c3620d Polish SectionSource sections panel readability Human minor 15 days ago
sha256:c2caa9a7e90ed1f2110dbb0f0e09ff7b311cf0b640c707693e2dbf30144ce1d9 Add SectionSource Hub UI runtime Human minor 16 days ago
sha256:aeff9d7c077329f4ec5e6617ecf10b1fcb7d08b110a3018b315ef974f8e01cb4 fix(hub): restore note detail actions for viewer and evaluator Human minor 27 days ago