hub.js file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
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 cons
File truncated at 200 KB — view full file ↗