hub.js javascript
11,621 lines 466.2 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 1 day ago
1 /**
2 * Knowtation Hub UI — list, calendar, overview, quick add, presets. Phase 11C.
3 */
4
5 (function () {
6 const params = new URLSearchParams(location.search);
7 // Build-time or deployment config: set window.HUB_API_BASE_URL (e.g. from config.js). Empty string = same origin (when static host proxies /api to the gateway).
8 const apiBase = (function resolveApiBase() {
9 if (typeof window === 'undefined') return 'http://localhost:3333';
10 const paramApi = params.get('api');
11 if (paramApi != null && String(paramApi).trim()) {
12 return String(paramApi).trim().replace(/\/$/, '');
13 }
14 const hostname = location.hostname || '';
15 const isLocalDev =
16 hostname === 'localhost' ||
17 hostname === '127.0.0.1' ||
18 hostname === '[::1]' ||
19 hostname === '::1';
20 // Self-hosted dev: always call the same origin as the page (npm run hub). Stale localStorage
21 // hub_api_url often points at a hosted gateway and causes HTML 404 for Node-only routes.
22 if (isLocalDev) {
23 return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
24 }
25 if (Object.prototype.hasOwnProperty.call(window, 'HUB_API_BASE_URL')) {
26 const v = window.HUB_API_BASE_URL;
27 if (v == null) {
28 return (
29 localStorage.getItem('hub_api_url') ||
30 location.origin ||
31 'http://localhost:3333'
32 ).replace(/\/$/, '');
33 }
34 const s = String(v).trim();
35 if (s === '') return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
36 return s.replace(/\/$/, '');
37 }
38 return (localStorage.getItem('hub_api_url') || location.origin || 'http://localhost:3333').replace(/\/$/, '');
39 })();
40 /** Public MCP endpoint (https://…/mcp) when operator sets window.HUB_MCP_PUBLIC_URL in web/hub/config.js; else ''. */
41 const mcpPublicUrl = (function resolveMcpPublicUrl() {
42 if (typeof window === 'undefined') return '';
43 if (!Object.prototype.hasOwnProperty.call(window, 'HUB_MCP_PUBLIC_URL')) return '';
44 const v = window.HUB_MCP_PUBLIC_URL;
45 if (v == null) return '';
46 const s = String(v).trim();
47 if (s === '') return '';
48 return s.replace(/\/$/, '');
49 })();
50 /** Canonical doc: where Hub token, REST, remote MCP, and local CLI differ (copy blocks point here). */
51 const INTEGRATION_DOC_URL = 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md';
52 const hashParams = new URLSearchParams(location.hash.replace(/^#/, ''));
53 /** Used to defer onboarding until invite consume has run (see scheduleMaybeShowOnboardingWizard). */
54 const pageLoadHadInviteQuery = Boolean(params.get('invite'));
55 let token = hashParams.get('token') || params.get('token') || localStorage.getItem('hub_token');
56 const freshLoginFromOAuth = Boolean(hashParams.get('token') || params.get('token'));
57 if (token) {
58 localStorage.setItem('hub_token', token);
59 if (hashParams.has('token')) {
60 history.replaceState({}, '', location.pathname + location.search);
61 } else if (params.has('token')) {
62 const u = new URL(location.href);
63 u.searchParams.delete('token');
64 history.replaceState({}, '', u.toString());
65 }
66 }
67
68 /** After OAuth, mint HttpOnly refresh cookie when redirect Set-Cookie is missing (Netlify). */
69 const SESSION_DURABILITY_WARN_KEY = 'hub_session_durability_warn';
70 const SESSION_DURABILITY_WARN_COPY =
71 'This session could not be made durable; sign in again before it expires.';
72 let _sessionDurabilityUiReady = false;
73
74 function showSessionDurabilityBanner(show) {
75 const banner = document.getElementById('hub-session-durability-banner');
76 if (!banner) return;
77 if (show) {
78 banner.textContent = SESSION_DURABILITY_WARN_COPY;
79 banner.classList.remove('hidden');
80 } else {
81 banner.textContent = '';
82 banner.classList.add('hidden');
83 }
84 }
85
86 function persistSessionDurabilityWarning(on) {
87 try {
88 if (on) sessionStorage.setItem(SESSION_DURABILITY_WARN_KEY, '1');
89 else sessionStorage.removeItem(SESSION_DURABILITY_WARN_KEY);
90 } catch (_) {}
91 if (_sessionDurabilityUiReady) showSessionDurabilityBanner(on);
92 }
93
94 function restoreSessionDurabilityWarning() {
95 let on = false;
96 try {
97 on = sessionStorage.getItem(SESSION_DURABILITY_WARN_KEY) === '1';
98 } catch (_) {
99 on = false;
100 }
101 showSessionDurabilityBanner(on);
102 }
103
104 /**
105 * @param {string} accessToken
106 * @returns {Promise<
107 * | { ok: true; established: true }
108 * | { ok: false; code: 'missing_access'|'unauthorized'|'session_establish_denied'|'session_store_unavailable'|'network'|'malformed' }
109 * >}
110 */
111 async function establishPersistentSession(accessToken) {
112 if (!accessToken) return { ok: false, code: 'missing_access' };
113 const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
114 const timer =
115 ctrl && typeof setTimeout === 'function'
116 ? setTimeout(function () {
117 try {
118 ctrl.abort();
119 } catch (_) {}
120 }, 10000)
121 : null;
122 try {
123 const res = await fetch(apiBase + '/api/v1/auth/establish-refresh', {
124 method: 'POST',
125 credentials: 'include',
126 cache: 'no-store',
127 signal: ctrl ? ctrl.signal : undefined,
128 headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + accessToken },
129 });
130 if (res.status === 401 || res.status === 403) {
131 token = null;
132 try {
133 localStorage.removeItem('hub_token');
134 } catch (_) {}
135 persistSessionDurabilityWarning(false);
136 const appEl = document.getElementById('app');
137 const mainEl = document.getElementById('main');
138 const loginEl = document.getElementById('login-required');
139 if (appEl) appEl.classList.add('login-screen');
140 if (mainEl) mainEl.classList.add('hidden');
141 if (loginEl) loginEl.classList.remove('hidden');
142 return {
143 ok: false,
144 code: res.status === 403 ? 'session_establish_denied' : 'unauthorized',
145 };
146 }
147 if (res.status === 503) {
148 persistSessionDurabilityWarning(true);
149 return { ok: false, code: 'session_store_unavailable' };
150 }
151 if (!res.ok) {
152 persistSessionDurabilityWarning(true);
153 return { ok: false, code: 'malformed' };
154 }
155 let data = null;
156 try {
157 data = await res.json();
158 } catch (_) {
159 persistSessionDurabilityWarning(true);
160 return { ok: false, code: 'malformed' };
161 }
162 if (
163 !data ||
164 data.schema_version !== 1 ||
165 data.established !== true ||
166 Object.prototype.hasOwnProperty.call(data, 'refresh_token')
167 ) {
168 persistSessionDurabilityWarning(true);
169 return { ok: false, code: 'malformed' };
170 }
171 persistSessionDurabilityWarning(false);
172 return { ok: true, established: true };
173 } catch (_) {
174 persistSessionDurabilityWarning(true);
175 return { ok: false, code: 'network' };
176 } finally {
177 if (timer) clearTimeout(timer);
178 }
179 }
180 if (freshLoginFromOAuth && token) {
181 establishPersistentSession(token);
182 }
183
184 /** Latest GET /api/v1/settings used for Backup tab (hosted repo field + sync body). */
185 let lastBackupSettingsPayload = null;
186
187 const PRESETS_KEY = 'hub_view_presets';
188 const el = (id) => document.getElementById(id);
189 _sessionDurabilityUiReady = true;
190 restoreSessionDurabilityWarning();
191 const app = el('app');
192 const main = el('main');
193 const loginRequired = el('login-required');
194 const btnLoginGoogle = el('btn-login-google');
195 const btnLoginGithub = el('btn-login-github');
196 const btnLogout = el('btn-logout');
197 const btnNewNote = el('btn-new-note');
198 const btnImport = el('btn-import');
199 const btnHeaderSuggested = el('btn-header-suggested');
200 const btnHowToUse = el('btn-how-to-use');
201 const btnSettings = el('btn-settings');
202 const browseToolbar = el('browse-toolbar');
203 /** @type {number} last unfiltered proposed count for badge pulse */
204 let hubReviewBadgePrevCount = 0;
205 let hubNeedsYouDismissed = false;
206 /** Keyboard selection index for Review / History proposal lists */
207 let proposalListSelectedIndex = 0;
208 /** @type {string[]} proposal ids in the active Review/History list for N-of-M */
209 let proposalListIds = [];
210 try {
211 hubNeedsYouDismissed = sessionStorage.getItem('hub_needs_you_dismissed') === '1';
212 } catch (_) {
213 hubNeedsYouDismissed = false;
214 }
215
216 function hubShellIa() {
217 return globalThis.HubShellIa || null;
218 }
219
220 function getActiveHubMainTab() {
221 const t = document.querySelector('[data-tab].tab.active');
222 return (t && t.dataset.tab) || 'notes';
223 }
224
225 function getActiveNotesView() {
226 const graph = el('notes-view-graph');
227 if (graph && !graph.classList.contains('hidden')) return 'graph';
228 const cal = el('notes-view-calendar');
229 if (cal && !cal.classList.contains('hidden')) return 'calendar';
230 return 'list';
231 }
232
233 function syncVaultAdvancedFiltersOpen() {
234 const details = el('hub-search-advanced');
235 if (!details) return;
236 const SI = hubShellIa();
237 const active = typeof hasActiveNoteListFilters === 'function' ? hasActiveNoteListFilters() : false;
238 const expand =
239 SI && typeof SI.shouldExpandVaultAdvancedFilters === 'function'
240 ? SI.shouldExpandVaultAdvancedFilters(active, details.open)
241 : active || details.open;
242 if (expand) details.open = true;
243 }
244
245 function syncPendingEvalQuickChip() {
246 const chip = el('proposal-pending-eval-chip');
247 if (!chip) return;
248 const SI = hubShellIa();
249 const show =
250 SI && typeof SI.shouldShowPendingEvalQuickChip === 'function'
251 ? SI.shouldShowPendingEvalQuickChip(window.__hubProposalEvaluationRequired)
252 : Boolean(window.__hubProposalEvaluationRequired);
253 const onSuggested = getActiveHubMainTab() === 'suggested';
254 chip.classList.toggle('hidden', !(show && onSuggested));
255 const pe = el('proposal-filter-pending-eval');
256 const pressed = Boolean(pe && pe.checked);
257 chip.setAttribute('aria-pressed', pressed ? 'true' : 'false');
258 }
259
260 function syncModeToolbars(activeTab) {
261 const name = activeTab || getActiveHubMainTab();
262 const view = getActiveNotesView();
263 const SI = hubShellIa();
264 const chrome =
265 SI && typeof SI.hubChromeVisibility === 'function'
266 ? SI.hubChromeVisibility(name, view)
267 : {
268 noteSearch: name === 'notes' && view !== 'graph',
269 browseToolbar: name === 'notes' && view !== 'graph',
270 proposalFilters: name === 'suggested' || name === 'activity' || name === 'problem',
271 insights: name === 'notes' && view === 'graph',
272 };
273 const searchSec = el('hub-search-section') || document.querySelector('.search-section');
274 if (searchSec) searchSec.classList.toggle('hidden', !chrome.noteSearch);
275 if (browseToolbar) browseToolbar.classList.toggle('hidden', !chrome.browseToolbar);
276 setProposalFiltersBarVisible(chrome.proposalFilters);
277 if (chrome.noteSearch) syncVaultAdvancedFiltersOpen();
278 syncPendingEvalQuickChip();
279 }
280
281 function setReviewSplitPosition(index1Based, total) {
282 const posEl = el('detail-split-position');
283 const listPos = el('review-list-position');
284 const SI = hubShellIa();
285 const text =
286 SI && typeof SI.formatReviewSplitPosition === 'function'
287 ? SI.formatReviewSplitPosition(index1Based, total)
288 : index1Based > 0 && total > 0
289 ? index1Based + ' of ' + total
290 : '';
291 [posEl, listPos].forEach((node) => {
292 if (!node) return;
293 if (!text) {
294 node.textContent = '';
295 node.classList.add('hidden');
296 } else {
297 node.textContent = text;
298 node.classList.remove('hidden');
299 }
300 });
301 }
302
303 function clearReviewSplitPosition() {
304 setReviewSplitPosition(0, 0);
305 }
306
307 function updateProposalListSelection(container) {
308 if (!container) return;
309 const items = container.querySelectorAll('.list-item[data-id]');
310 if (items.length === 0) {
311 proposalListSelectedIndex = 0;
312 return;
313 }
314 const SI = hubShellIa();
315 proposalListSelectedIndex =
316 SI && typeof SI.clampListKeyboardIndex === 'function'
317 ? SI.clampListKeyboardIndex(proposalListSelectedIndex, items.length)
318 : Math.max(0, Math.min(proposalListSelectedIndex, items.length - 1));
319 items.forEach((item, i) => {
320 item.classList.toggle('selected', i === proposalListSelectedIndex);
321 if (i === proposalListSelectedIndex) item.setAttribute('tabindex', '0');
322 else item.removeAttribute('tabindex');
323 });
324 const sel = items[proposalListSelectedIndex];
325 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
326 }
327
328 function getActiveProposalListContainer() {
329 const tab = getActiveHubMainTab();
330 if (tab === 'suggested') return el('proposals-suggested');
331 if (tab === 'problem') return el('proposals-problem');
332 if (tab === 'activity') return el('proposals-activity');
333 return null;
334 }
335
336 function syncHubRailChrome(activeTab) {
337 const name = activeTab || getActiveHubMainTab();
338 const historyMode = name === 'activity' || name === 'problem';
339 const histBtn = el('hub-rail-history');
340 if (histBtn) histBtn.classList.toggle('active', historyMode);
341 const bottomHist = el('hub-bottom-history');
342 if (bottomHist) bottomHist.classList.toggle('active', historyMode);
343 const segments = el('history-segments');
344 if (segments) segments.classList.toggle('hidden', !historyMode);
345 document.querySelectorAll('.history-segment').forEach((btn) => {
346 btn.classList.toggle('active', btn.dataset.tab === name);
347 btn.setAttribute('aria-selected', btn.dataset.tab === name ? 'true' : 'false');
348 });
349 const insights = el('hub-rail-insights');
350 if (insights) {
351 const graphOn =
352 name === 'notes' && !el('notes-view-graph')?.classList.contains('hidden');
353 insights.classList.toggle('active', Boolean(graphOn));
354 }
355 const SI = hubShellIa();
356 if (historyMode && SI && typeof SI.writeHistorySegment === 'function') {
357 SI.writeHistorySegment(name === 'problem' ? 'problem' : 'activity', localStorage);
358 }
359 }
360
361 function setHubMoreSheetOpen(open) {
362 const sheet = el('hub-more-sheet');
363 const moreBtn = el('hub-bottom-more');
364 if (!sheet) return;
365 const show = Boolean(open);
366 sheet.classList.toggle('hidden', !show);
367 if (moreBtn) {
368 moreBtn.classList.toggle('active', show);
369 moreBtn.setAttribute('aria-expanded', show ? 'true' : 'false');
370 }
371 }
372
373 function closeHubMoreSheet() {
374 setHubMoreSheetOpen(false);
375 }
376
377 function openHubMoreSheet() {
378 setHubMoreSheetOpen(true);
379 }
380
381 function runHubSecondaryAction(action) {
382 const key = String(action || '');
383 if (key === 'insights') {
384 switchHubMainTab('notes');
385 switchNotesView('graph');
386 return;
387 }
388 if (key === 'import') {
389 if (typeof openImportModal === 'function') openImportModal();
390 else if (btnImport) btnImport.click();
391 return;
392 }
393 if (key === 'connect') {
394 openSettingsIntegrationsTab();
395 return;
396 }
397 if (key === 'settings') {
398 openSettings();
399 return;
400 }
401 if (key === 'help') {
402 if (typeof openHowToUse === 'function') openHowToUse();
403 else if (btnHowToUse) btnHowToUse.click();
404 }
405 }
406
407 function applyReviewBadgeCount(rawCount) {
408 const SI = hubShellIa();
409 const next = SI && typeof SI.clampProposedBadgeCount === 'function'
410 ? SI.clampProposedBadgeCount(rawCount)
411 : Math.max(0, Math.min(100, Math.floor(Number(rawCount) || 0)));
412 const text =
413 SI && typeof SI.formatProposedBadgeText === 'function'
414 ? SI.formatProposedBadgeText(next)
415 : next > 0
416 ? String(next)
417 : '';
418 const pulse =
419 SI && typeof SI.shouldPulseReviewBadge === 'function'
420 ? SI.shouldPulseReviewBadge(hubReviewBadgePrevCount, next)
421 : next > hubReviewBadgePrevCount;
422 ['hub-review-badge', 'hub-header-review-badge', 'hub-bottom-review-badge'].forEach((id) => {
423 const badge = el(id);
424 if (!badge) return;
425 if (!text) {
426 badge.textContent = '';
427 badge.classList.add('hidden');
428 badge.classList.remove('hub-rail-badge-pulse');
429 return;
430 }
431 badge.textContent = text;
432 badge.classList.remove('hidden');
433 if (pulse) {
434 badge.classList.remove('hub-rail-badge-pulse');
435 void badge.offsetWidth;
436 badge.classList.add('hub-rail-badge-pulse');
437 }
438 });
439 hubReviewBadgePrevCount = next;
440 updateNeedsYouBanner(next);
441 }
442
443 function updateNeedsYouBanner(proposedCount) {
444 const banner = el('hub-needs-you-banner');
445 const textEl = el('hub-needs-you-text');
446 if (!banner) return;
447 const SI = hubShellIa();
448 const show =
449 SI && typeof SI.shouldShowNeedsYouBanner === 'function'
450 ? SI.shouldShowNeedsYouBanner(proposedCount, hubNeedsYouDismissed)
451 : proposedCount > 0 && !hubNeedsYouDismissed;
452 const onVault = getActiveHubMainTab() === 'notes';
453 banner.classList.toggle('hidden', !(show && onVault));
454 if (textEl && SI && typeof SI.needsYouBannerCopy === 'function') {
455 textEl.textContent = SI.needsYouBannerCopy(proposedCount);
456 } else if (textEl) {
457 textEl.textContent =
458 proposedCount +
459 (proposedCount === 1 ? ' proposal' : ' proposals') +
460 ' waiting in Review';
461 }
462 }
463
464 async function refreshReviewBadge() {
465 if (!token) {
466 applyReviewBadgeCount(0);
467 return;
468 }
469 try {
470 const out = await api('/api/v1/proposals?status=proposed&limit=100');
471 applyReviewBadgeCount((out && out.proposals ? out.proposals.length : 0) || 0);
472 } catch (_) {
473 /* keep last badge; fail closed without wiping */
474 }
475 }
476
477 function openHistoryMode(preferredSegment) {
478 const SI = hubShellIa();
479 const seg =
480 preferredSegment ||
481 (SI && typeof SI.readHistorySegment === 'function'
482 ? SI.readHistorySegment(localStorage)
483 : 'activity');
484 switchHubMainTab(seg === 'problem' ? 'problem' : 'activity');
485 }
486 const userName = el('user-name');
487 const oauthNotConfigured = el('oauth-not-configured');
488 const loginIntro = el('login-intro');
489 const searchQuery = el('search-query');
490 const filterProject = el('filter-project');
491 const filterTag = el('filter-tag');
492 const filterFolder = el('filter-folder');
493 const filterSince = el('filter-since');
494 const filterUntil = el('filter-until');
495 const filterContentScope = el('filter-content-scope');
496 const filterContentClass = el('filter-content-class');
497 const filterNetwork = el('filter-network');
498 const filterWallet = el('filter-wallet');
499 const searchMode = el('search-mode');
500 const btnSearch = el('btn-search');
501 const btnClearSearch = el('btn-clear-search');
502 const btnApplyFilters = el('btn-apply-filters');
503 const btnReindex = el('btn-reindex');
504 const notesList = el('notes-list');
505 const notesTotal = el('notes-total');
506 /** True when the last unfiltered browse list (loadNotes, no list filters) returned zero notes. */
507 let hubBrowseListEmptyUnfiltered = false;
508 /** Last facets from {@link fetchFacetsResolved} (Hub create panel project pickers + similarity guard). */
509 let lastHubFacets = null;
510 /** Latest `/api/v1/vault/folders` list for subfolder derivation under `projects/<slug>/`. */
511 let lastVaultFoldersForCreate = [];
512 /** After “Keep my path” on similar-project modal, allow one create without re-prompting. */
513 let fullCreateSimilarOverrideOnce = false;
514 let fullCreateSimilarModalSuggestedSlug = '';
515 let fullCreateSimilarModalPendingPath = '';
516 let fullPathSimilarDebounceTimer = 0;
517 const filterChipsEl = el('filter-chips');
518 const presetsListEl = el('presets-list');
519 const presetNameInput = el('preset-name');
520 const hubBetaNote = el('hub-beta-note');
521 if (hubBetaNote && window.location.hostname !== 'knowtation.store' && window.location.hostname !== 'www.knowtation.store') hubBetaNote.classList.add('hidden');
522
523 let providers = null;
524 let calendarMonth = new Date();
525 let currentNotePathForCopy = '';
526 /** @type {{ path: string, body: string, frontmatter: Record<string, string> } | null} */
527 let currentOpenNote = null;
528 /** Increments when the SectionSource panel is reset so stale body-free reads do not render. */
529 let hubSectionSourceSeq = 0;
530 /** When set, full-create save may delete this path after posting the duplicate (optional checkbox). */
531 /** @type {{ path: string } | null} */
532 let pendingDuplicateDeleteSource = null;
533 /** AbortController for window resize while note edit body layout is active. */
534 let detailEditBodyLayoutAbort = null;
535
536 /** Hide the detail drawer (does not clear currentOpenNote). */
537 function hideDetailPanelChrome() {
538 const dp = el('detail-panel');
539 if (dp) {
540 dp.classList.add('hidden');
541 dp.classList.remove('detail-panel-proposal-wide');
542 }
543 clearReviewSplitPosition();
544 }
545
546 /** User dismisses the drawer (Escape, Close): clear open-note state. */
547 function closeDetailPanel() {
548 currentOpenNote = null;
549 currentNotePathForCopy = '';
550 resetDetailSectionSourceState();
551 teardownDetailEditBodyLayout();
552 hideDetailPanelChrome();
553 const bcbClose = el('btn-detail-copy-body');
554 if (bcbClose) bcbClose.classList.add('hidden');
555 const bcp = el('btn-copy-path');
556 if (bcp) bcp.classList.add('hidden');
557 }
558
559 let listSelectedIndex = 0;
560 /** Increments on each `openNote` call so stale fetch completions do not append duplicate actions or overwrite UI. */
561 let hubOpenNoteSeq = 0;
562 /** @type {import('chart.js').Chart[]} */
563 let chartInstances = [];
564
565 const FILTER_CHIPS_EXPANDED_KEY = 'hub_filter_chips_expanded';
566 let filterChipsExpanded = false;
567 try {
568 filterChipsExpanded = localStorage.getItem(FILTER_CHIPS_EXPANDED_KEY) === '1';
569 } catch (_) {
570 filterChipsExpanded = false;
571 }
572
573 const ACCENT_STORAGE_KEY = 'hub_accent_color';
574 const THEME_STORAGE_KEY = 'hub_theme';
575 const COLOR_PALETTE_STORAGE_KEY = 'hub_color_palette';
576 const DEFAULT_ACCENT = '#89cff0';
577 const DEFAULT_THEME = 'dark';
578 const DEFAULT_COLOR_PALETTE = 'default';
579 const VALID_COLOR_PALETTES = new Set([
580 'default',
581 'ocean',
582 'forest',
583 'sunset',
584 'lavender',
585 'ember',
586 'arctic',
587 'slate',
588 'midnight',
589 'sakura',
590 'sand',
591 'mint',
592 ]);
593 const loadingHtml = '<div class="loading-state" aria-live="polite">Loading…</div>';
594 function applyAccent(hex) {
595 if (hex) {
596 document.documentElement.style.setProperty('--accent', hex);
597 try {
598 localStorage.setItem(ACCENT_STORAGE_KEY, hex);
599 } catch (_) {}
600 }
601 }
602 function applyTheme(theme) {
603 const value = theme === 'light' ? 'light' : 'dark';
604 document.documentElement.setAttribute('data-theme', value === 'dark' ? '' : value);
605 try {
606 localStorage.setItem(THEME_STORAGE_KEY, value);
607 } catch (_) {}
608 }
609 function applyColorPalette(id) {
610 const p =
611 id && VALID_COLOR_PALETTES.has(String(id)) ? String(id) : DEFAULT_COLOR_PALETTE;
612 if (p === DEFAULT_COLOR_PALETTE) {
613 document.documentElement.removeAttribute('data-palette');
614 } else {
615 document.documentElement.setAttribute('data-palette', p);
616 }
617 try {
618 localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, p);
619 } catch (_) {}
620 }
621 function currentColorPalette() {
622 const a = document.documentElement.getAttribute('data-palette');
623 if (a && VALID_COLOR_PALETTES.has(a) && a !== DEFAULT_COLOR_PALETTE) return a;
624 return DEFAULT_COLOR_PALETTE;
625 }
626 (function initThemeAndAccent() {
627 try {
628 const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
629 if (savedTheme === 'light') applyTheme('light');
630 const savedAccent = localStorage.getItem(ACCENT_STORAGE_KEY);
631 if (savedAccent) applyAccent(savedAccent);
632 const savedPalette = localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
633 if (savedPalette) applyColorPalette(savedPalette);
634 } catch (_) {}
635 })();
636
637 function headers() {
638 const h = { 'Content-Type': 'application/json' };
639 if (token) h['Authorization'] = 'Bearer ' + token;
640 const vid = getCurrentVaultId();
641 if (vid) h['X-Vault-Id'] = vid;
642 return h;
643 }
644
645 // Persistent sessions: when the short-lived access token expires, silently exchange the
646 // HttpOnly refresh cookie for a new one instead of dropping the user to the login screen.
647 // Single-flight so a burst of 401s triggers exactly one refresh.
648 let refreshInFlight = null;
649 async function refreshAccessToken() {
650 if (refreshInFlight) return refreshInFlight;
651 refreshInFlight = (async () => {
652 try {
653 const res = await fetch(apiBase + '/api/v1/auth/refresh', {
654 method: 'POST',
655 credentials: 'include', // send the HttpOnly refresh cookie
656 cache: 'no-store',
657 headers: { 'Content-Type': 'application/json' },
658 });
659 if (res.status === 401 || res.status === 403) {
660 return { ok: false, code: 'session_expired', status: res.status };
661 }
662 if (res.status === 503) {
663 return { ok: false, code: 'session_unavailable', status: 503 };
664 }
665 if (!res.ok) {
666 return { ok: false, code: 'session_unavailable', status: res.status };
667 }
668 let data = null;
669 try {
670 data = await res.json();
671 } catch (_) {
672 return { ok: false, code: 'malformed', status: 200 };
673 }
674 if (data && typeof data.access_token === 'string' && data.access_token) {
675 token = data.access_token;
676 try {
677 localStorage.setItem('hub_token', token);
678 } catch (_) {}
679 return { ok: true, token: data.access_token };
680 }
681 return { ok: false, code: 'malformed', status: 200 };
682 } catch (_) {
683 return { ok: false, code: 'session_unavailable', status: null };
684 }
685 })();
686 try {
687 return await refreshInFlight;
688 } finally {
689 refreshInFlight = null;
690 }
691 }
692
693 function forceHubLoginScreen() {
694 token = null;
695 try {
696 localStorage.removeItem('hub_token');
697 } catch (_) {}
698 persistSessionDurabilityWarning(false);
699 if (app) app.classList.add('login-screen');
700 if (main) main.classList.add('hidden');
701 if (loginRequired) loginRequired.classList.remove('hidden');
702 if (browseToolbar) browseToolbar.classList.add('hidden');
703 if (btnNewNote) btnNewNote.classList.add('hidden');
704 if (btnImport) btnImport.classList.add('hidden');
705 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
706 if (btnHowToUse) btnHowToUse.classList.add('hidden');
707 if (btnSettings) btnSettings.classList.add('hidden');
708 if (typeof showLoginChrome === 'function') showLoginChrome();
709 }
710
711 /**
712 * Decode local JWT claims only as a refresh scheduling hint — never as authorization.
713 * @param {string} jwt
714 * @returns {{ type?: string, iat?: number, exp?: number } | null}
715 */
716 function peekAccessClaims(jwt) {
717 if (!jwt || typeof jwt !== 'string') return null;
718 const parts = jwt.split('.');
719 if (parts.length < 2) return null;
720 try {
721 const json = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
722 const payload = JSON.parse(json);
723 if (!payload || typeof payload !== 'object') return null;
724 return payload;
725 } catch (_) {
726 return null;
727 }
728 }
729
730 /**
731 * @param {number} [minRemainingSeconds=120]
732 * @returns {Promise<
733 * | { ok: true; token: string }
734 * | { ok: false; code: 'session_expired'|'session_unavailable'|'malformed' }
735 * >}
736 */
737 async function ensureFreshHumanSession(minRemainingSeconds) {
738 const floor = typeof minRemainingSeconds === 'number' ? minRemainingSeconds : 120;
739 const current =
740 token ||
741 (typeof localStorage !== 'undefined' ? localStorage.getItem('hub_token') : null) ||
742 '';
743 const claims = peekAccessClaims(current);
744 const now = Math.floor(Date.now() / 1000);
745 const needsRefresh =
746 !current ||
747 !claims ||
748 claims.type !== 'session' ||
749 typeof claims.iat !== 'number' ||
750 typeof claims.exp !== 'number' ||
751 !Number.isFinite(claims.iat) ||
752 !Number.isFinite(claims.exp) ||
753 claims.exp - now <= floor;
754
755 if (!needsRefresh) {
756 token = current;
757 return { ok: true, token: current };
758 }
759
760 const refreshed = await refreshAccessToken();
761 if (refreshed && refreshed.ok && refreshed.token) {
762 return { ok: true, token: refreshed.token };
763 }
764 const code =
765 refreshed && refreshed.code === 'malformed'
766 ? 'malformed'
767 : refreshed && refreshed.code === 'session_unavailable'
768 ? 'session_unavailable'
769 : 'session_expired';
770 if (code === 'session_expired' || code === 'malformed') {
771 forceHubLoginScreen();
772 }
773 return { ok: false, code: code };
774 }
775
776 /**
777 * Fresh-session request helper for home/settings/copy/credentials/consent.
778 * Returns {status, ok, data} for typed UI mapping. Mutations never network-retry.
779 * @param {string} path
780 * @param {RequestInit & { noRetry?: boolean, _retriedAfterRefresh?: boolean }} [opts]
781 */
782 async function hubApiResponse(path, opts) {
783 opts = opts || {};
784 const method = (opts.method || 'GET').toUpperCase();
785 // Prefetch a fresh human session before protected calls (auth endpoints skip — never recurse).
786 if (
787 path !== '/api/v1/auth/refresh' &&
788 path !== '/api/v1/auth/logout' &&
789 path !== '/api/v1/auth/establish-refresh'
790 ) {
791 const fresh = await ensureFreshHumanSession(120);
792 if (!fresh.ok) {
793 return {
794 status: fresh.code === 'session_unavailable' ? 503 : 401,
795 ok: false,
796 data: { error: 'Session required', code: fresh.code },
797 sessionCode: fresh.code,
798 };
799 }
800 }
801 const maxNetworkRetries =
802 opts.noRetry === true ? 0 : method === 'GET' || method === 'HEAD' ? 2 : 0;
803 const { noRetry: _noRetry, _retriedAfterRefresh, ...fetchOpts } = opts;
804 let res;
805 let networkRetries = maxNetworkRetries;
806 for (;;) {
807 try {
808 res = await fetch(apiBase + path, {
809 ...fetchOpts,
810 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
811 headers: { ...headers(), ...fetchOpts.headers },
812 });
813 break;
814 } catch (e) {
815 const m = e && e.message ? String(e.message) : String(e);
816 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
817 networkRetries--;
818 await new Promise(function (resolve) {
819 setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000);
820 });
821 continue;
822 }
823 return {
824 status: 0,
825 ok: false,
826 data: { error: m, code: 'network' },
827 sessionCode: 'session_unavailable',
828 };
829 }
830 }
831 if (
832 res.status === 401 &&
833 path !== '/api/v1/auth/refresh' &&
834 path !== '/api/v1/auth/logout' &&
835 path !== '/api/v1/auth/establish-refresh' &&
836 !_retriedAfterRefresh
837 ) {
838 const refreshed = await refreshAccessToken();
839 if (refreshed && refreshed.ok) {
840 return hubApiResponse(path, { ...opts, _retriedAfterRefresh: true });
841 }
842 if (refreshed && refreshed.code === 'session_unavailable') {
843 return {
844 status: 503,
845 ok: false,
846 data: { error: 'Session unavailable', code: 'session_unavailable' },
847 sessionCode: 'session_unavailable',
848 };
849 }
850 forceHubLoginScreen();
851 return {
852 status: 401,
853 ok: false,
854 data: { error: 'Unauthorized', code: 'session_expired' },
855 sessionCode: 'session_expired',
856 };
857 }
858 let text = await res.text();
859 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
860 let data = null;
861 if (text) {
862 try {
863 data = JSON.parse(text);
864 } catch (_) {
865 data = { error: 'malformed', raw: text.slice(0, 200) };
866 }
867 }
868 return { status: res.status, ok: res.ok, data: data };
869 }
870
871 async function api(path, opts = {}) {
872 const method = (opts.method || 'GET').toUpperCase();
873 // GET/HEAD: retry up to 2×. POST/PATCH/PUT/DELETE: zero network retries (only 401→refresh→once).
874 // `opts.noRetry: true` opts out of retries entirely (e.g. POST /api/v1/index).
875 const maxNetworkRetries =
876 opts.noRetry === true ? 0 : method === 'GET' || method === 'HEAD' ? 2 : 0;
877 // Strip non-fetch keys before forwarding to fetch() so they don't pollute the request init.
878 const { noRetry: _noRetry, ...fetchOpts } = opts;
879 // Internal one-shot control flag for the 401 silent-refresh retry; never forward to fetch().
880 delete fetchOpts._retriedAfterRefresh;
881 // Prefetch a fresh human session before protected calls (auth endpoints skip).
882 if (
883 path !== '/api/v1/auth/refresh' &&
884 path !== '/api/v1/auth/logout' &&
885 path !== '/api/v1/auth/establish-refresh'
886 ) {
887 const fresh = await ensureFreshHumanSession(120);
888 if (!fresh.ok) {
889 if (fresh.code === 'session_unavailable') {
890 throw new Error('Session could not be refreshed. Retry.');
891 }
892 throw new Error('Unauthorized');
893 }
894 }
895 let res;
896 let networkRetries = maxNetworkRetries;
897 for (;;) {
898 try {
899 res = await fetch(apiBase + path, {
900 ...fetchOpts,
901 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
902 headers: { ...headers(), ...fetchOpts.headers },
903 });
904 break;
905 } catch (e) {
906 const m = e && e.message ? String(e.message) : String(e);
907 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
908 networkRetries--;
909 await new Promise(resolve => setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000));
910 continue;
911 }
912 if (m === 'Failed to fetch' || m.includes('NetworkError')) {
913 throw new Error(
914 'Could not reach the API (' +
915 apiBase +
916 '). Check gateway status, CORS (HUB_CORS_ORIGIN), ad blockers, and Netlify limits.',
917 );
918 }
919 throw e instanceof Error ? e : new Error(m);
920 }
921 }
922 if (res.status === 401) {
923 // Try a one-time silent refresh before forcing re-login. Never recurse on the auth
924 // endpoints themselves, and only retry once per original request.
925 if (
926 path !== '/api/v1/auth/refresh' &&
927 path !== '/api/v1/auth/logout' &&
928 path !== '/api/v1/auth/establish-refresh' &&
929 !opts._retriedAfterRefresh
930 ) {
931 const refreshed = await refreshAccessToken();
932 if (refreshed && refreshed.ok) {
933 return api(path, { ...opts, _retriedAfterRefresh: true });
934 }
935 if (refreshed && refreshed.code === 'session_unavailable') {
936 throw new Error('Session could not be refreshed. Retry.');
937 }
938 }
939 forceHubLoginScreen();
940 throw new Error('Unauthorized');
941 }
942 let text = await res.text();
943 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
944 let data;
945 try {
946 data = text ? JSON.parse(text) : null;
947 } catch (_) {
948 const t = text.trim();
949 if (/^<!DOCTYPE/i.test(t) || /<html/i.test(t)) {
950 throw new Error(
951 `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.`,
952 );
953 }
954 throw new Error(
955 'Response was not valid JSON (' +
956 res.status +
957 '). Start of body: ' +
958 t.slice(0, 120) +
959 (t.length > 120 ? '...' : ''),
960 );
961 }
962 if (!res.ok) {
963 const label = data?.error || res.statusText;
964 const detail = data?.message != null && String(data.message).trim() ? String(data.message).trim() : '';
965 const combined = detail ? `${label}: ${detail}` : label;
966 const err = new Error(combined);
967 if (data && data.code) err.code = data.code;
968 throw err;
969 }
970 return data;
971 }
972
973 /** Busy state for buttons during slow API calls (clear feedback on hosted). */
974 function setButtonBusy(btn, busy, labelWhenBusy) {
975 if (!btn || btn.nodeType !== 1) return;
976 const busyText = labelWhenBusy || 'Working…';
977 if (busy) {
978 if (btn.dataset.knowtationBtnRestLabel == null) {
979 btn.dataset.knowtationBtnRestLabel = btn.textContent;
980 }
981 btn.textContent = busyText;
982 btn.disabled = true;
983 btn.classList.add('btn-busy');
984 btn.setAttribute('aria-busy', 'true');
985 } else {
986 if (btn.dataset.knowtationBtnRestLabel != null) {
987 btn.textContent = btn.dataset.knowtationBtnRestLabel;
988 delete btn.dataset.knowtationBtnRestLabel;
989 }
990 btn.classList.remove('btn-busy');
991 btn.removeAttribute('aria-busy');
992 btn.disabled = false;
993 }
994 }
995
996 async function withButtonBusy(btn, labelWhenBusy, fn) {
997 if (!btn) return fn();
998 setButtonBusy(btn, true, labelWhenBusy);
999 try {
1000 return await fn();
1001 } finally {
1002 setButtonBusy(btn, false);
1003 }
1004 }
1005
1006 const HOSTED_BACKUP_REPO_LS = 'knowtation_hosted_backup_repo';
1007 /** If set, `resolveApiBase` uses this instead of `location.origin` — can point local Hub UI at Netlify by mistake. */
1008 const HUB_API_URL_LS = 'hub_api_url';
1009
1010 const VAULT_ID_LS = 'hub_vault_id';
1011 /** @see `web/hub/hub-client-import-zip.mjs` — 4B sequential import cap. */
1012 const HUB_IMPORT_MAX_SEQUENTIAL = 200;
1013 const importFileEl = el('import-file');
1014 const importFileFolderEl = el('import-file-folder');
1015 const importFolderHintEl = el('import-folder-hint');
1016 const importBatchCancelBtn = el('import-batch-cancel');
1017 const importBatchAriaEl = el('import-batch-aria');
1018 /** Dropped files/folder (4C) — when set, submit uses this instead of the file inputs. */
1019 /** @type {File[] | null} */
1020 let importPendingDropFiles = null;
1021 const importDropZoneEl = el('import-drop-zone');
1022 const importDropStatusEl = el('import-drop-status');
1023 /** @type {AbortController | null} */
1024 let importBatchAbort = null;
1025 const btnImportChooseFolder = el('btn-import-choose-folder');
1026
1027 function wrapFileWithWebkitRel(file, relPath) {
1028 const w = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
1029 const rel = String(relPath || file.name).replace(/^\//, '');
1030 try {
1031 Object.defineProperty(w, 'webkitRelativePath', { value: rel, enumerable: true, configurable: true });
1032 } catch (_) {}
1033 return w;
1034 }
1035
1036 /**
1037 * @param {FileSystemFileEntry} fe
1038 * @param {string} pathPrefix
1039 * @returns {Promise<File>}
1040 */
1041 function fileEntryToFileWithPath(fe, pathPrefix) {
1042 return new Promise((resolve, reject) => {
1043 fe.file(
1044 (file) => {
1045 const rel = (String(pathPrefix || '') + file.name).replace(/^\//, '');
1046 resolve(wrapFileWithWebkitRel(file, rel));
1047 },
1048 reject,
1049 );
1050 });
1051 }
1052
1053 /**
1054 * @param {FileSystemDirectoryEntry} dirEntry
1055 * @param {string} pathPrefix
1056 * @returns {Promise<File[]>}
1057 */
1058 async function readAllFilesInDirectoryEntry(dirEntry, pathPrefix) {
1059 const all = [];
1060 const reader = dirEntry.createReader();
1061 let batch;
1062 do {
1063 /** @type {FileSystemEntry[]} */
1064 batch = await new Promise((res, rej) => reader.readEntries(res, rej));
1065 for (const e of batch) {
1066 if (e.isFile) {
1067 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(e), pathPrefix));
1068 } else if (e.isDirectory) {
1069 all.push(
1070 ...(await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(e), pathPrefix + e.name + '/')),
1071 );
1072 }
1073 }
1074 } while (batch.length > 0);
1075 return all;
1076 }
1077
1078 /**
1079 * @param {DataTransfer} dataTransfer
1080 * @returns {Promise<File[]>}
1081 */
1082 async function collectFilesFromDataTransfer(dataTransfer) {
1083 if (!dataTransfer) return [];
1084 const canEntry =
1085 dataTransfer.items &&
1086 dataTransfer.items.length > 0 &&
1087 Array.from(dataTransfer.items).some((it) => it.kind === 'file' && 'webkitGetAsEntry' in it);
1088 if (canEntry) {
1089 const all = [];
1090 for (const item of Array.from(dataTransfer.items)) {
1091 if (item.kind !== 'file') continue;
1092 if (item.webkitGetAsEntry) {
1093 const entry = item.webkitGetAsEntry();
1094 if (entry) {
1095 if (entry.isFile) {
1096 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(entry), ''));
1097 } else if (entry.isDirectory) {
1098 all.push(
1099 ...(
1100 await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(entry), entry.name + '/')
1101 ),
1102 );
1103 }
1104 } else {
1105 const f = item.getAsFile();
1106 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
1107 }
1108 } else {
1109 const f = item.getAsFile();
1110 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
1111 }
1112 }
1113 return all;
1114 }
1115 if (dataTransfer.files && dataTransfer.files.length) {
1116 return Array.from(dataTransfer.files).map((f) => wrapFileWithWebkitRel(f, f.name));
1117 }
1118 return [];
1119 }
1120
1121 function updateImportDropStatusUi() {
1122 if (!importDropStatusEl) return;
1123 if (importPendingDropFiles && importPendingDropFiles.length > 0) {
1124 importDropStatusEl.hidden = false;
1125 importDropStatusEl.textContent =
1126 importPendingDropFiles.length +
1127 ' file(s) from drop. Click Import, or use the file picker above to replace.';
1128 } else {
1129 importDropStatusEl.hidden = true;
1130 importDropStatusEl.textContent = '';
1131 }
1132 }
1133
1134 function clearImportDropPending() {
1135 importPendingDropFiles = null;
1136 if (importDropZoneEl) importDropZoneEl.classList.remove('import-drop-zone--over');
1137 updateImportDropStatusUi();
1138 }
1139
1140 function setImportBatchAria(s) {
1141 if (importBatchAriaEl) importBatchAriaEl.textContent = s || '';
1142 }
1143
1144 function normalizeUrlOrigin(base) {
1145 try {
1146 const s = String(base || '').trim().replace(/\/$/, '');
1147 if (!s) return '';
1148 const u = new URL(s.startsWith('http') ? s : 'https://' + s);
1149 return u.origin;
1150 } catch (_) {
1151 return '';
1152 }
1153 }
1154
1155 function isLocalHubHostname() {
1156 const h = location.hostname;
1157 return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
1158 }
1159
1160 /** Local Hub tab but `apiBase` targets another origin (e.g. Netlify) — causes “Could not reach the API … knowtation-gateway…”. */
1161 function localApiBaseFootgunActive() {
1162 if (!isLocalHubHostname()) return false;
1163 const pageO = normalizeUrlOrigin(location.origin);
1164 const apiO = normalizeUrlOrigin(apiBase);
1165 if (!pageO || !apiO) return false;
1166 return pageO !== apiO;
1167 }
1168
1169 function refreshApiBaseFootgunBanner() {
1170 const b = el('hub-api-base-footgun-banner');
1171 if (!b) return;
1172 if (!localApiBaseFootgunActive()) {
1173 b.classList.add('hidden');
1174 b.innerHTML = '';
1175 return;
1176 }
1177 let lsHint = false;
1178 try {
1179 lsHint = Boolean(localStorage.getItem(HUB_API_URL_LS));
1180 } catch (_) {}
1181 const qsHint = Boolean(params.get('api'));
1182 b.classList.remove('hidden');
1183 const hint =
1184 (lsHint ? ' <code>localStorage.' + HUB_API_URL_LS + '</code> is set.' : '') +
1185 (qsHint ? ' This URL has an <code>?api=</code> override.' : '');
1186 b.innerHTML =
1187 '<p><strong>Wrong API for this tab.</strong> This page is on <code>' +
1188 escapeHtml(location.origin) +
1189 '</code> but the Hub calls <code>' +
1190 escapeHtml(apiBase) +
1191 '</code> for requests (settings, backup, notes).' +
1192 hint +
1193 ' For self-hosted <code>npm run hub</code>, clear the override so the API matches this origin, then reload.</p>' +
1194 '<p><button type="button" class="btn-secondary" id="hub-api-footgun-clear">Clear API override &amp; reload</button></p>';
1195 const clearBtn = el('hub-api-footgun-clear');
1196 if (clearBtn) {
1197 clearBtn.onclick = () => {
1198 try {
1199 localStorage.removeItem(HUB_API_URL_LS);
1200 } catch (_) {}
1201 const u = new URL(location.href);
1202 u.searchParams.delete('api');
1203 window.location.href = u.toString();
1204 };
1205 }
1206 }
1207
1208 function getCurrentVaultId() {
1209 try {
1210 return localStorage.getItem(VAULT_ID_LS) || 'default';
1211 } catch (_) {
1212 return 'default';
1213 }
1214 }
1215
1216 function setCurrentVaultId(id) {
1217 try {
1218 localStorage.setItem(VAULT_ID_LS, id);
1219 } catch (_) {}
1220 }
1221
1222 /** Per-vault hint: Meaning (semantic) search may lag vault edits until Re-index runs successfully. */
1223 const HUB_SEMANTIC_INDEX_STALE_PREFIX = 'hub_semantic_index_stale_v1:';
1224
1225 function hubSemanticIndexStaleLsKey(vaultId) {
1226 const v = vaultId != null && String(vaultId).trim() !== '' ? String(vaultId).trim() : 'default';
1227 return HUB_SEMANTIC_INDEX_STALE_PREFIX + v;
1228 }
1229
1230 function hubRefreshIndexStaleBanner() {
1231 const banner = el('hub-index-stale-banner');
1232 if (!banner) return;
1233 let flagged = false;
1234 try {
1235 flagged = Boolean(localStorage.getItem(hubSemanticIndexStaleLsKey(getCurrentVaultId())));
1236 } catch (_) {
1237 flagged = false;
1238 }
1239 if (!flagged) {
1240 banner.classList.add('hidden');
1241 return;
1242 }
1243 banner.classList.remove('hidden');
1244 }
1245
1246 function hubMarkSemanticIndexStaleForVault(vaultId) {
1247 try {
1248 localStorage.setItem(hubSemanticIndexStaleLsKey(vaultId), String(Date.now()));
1249 } catch (_) {}
1250 hubRefreshIndexStaleBanner();
1251 }
1252
1253 function hubMarkSemanticIndexStale() {
1254 hubMarkSemanticIndexStaleForVault(getCurrentVaultId());
1255 }
1256
1257 function hubClearSemanticIndexStaleForVault(vaultId) {
1258 try {
1259 localStorage.removeItem(hubSemanticIndexStaleLsKey(vaultId));
1260 } catch (_) {}
1261 hubRefreshIndexStaleBanner();
1262 }
1263
1264 function hubClearSemanticIndexStale() {
1265 hubClearSemanticIndexStaleForVault(getCurrentVaultId());
1266 }
1267
1268 function updateVaultSwitcher(vaultList, allowedVaultIds) {
1269 const wrap = el('vault-switcher-wrap');
1270 const select = el('vault-switcher');
1271 if (!wrap || !select) return;
1272 const rows = Array.isArray(vaultList) ? vaultList : [];
1273 const byId = new Map(rows.map((v) => [String(v.id), v]));
1274 let allowed =
1275 Array.isArray(allowedVaultIds) && allowedVaultIds.length
1276 ? allowedVaultIds.map(String)
1277 : rows.length
1278 ? rows.map((v) => String(v.id))
1279 : ['default'];
1280 allowed = [...new Set(allowed)];
1281 const options = allowed.map((id) => {
1282 const v = byId.get(id);
1283 return { id, label: v && (v.label || v.id) ? String(v.label || v.id) : id };
1284 });
1285 select.innerHTML = options
1286 .map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label) + '</option>')
1287 .join('');
1288 select.value = getCurrentVaultId();
1289 if (!allowed.includes(select.value)) select.value = allowed[0] || 'default';
1290 setCurrentVaultId(select.value);
1291 // Always surface the current vault once settings load (even with a single
1292 // vault) so the control is discoverable under the left-rail Vault area.
1293 wrap.classList.toggle('hidden', options.length < 1);
1294 if (allowed.length >= 2 && options.length === 1) {
1295 select.title =
1296 '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.';
1297 } else if (options.length === 1) {
1298 select.title = 'Current vault. Add more under Settings → Vaults when your role allows.';
1299 } else {
1300 select.title = 'Switch the active vault for notes, search, and proposals.';
1301 }
1302 select.onchange = () => {
1303 setCurrentVaultId(select.value);
1304 loadFacets();
1305 loadNotes();
1306 loadProposals();
1307 hubRefreshIndexStaleBanner();
1308 };
1309 }
1310
1311 function applyHostedUiFromSettings(s) {
1312 if (!s || typeof s !== 'object') return;
1313 const hosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1314 window.__hubIsHosted = hosted;
1315 const btn = el('btn-projects-help');
1316 if (btn) btn.classList.toggle('hidden', !hosted);
1317 }
1318
1319 function normalizeGithubRepoSlug(raw) {
1320 let t = (raw || '').trim();
1321 if (!t) return '';
1322 t = t.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
1323 const parts = t.split('/').filter(Boolean);
1324 if (parts.length >= 2) return parts[0] + '/' + parts[1];
1325 return t;
1326 }
1327
1328 /** Hosted (canister): any logged-in user may sync to their own GitHub; self-hosted still requires admin. */
1329 function settingsSyncDisabled(s, vg, isHosted) {
1330 const isAdmin = s.role === 'admin';
1331 const hostedGitBackup = isHosted && s.github_connect_available;
1332 if (hostedGitBackup) {
1333 const inputEl = el('settings-hosted-repo');
1334 const inputRepo = normalizeGithubRepoSlug(inputEl && inputEl.value);
1335 const slug = inputRepo || normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) || normalizeGithubRepoSlug(s.repo);
1336 return !s.github_connected || !slug;
1337 }
1338 return !vg.enabled || !vg.has_remote || !isAdmin;
1339 }
1340
1341 /** After Connect GitHub, blob read-after-write can lag; retry settings until github_connected or timeout. */
1342 async function fetchSettingsForBackupModal() {
1343 const pendingRaw = sessionStorage.getItem('knowtation_github_connect_pending');
1344 const pendingTs = pendingRaw ? parseInt(pendingRaw, 10) : NaN;
1345 const pendingFresh = Number.isFinite(pendingTs) && Date.now() - pendingTs < 120000;
1346 if (!pendingFresh) {
1347 if (pendingRaw) sessionStorage.removeItem('knowtation_github_connect_pending');
1348 return api('/api/v1/settings');
1349 }
1350 let s;
1351 for (let attempt = 0; attempt < 8; attempt++) {
1352 s = await api('/api/v1/settings');
1353 if (s.github_connected || !s.github_connect_available) break;
1354 if (attempt < 7) await new Promise((r) => setTimeout(r, 600));
1355 }
1356 sessionStorage.removeItem('knowtation_github_connect_pending');
1357 return s;
1358 }
1359
1360 /** Align with hub/server effectiveRole: viewer read-only; member maps to editor for writes. */
1361 function hubUserCanWriteNotes() {
1362 const r = window.__hubUserRole;
1363 return r === 'editor' || r === 'admin' || r === 'member';
1364 }
1365
1366 /** Same roles as POST /api/v1/proposals on Hub (evaluators propose; viewers do not). */
1367 function hubUserMayProposeFromNote() {
1368 const r = window.__hubUserRole;
1369 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1370 }
1371
1372 /** Download current note (POST /api/v1/export); allowed for any vault reader including viewer. */
1373 function hubUserCanExportNote() {
1374 const r = window.__hubUserRole || 'member';
1375 return (
1376 r === 'editor' || r === 'admin' || r === 'member' || r === 'viewer' || r === 'evaluator'
1377 );
1378 }
1379
1380 /** Proposal Enrich (AI): evaluators may run it without note-write roles; editors/admins/members still qualify. */
1381 function hubUserMayEnrichProposal() {
1382 const r = window.__hubUserRole;
1383 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1384 }
1385
1386 /** Multi-vault copy/move in note detail (Settings must list ≥2 allowed vaults). */
1387 function hubHasMultipleVaultsForCopy() {
1388 const s = lastBackupSettingsPayload;
1389 if (!s || !Array.isArray(s.allowed_vault_ids)) return false;
1390 return s.allowed_vault_ids.filter(Boolean).length >= 2;
1391 }
1392
1393 function hubUserIsAdmin() {
1394 return window.__hubUserRole === 'admin';
1395 }
1396
1397 /** Delete vault: self-hosted admins only; hosted matches “create vault” (writer + workspace owner when set). */
1398 function hubUserMayDeleteVault() {
1399 if (!hubUserCanWriteNotes()) return false;
1400 if (isHostedHubFromSettings()) {
1401 const ws = lastBackupSettingsPayload;
1402 const ownerId =
1403 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
1404 ? String(ws.workspace_owner_id).trim()
1405 : '';
1406 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
1407 if (ownerId && me && me !== ownerId) return false;
1408 return true;
1409 }
1410 return hubUserIsAdmin();
1411 }
1412
1413 function populateSettingsDeleteVaultSelect(s) {
1414 const sel = el('settings-delete-vault-select');
1415 if (!sel) return;
1416 const vaultList = (s && Array.isArray(s.vault_list) && s.vault_list) || [];
1417 const allowedRaw = s && Array.isArray(s.allowed_vault_ids) ? s.allowed_vault_ids : null;
1418 const allowedSet = allowedRaw && allowedRaw.length > 0 ? new Set(allowedRaw.map(String)) : null;
1419 const opts = vaultList.filter((v) => {
1420 if (!v || v.id == null) return false;
1421 const id = String(v.id).trim();
1422 if (!id || id === 'default') return false;
1423 if (allowedSet && !allowedSet.has(id)) return false;
1424 return true;
1425 });
1426 sel.innerHTML =
1427 opts.length === 0
1428 ? '<option value="">(no extra vaults)</option>'
1429 : '<option value="">— Choose vault —</option>' +
1430 opts
1431 .map(
1432 (v) =>
1433 '<option value="' +
1434 escapeHtml(String(v.id)) +
1435 '">' +
1436 escapeHtml(String(v.label != null && v.label !== '' ? v.label : v.id)) +
1437 '</option>',
1438 )
1439 .join('');
1440 }
1441
1442 function refreshVaultDeleteSubsection() {
1443 const wrap = el('settings-danger-zone-vault');
1444 if (!wrap) return;
1445 const s = lastBackupSettingsPayload;
1446 if (!s || !hubUserMayDeleteVault()) {
1447 wrap.classList.add('hidden');
1448 return;
1449 }
1450 populateSettingsDeleteVaultSelect(s);
1451 const vaultList = (s.vault_list) || [];
1452 const extra = vaultList.filter((v) => v && String(v.id).trim() && String(v.id).trim() !== 'default');
1453 if (extra.length === 0) {
1454 wrap.classList.add('hidden');
1455 return;
1456 }
1457 wrap.classList.remove('hidden');
1458 }
1459
1460 function refreshDeleteProjectPanelVisibility() {
1461 const panel = el('settings-danger-zone-panel');
1462 if (panel) panel.classList.toggle('hidden', !hubUserCanWriteNotes());
1463 refreshVaultDeleteSubsection();
1464 }
1465
1466 /** Apply GET /api/v1/settings payload to header vault switcher, hosted flag, and cached backup modal state. */
1467 function applySettingsPayloadToHubChrome(s) {
1468 if (!s || typeof s !== 'object') return;
1469 lastBackupSettingsPayload = s;
1470 if (s.role) window.__hubUserRole = String(s.role);
1471 refreshDeleteProjectPanelVisibility();
1472 refreshNewProposalTabVisibility();
1473 const allowed = (s.allowed_vault_ids || []).map(String);
1474 const current = String(getCurrentVaultId());
1475 if (allowed.length && !allowed.includes(current)) {
1476 setCurrentVaultId(allowed[0] || 'default');
1477 }
1478 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
1479 if (typeof refreshAgentCredVaultSelect === 'function') refreshAgentCredVaultSelect();
1480 applyHostedUiFromSettings(s);
1481 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
1482 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
1483 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
1484 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
1485 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
1486 syncPendingEvalQuickChip();
1487 const metaSelf = el('settings-bulk-metadata-self-only');
1488 if (metaSelf) metaSelf.classList.remove('hidden');
1489 applyMuseBridgePanel(s);
1490 }
1491
1492 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
1493 function applyMuseBridgePanel(s) {
1494 if (!s || typeof s !== 'object') return;
1495 const mb = s.muse_bridge;
1496 const statusEl = el('settings-muse-status');
1497 const envHint = el('settings-muse-env-hint');
1498 const input = el('settings-muse-url');
1499 const saveBtn = el('btn-settings-muse-save');
1500 const msg = el('settings-muse-msg');
1501 if (msg) {
1502 msg.textContent = '';
1503 msg.className = 'settings-msg';
1504 }
1505 if (!mb) {
1506 if (statusEl) statusEl.textContent = '—';
1507 if (input) {
1508 input.value = '';
1509 input.disabled = true;
1510 }
1511 if (saveBtn) saveBtn.classList.add('hidden');
1512 return;
1513 }
1514 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1515 const isAdmin = s.role === 'admin';
1516 if (statusEl) {
1517 statusEl.textContent =
1518 mb.enabled && mb.origin
1519 ? 'Server status: linked — ' + mb.origin
1520 : 'Server status: Muse link not configured for this Hub.';
1521 }
1522 if (envHint) {
1523 envHint.classList.toggle('hidden', !mb.env_override_active);
1524 envHint.textContent = mb.env_override_active
1525 ? '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.'
1526 : '';
1527 }
1528 if (input) {
1529 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
1530 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
1531 input.disabled = !canEdit;
1532 input.title = canEdit
1533 ? ''
1534 : isHosted
1535 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
1536 : !isAdmin
1537 ? 'Only admins can save the Muse URL.'
1538 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
1539 }
1540 if (saveBtn) {
1541 const show = !isHosted && isAdmin && mb.url_editable === true;
1542 saveBtn.classList.toggle('hidden', !show);
1543 }
1544 }
1545
1546 function showLoginChrome() {
1547 btnLogout.classList.add('hidden');
1548 userName.textContent = '';
1549 if (!providers) return;
1550 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1551 if (providers.github) btnLoginGithub.classList.remove('hidden');
1552 if (!providers.google && !providers.github) {
1553 oauthNotConfigured.classList.remove('hidden');
1554 if (loginIntro) loginIntro.classList.add('hidden');
1555 }
1556 }
1557
1558 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
1559 let onboardingModulePromise = null;
1560 function loadOnboardingModule() {
1561 if (!onboardingModulePromise) {
1562 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
1563 }
1564 return onboardingModulePromise;
1565 }
1566
1567 function getOnboardingUserKey() {
1568 if (!token) return '';
1569 try {
1570 const payload = JSON.parse(atob(token.split('.')[1]));
1571 return String(payload.sub || payload.email || 'unknown');
1572 } catch (_) {
1573 return 'unknown';
1574 }
1575 }
1576
1577 /**
1578 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
1579 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
1580 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
1581 */
1582 function wizardHostedFromContext(settingsPayload) {
1583 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
1584 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
1585 if (vd === 'canister') return true;
1586 try {
1587 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
1588 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
1589 } catch (_) {}
1590 return false;
1591 }
1592
1593 function persistOnboardingProgress(mod, partial) {
1594 const userKey = getOnboardingUserKey();
1595 const isHosted = wizardHostedFromContext();
1596 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1597 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1598 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1599 st = mod.createFreshState(userKey, hostingPath);
1600 }
1601 Object.assign(st, partial);
1602 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1603 }
1604
1605 let onboardingWizardBindingsDone = false;
1606 let onboardingRenderStep = function () {};
1607
1608 function closeOnboardingWizardResume() {
1609 const modal = el('modal-onboarding');
1610 if (!modal || modal.classList.contains('hidden')) return;
1611 modal.classList.add('hidden');
1612 }
1613
1614 function closeOnboardingWizardDismiss() {
1615 loadOnboardingModule()
1616 .then((mod) => {
1617 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1618 updateEmptyVaultStripVisibility();
1619 })
1620 .catch(function () {});
1621 const modal = el('modal-onboarding');
1622 if (modal) modal.classList.add('hidden');
1623 }
1624
1625 function bindOnboardingWizardOnce(mod) {
1626 if (onboardingWizardBindingsDone) return;
1627 onboardingWizardBindingsDone = true;
1628 const modal = el('modal-onboarding');
1629 const closeBtn = el('modal-onboarding-close');
1630 const backdrop = el('modal-onboarding-backdrop');
1631 const btnSkip = el('btn-onboarding-skip');
1632 const btnBack = el('btn-onboarding-back');
1633 const btnNext = el('btn-onboarding-next');
1634 const body = el('onboarding-step-body');
1635 const progress = el('onboarding-progress');
1636 const live = el('onboarding-live');
1637 const secondary = el('onboarding-secondary-actions');
1638
1639 function handleSecondaryAction(id) {
1640 /* 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. */
1641 if (id === 'projectsHelp') {
1642 openProjectsHelpModal();
1643 return;
1644 }
1645 if (id === 'howToKnowledge') {
1646 openHowToUse('knowledge-agents');
1647 return;
1648 }
1649 if (id === 'openSettingsBackup') {
1650 openSettings();
1651 return;
1652 }
1653 if (id === 'openSettingsIntegrations') {
1654 openSettingsIntegrationsTab();
1655 return;
1656 }
1657 if (id === 'howToSetup4') {
1658 openHowToUse('setup', 'how-to-step-selfhosted-index');
1659 return;
1660 }
1661 if (id === 'howToSetup3') {
1662 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1663 return;
1664 }
1665 if (id === 'openWhyTokenDoc') {
1666 window.open(
1667 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1668 '_blank',
1669 'noopener,noreferrer',
1670 );
1671 return;
1672 }
1673 if (id === 'openImportModal') {
1674 closeOnboardingWizardResume();
1675 openImportModal();
1676 return;
1677 }
1678 if (id === 'openImportSourcesDoc') {
1679 window.open(
1680 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1681 '_blank',
1682 'noopener,noreferrer',
1683 );
1684 return;
1685 }
1686 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1687 window.open(
1688 id === 'openAgentDocProposals'
1689 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1690 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1691 '_blank',
1692 'noopener,noreferrer',
1693 );
1694 return;
1695 }
1696 if (id === 'focusSuggestedTab') {
1697 closeOnboardingWizardResume();
1698 switchHubMainTab('suggested');
1699 return;
1700 }
1701 }
1702
1703 onboardingRenderStep = function renderOnboardingStep() {
1704 const userKey = getOnboardingUserKey();
1705 const isHosted = wizardHostedFromContext();
1706 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1707 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1708 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1709 st = mod.createFreshState(userKey, hostingPath);
1710 }
1711 const total = mod.getStepCount(isHosted);
1712 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1713 const content = mod.getStepContent(isHosted, idx);
1714 if (body) body.innerHTML = content ? content.bodyHtml : '';
1715 if (content && content.id === 'h-imports' && body) {
1716 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1717 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1718 }
1719
1720 if (progress) {
1721 progress.innerHTML = '';
1722 for (let i = 0; i < total; i++) {
1723 const d = document.createElement('span');
1724 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1725 d.title = 'Step ' + (i + 1) + ' of ' + total;
1726 progress.appendChild(d);
1727 }
1728 }
1729 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1730
1731 if (btnBack) btnBack.disabled = idx <= 0;
1732 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1733
1734 if (secondary) {
1735 secondary.innerHTML = '';
1736 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1737 const b = document.createElement('button');
1738 b.type = 'button';
1739 b.className = 'btn-link btn-link-small';
1740 b.textContent = a.label;
1741 b.addEventListener('click', () => handleSecondaryAction(a.id));
1742 secondary.appendChild(b);
1743 });
1744 }
1745 };
1746
1747 if (btnBack) {
1748 btnBack.addEventListener('click', () => {
1749 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1750 if (!st || st.status !== 'in_progress') return;
1751 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1752 onboardingRenderStep();
1753 });
1754 }
1755 if (btnNext) {
1756 btnNext.addEventListener('click', () => {
1757 const userKey = getOnboardingUserKey();
1758 const isHosted = wizardHostedFromContext();
1759 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1760 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1761 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1762 const total = mod.getStepCount(isHosted);
1763 if (st.stepIndex >= total - 1) {
1764 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1765 if (modal) modal.classList.add('hidden');
1766 return;
1767 }
1768 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1769 onboardingRenderStep();
1770 });
1771 }
1772 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1773 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1774 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1775
1776 modal.addEventListener('click', (ev) => {
1777 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1778 if (!copyBtn || !body) return;
1779 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1780 const txt = ta && ta.value ? String(ta.value) : '';
1781 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1782 ev.preventDefault();
1783 void navigator.clipboard.writeText(txt).then(() => {
1784 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1785 });
1786 });
1787 }
1788
1789 async function openOnboardingWizard(opts) {
1790 const restart = opts && opts.restart;
1791 const mod = await loadOnboardingModule();
1792 bindOnboardingWizardOnce(mod);
1793 const userKey = getOnboardingUserKey();
1794 const isHosted = wizardHostedFromContext();
1795 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1796 if (restart) {
1797 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1798 } else {
1799 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1800 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1801 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1802 }
1803 }
1804 const modal = el('modal-onboarding');
1805 if (modal) modal.classList.remove('hidden');
1806 onboardingRenderStep();
1807 const btnNext = el('btn-onboarding-next');
1808 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1809 }
1810
1811 async function scheduleMaybeShowOnboardingWizard(_s) {
1812 // Auto-popup removed: open only from How to use → Open setup walkthrough / Setup guide.
1813 return;
1814 }
1815
1816 function syncHubHeaderOffset() {
1817 const header = document.querySelector('.hub-header');
1818 if (!header) return;
1819 const h = Math.max(48, Math.round(header.getBoundingClientRect().height));
1820 document.documentElement.style.setProperty('--hub-header-offset', h + 'px');
1821 }
1822
1823 function showMain() {
1824 if (app) app.classList.remove('login-screen');
1825 loginRequired.classList.add('hidden');
1826 main.classList.remove('hidden');
1827 btnHowToUse.classList.remove('hidden');
1828 if (btnSettings) btnSettings.classList.remove('hidden');
1829 syncHubHeaderOffset();
1830 syncModeToolbars(getActiveHubMainTab());
1831 if (token) {
1832 btnLoginGoogle.classList.add('hidden');
1833 btnLoginGithub.classList.add('hidden');
1834 oauthNotConfigured.classList.add('hidden');
1835 btnLogout.classList.remove('hidden');
1836 try {
1837 const payload = JSON.parse(atob(token.split('.')[1]));
1838 userName.textContent = payload.name || payload.sub || 'Logged in';
1839 window.__hubUserRole = payload.role || 'member';
1840 const isViewer = window.__hubUserRole === 'viewer';
1841 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1842 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1843 const railImport = el('hub-rail-import');
1844 if (railImport) railImport.classList.toggle('hidden', isViewer);
1845 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1846 refreshDeleteProjectPanelVisibility();
1847 void refreshReviewBadge();
1848 } catch (_) {
1849 userName.textContent = 'Logged in';
1850 window.__hubUserRole = 'member';
1851 if (btnNewNote) btnNewNote.classList.remove('hidden');
1852 if (btnImport) btnImport.classList.remove('hidden');
1853 const railImport = el('hub-rail-import');
1854 if (railImport) railImport.classList.remove('hidden');
1855 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1856 refreshDeleteProjectPanelVisibility();
1857 void refreshReviewBadge();
1858 }
1859 } else {
1860 if (btnNewNote) btnNewNote.classList.add('hidden');
1861 if (btnImport) btnImport.classList.add('hidden');
1862 const railImport = el('hub-rail-import');
1863 if (railImport) railImport.classList.add('hidden');
1864 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1865 applyReviewBadgeCount(0);
1866 }
1867 hubRefreshIndexStaleBanner();
1868 }
1869
1870 function loginUrl(provider) {
1871 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1872 const invite = params.get('invite');
1873 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1874 }
1875 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1876 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1877 // because a direct window.location.href navigation has no retry mechanism.
1878 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1879 // guaranteed warm, and the OAuth redirect hits a hot instance.
1880 async function oauthNavigate(provider, btn) {
1881 const original = btn.textContent;
1882 btn.disabled = true;
1883 btn.textContent = 'Connecting…';
1884 try {
1885 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1886 // during this time so the user knows something is happening.
1887 await fetch(apiBase + '/api/v1/auth/providers', {
1888 cache: 'no-store',
1889 signal: AbortSignal.timeout(22000),
1890 });
1891 } catch (_) {
1892 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1893 // OAuth handler itself has the full 26 s budget once TCP is established.
1894 }
1895 window.location.href = loginUrl(provider);
1896 // Navigation is underway; restore button state in case the browser returns here.
1897 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1898 }
1899 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1900 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1901
1902 btnLogout.onclick = () => {
1903 // Revoke the refresh token server-side (real logout), then clear local state regardless
1904 // of whether the network call succeeds.
1905 try {
1906 fetch(apiBase + '/api/v1/auth/logout', {
1907 method: 'POST',
1908 credentials: 'include',
1909 cache: 'no-store',
1910 headers: { 'Content-Type': 'application/json' },
1911 }).catch(() => {});
1912 } catch (_) { /* best effort */ }
1913 token = null;
1914 localStorage.removeItem('hub_token');
1915 persistSessionDurabilityWarning(false);
1916 if (app) app.classList.add('login-screen');
1917 main.classList.add('hidden');
1918 browseToolbar.classList.add('hidden');
1919 btnNewNote.classList.add('hidden');
1920 if (btnImport) btnImport.classList.add('hidden');
1921 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1922 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1923 if (btnSettings) btnSettings.classList.add('hidden');
1924 closeOnboardingWizardResume();
1925 loginRequired.classList.remove('hidden');
1926 if (loginIntro) loginIntro.classList.remove('hidden');
1927 showLoginChrome();
1928 };
1929
1930 async function initProviders() {
1931 for (let attempt = 0; attempt < 3; attempt++) {
1932 try {
1933 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1934 if (!r.ok) throw new Error('providers');
1935 providers = await r.json();
1936 break;
1937 } catch (_) {
1938 if (attempt < 2) {
1939 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1940 continue;
1941 }
1942 providers = { google: false, github: false };
1943 oauthNotConfigured.classList.remove('hidden');
1944 if (loginIntro) loginIntro.classList.add('hidden');
1945 const first = oauthNotConfigured.querySelector('p');
1946 if (first) {
1947 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1948 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1949 if (isHosted && sameOrigin) {
1950 first.innerHTML =
1951 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1952 '</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.';
1953 } else if (isHosted && !sameOrigin) {
1954 first.innerHTML =
1955 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1956 '</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.';
1957 } else {
1958 first.innerHTML =
1959 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1960 escapeHtml(apiBase) +
1961 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1962 }
1963 }
1964 return;
1965 }
1966 }
1967
1968 if (!providers.google && !providers.github) {
1969 oauthNotConfigured.classList.remove('hidden');
1970 if (loginIntro) loginIntro.classList.add('hidden');
1971 } else {
1972 oauthNotConfigured.classList.add('hidden');
1973 if (loginIntro) loginIntro.classList.remove('hidden');
1974 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1975 const loggedIn =
1976 Boolean(token) ||
1977 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1978 if (!loggedIn) {
1979 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1980 if (providers.github) btnLoginGithub.classList.remove('hidden');
1981 }
1982 }
1983 }
1984
1985 if (token) {
1986 if (params.get('invite')) {
1987 (async () => {
1988 const inviteToken = params.get('invite');
1989 let lastErr;
1990 for (let attempt = 0; attempt < 3; attempt++) {
1991 try {
1992 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1993 const u = new URL(location.href);
1994 u.searchParams.delete('invite');
1995 u.searchParams.set('invite_accepted', '1');
1996 history.replaceState({}, '', u.toString());
1997 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1998 return;
1999 } catch (e) {
2000 lastErr = e;
2001 const code = e && e.code;
2002 const msg = String(e && e.message ? e.message : e || '');
2003 const staleInvite =
2004 code === 'NOT_FOUND' ||
2005 code === 'EXPIRED' ||
2006 /not found|already used|expired/i.test(msg);
2007 if (staleInvite) {
2008 const u = new URL(location.href);
2009 u.searchParams.delete('invite');
2010 history.replaceState({}, '', u.toString());
2011 if (code === 'EXPIRED' && typeof showToast === 'function') {
2012 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
2013 }
2014 return;
2015 }
2016 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
2017 }
2018 }
2019 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
2020 })();
2021 }
2022 showMain();
2023 getImageProxyToken().catch(function () {});
2024 (async function ensureVaultAndSwitcherThenLoad() {
2025 let settingsPayload = null;
2026 try {
2027 settingsPayload = await api('/api/v1/settings');
2028 applySettingsPayloadToHubChrome(settingsPayload);
2029 } catch (_) {}
2030 syncHubListSortUI('notes');
2031 syncModeToolbars('notes');
2032 refreshNewProposalTabVisibility();
2033 loadFacets();
2034 loadNotes();
2035 loadProposals();
2036 loadActivity();
2037 renderPresets();
2038 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
2039 })();
2040 initProviders();
2041 if (params.get('open') === 'billing') {
2042 const checkoutSuccess = params.get('checkout') === 'success';
2043 // Clean up params before opening so back-button doesn't re-trigger.
2044 const u = new URL(location.href);
2045 u.searchParams.delete('open');
2046 u.searchParams.delete('checkout');
2047 history.replaceState({}, '', u.toString());
2048 // Small delay so the main Hub has rendered before the modal opens.
2049 setTimeout(() => {
2050 openSettingsBillingTab();
2051 if (checkoutSuccess && typeof showToast === 'function') {
2052 showToast('Subscription activated — welcome to your new plan!');
2053 }
2054 }, 400);
2055 }
2056 if (params.get('github_connected') === '1') {
2057 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
2058 setTimeout(() => {
2059 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
2060 const u = new URL(location.href);
2061 u.searchParams.delete('github_connected');
2062 history.replaceState({}, '', u.toString());
2063 }, 500);
2064 } else if (params.get('github_connect_error')) {
2065 setTimeout(() => {
2066 const code = params.get('github_connect_error');
2067 const msg =
2068 code === 'blob_storage'
2069 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
2070 : 'GitHub connect: ' + code;
2071 if (typeof showToast === 'function') showToast(msg, true);
2072 const u = new URL(location.href);
2073 u.searchParams.delete('github_connect_error');
2074 history.replaceState({}, '', u.toString());
2075 }, 500);
2076 }
2077 } else {
2078 if (app) app.classList.add('login-screen');
2079 main.classList.add('hidden');
2080 loginRequired.classList.remove('hidden');
2081 btnNewNote.classList.add('hidden');
2082 if (btnImport) btnImport.classList.add('hidden');
2083 const inviteBanner = el('login-invite-banner');
2084 if (inviteBanner && params.get('invite')) {
2085 inviteBanner.textContent = "You've been invited. Sign in to join.";
2086 inviteBanner.classList.remove('hidden');
2087 }
2088 initProviders();
2089 }
2090 refreshApiBaseFootgunBanner();
2091 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
2092 setTimeout(() => {
2093 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
2094 const u = new URL(location.href);
2095 u.searchParams.delete('invite_accepted');
2096 history.replaceState({}, '', u.pathname + u.search);
2097 }, 500);
2098 }
2099
2100 function dateSlice(d) {
2101 if (!d || typeof d !== 'string') return '';
2102 return d.trim().slice(0, 10);
2103 }
2104
2105 /** 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. */
2106 function materializeFrontmatter(fm) {
2107 if (fm == null) return {};
2108 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
2109 if (typeof fm === 'string') {
2110 let cur = fm.replace(/^\uFEFF/, '').trim();
2111 if (!cur) return {};
2112 for (let i = 0; i < 8; i++) {
2113 try {
2114 const o = JSON.parse(cur);
2115 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
2116 if (typeof o === 'string') {
2117 const next = o.trim();
2118 if (next === cur) return {};
2119 cur = next;
2120 continue;
2121 }
2122 return {};
2123 } catch {
2124 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
2125 try {
2126 const inner = JSON.parse(cur);
2127 if (typeof inner === 'string') {
2128 cur = inner.trim();
2129 continue;
2130 }
2131 } catch {
2132 /* fall through */
2133 }
2134 }
2135 return {};
2136 }
2137 }
2138 return {};
2139 }
2140 return {};
2141 }
2142
2143 function tagsFromFrontmatter(fm) {
2144 const raw = fm && fm.tags;
2145 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
2146 if (typeof raw === 'string' && raw.trim()) {
2147 return raw
2148 .split(/[,\n]/)
2149 .map((s) => s.trim())
2150 .filter(Boolean);
2151 }
2152 return [];
2153 }
2154
2155 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
2156 function isoDateLocalFromMs(ms) {
2157 const d = new Date(ms);
2158 if (Number.isNaN(d.getTime())) return null;
2159 const y = d.getFullYear();
2160 const mo = String(d.getMonth() + 1).padStart(2, '0');
2161 const day = String(d.getDate()).padStart(2, '0');
2162 return y + '-' + mo + '-' + day;
2163 }
2164
2165 /**
2166 * Calendar bucket for Hub list/calendar/overview.
2167 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
2168 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
2169 */
2170 function calendarDisplayDayKey(raw) {
2171 if (raw == null) return null;
2172 const s = String(raw).trim();
2173 if (!s) return null;
2174 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
2175 const ms = Date.parse(s);
2176 if (Number.isNaN(ms)) return s.slice(0, 10);
2177 return isoDateLocalFromMs(ms);
2178 }
2179
2180 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
2181 function inferredDisplayDateFromNotePath(notePath) {
2182 if (!notePath || typeof notePath !== 'string') return null;
2183 const base = notePath.split('/').pop() || '';
2184 const m = /^note-(\d{10,})\.md$/i.exec(base);
2185 if (!m) return null;
2186 const ms = Number(m[1]);
2187 if (!Number.isFinite(ms)) return null;
2188 return isoDateLocalFromMs(ms);
2189 }
2190
2191 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
2192 function listItemDisplayDate(n, fm) {
2193 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
2194 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
2195 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
2196 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
2197 const inferred = inferredDisplayDateFromNotePath(n.path);
2198 return inferred || null;
2199 }
2200
2201 function noteSortOrCalendarDay(n) {
2202 const raw = n.date || n.updated || '';
2203 return calendarDisplayDayKey(raw) || dateSlice(raw);
2204 }
2205
2206 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
2207 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
2208 const HUB_SORT_NOTES_OPTS = [
2209 { v: 'date_desc', l: 'Newest first' },
2210 { v: 'date_asc', l: 'Oldest first' },
2211 { v: 'year_desc', l: 'Year (newest first)' },
2212 { v: 'year_asc', l: 'Year (oldest first)' },
2213 { v: 'path_asc', l: 'Path A–Z' },
2214 { v: 'title_asc', l: 'Title A–Z' },
2215 ];
2216 const HUB_SORT_PROP_OPTS = [
2217 { v: 'updated_desc', l: 'Newest first' },
2218 { v: 'updated_asc', l: 'Oldest first' },
2219 { v: 'path_asc', l: 'Path A–Z' },
2220 { v: 'status_asc', l: 'Status A–Z' },
2221 ];
2222
2223 function hubListSortGetSelect() {
2224 return el('hub-list-sort');
2225 }
2226
2227 function syncHubListSortUI(activeTab) {
2228 const sel = hubListSortGetSelect();
2229 if (!sel) return;
2230 const isNotes = activeTab === 'notes';
2231 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
2232 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
2233 let saved = '';
2234 try {
2235 saved = localStorage.getItem(key) || '';
2236 } catch (_) {}
2237 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
2238 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
2239 sel.value = saved;
2240 }
2241
2242 function setProposalFiltersBarVisible(show) {
2243 const bar = el('proposal-filters-bar');
2244 if (bar) bar.classList.toggle('hidden', !show);
2245 }
2246
2247 function refreshNewProposalTabVisibility() {
2248 const btn = el('btn-new-proposal');
2249 if (!btn) return;
2250 const tab = getActiveHubMainTab();
2251 const show = tab === 'suggested' && hubUserCanWriteNotes();
2252 btn.classList.toggle('hidden', !show);
2253 }
2254
2255 function applySortedNotesClient(notes) {
2256 const tab = getActiveHubMainTab();
2257 if (tab !== 'notes') return notes;
2258 const S = globalThis.HubListSort;
2259 const sel = hubListSortGetSelect();
2260 const mode = sel && sel.value ? sel.value : 'date_desc';
2261 if (!S || typeof S.sortNotesList !== 'function') return notes;
2262 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
2263 }
2264
2265 function applySortedProposalsClient(list) {
2266 const S = globalThis.HubListSort;
2267 const sel = hubListSortGetSelect();
2268 const mode = sel && sel.value ? sel.value : 'updated_desc';
2269 if (!S || typeof S.sortProposalsList !== 'function') return list;
2270 return S.sortProposalsList(list, mode);
2271 }
2272
2273 function normalizeHubListItem(n) {
2274 if (!n || typeof n !== 'object') return n;
2275 const fm = materializeFrontmatter(n.frontmatter);
2276 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
2277 const displayDate = listItemDisplayDate(n, fm);
2278 const updated =
2279 n.updated != null
2280 ? String(n.updated)
2281 : fm.knowtation_edited_at != null
2282 ? String(fm.knowtation_edited_at)
2283 : null;
2284 return {
2285 ...n,
2286 frontmatter: fm,
2287 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
2288 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
2289 tags,
2290 date: displayDate,
2291 updated,
2292 };
2293 }
2294
2295 function facetsAreEmpty(f) {
2296 if (!f || typeof f !== 'object') return true;
2297 const pl = f.projects && f.projects.length;
2298 const tl = f.tags && f.tags.length;
2299 const fl = f.folders && f.folders.length;
2300 return !pl && !tl && !fl;
2301 }
2302
2303 async function deriveFacetsFromNotes() {
2304 const out = await api('/api/v1/notes?limit=500&offset=0');
2305 const projects = new Set();
2306 const tags = new Set();
2307 const folders = new Set();
2308 for (const raw of out.notes || []) {
2309 const n = normalizeHubListItem(raw);
2310 if (n.path) {
2311 const seg = String(n.path).split('/')[0];
2312 if (seg) folders.add(seg);
2313 }
2314 if (n.project) projects.add(String(n.project));
2315 (n.tags || []).forEach((t) => tags.add(String(t)));
2316 }
2317 return {
2318 projects: [...projects].sort((a, b) => a.localeCompare(b)),
2319 tags: [...tags].sort((a, b) => a.localeCompare(b)),
2320 folders: [...folders].sort((a, b) => a.localeCompare(b)),
2321 };
2322 }
2323
2324 async function fetchFacetsResolved() {
2325 let facets = await api('/api/v1/notes/facets');
2326 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
2327 return facets;
2328 }
2329
2330 function hubRowIsApprovalLog(n) {
2331 if (!n || !n.path) return false;
2332 const path = String(n.path).replace(/\\/g, '/');
2333 if (path === 'approvals' || path.startsWith('approvals/')) return true;
2334 const k =
2335 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
2336 return String(k) === 'approval_log';
2337 }
2338
2339 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
2340 function applyVaultListFilters(notes, opts) {
2341 let out = notes.slice();
2342 if (opts.folder) {
2343 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
2344 const prefix = f + '/';
2345 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
2346 }
2347 if (opts.project) {
2348 const p = normSlug(opts.project);
2349 out = out.filter(
2350 (n) =>
2351 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
2352 );
2353 }
2354 if (opts.tag) {
2355 const t = normSlug(opts.tag);
2356 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
2357 }
2358 if (opts.since) {
2359 const s = dateSlice(opts.since);
2360 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
2361 }
2362 if (opts.until) {
2363 const u = dateSlice(opts.until);
2364 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
2365 }
2366 const cs = opts.content_scope;
2367 if (cs === 'notes') {
2368 out = out.filter((n) => !hubRowIsApprovalLog(n));
2369 } else if (cs === 'approval_logs') {
2370 out = out.filter((n) => hubRowIsApprovalLog(n));
2371 }
2372 if (opts.content_class) {
2373 const cc = String(opts.content_class).trim().toLowerCase();
2374 out = out.filter((n) => {
2375 const v = n.content_class ?? n.frontmatter?.content_class;
2376 return v != null && String(v).trim().toLowerCase() === cc;
2377 });
2378 }
2379 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
2380 if (opts.network) {
2381 const net = String(opts.network).trim().toLowerCase();
2382 out = out.filter((n) => {
2383 const v = n.frontmatter?.network ?? n.network;
2384 return v != null && String(v).trim().toLowerCase() === net;
2385 });
2386 }
2387 if (opts.wallet_address) {
2388 const wa = String(opts.wallet_address).trim().toLowerCase();
2389 out = out.filter((n) => {
2390 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
2391 return v != null && String(v).trim().toLowerCase() === wa;
2392 });
2393 }
2394 if (opts.payment_status) {
2395 const ps = String(opts.payment_status).trim().toLowerCase();
2396 out = out.filter((n) => {
2397 const v = n.frontmatter?.payment_status ?? n.payment_status;
2398 return v != null && String(v).trim().toLowerCase() === ps;
2399 });
2400 }
2401 return out;
2402 }
2403
2404 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
2405 const HUB_RESERVED_FM_KEYS = new Set([
2406 'knowtation_editor',
2407 'knowtation_edited_at',
2408 'author_kind',
2409 'knowtation_proposed_by',
2410 'knowtation_approved_by',
2411 ]);
2412
2413 function stripReservedHubFm(fm) {
2414 const out = {};
2415 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
2416 for (const [k, v] of Object.entries(fm)) {
2417 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
2418 out[k] = v;
2419 }
2420 return out;
2421 }
2422
2423 /**
2424 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
2425 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
2426 */
2427 function stringifyNotePostPayload(path, body, frontmatter) {
2428 const fmStr =
2429 typeof frontmatter === 'string'
2430 ? frontmatter
2431 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
2432 return JSON.stringify({ path, body, frontmatter: fmStr });
2433 }
2434
2435 const DETAIL_EDIT_FM_KEYS = [
2436 'title',
2437 'date',
2438 'project',
2439 'tags',
2440 'causal_chain_id',
2441 'entity',
2442 'episode_id',
2443 'follows',
2444 ];
2445
2446 function mergedFrontmatterForDetailSave() {
2447 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
2448 const preserved = {};
2449 for (const [k, v] of Object.entries(base)) {
2450 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
2451 }
2452 const dateVal =
2453 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
2454 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
2455 const tTitle = title.trim();
2456 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2457 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
2458 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
2459 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
2460 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
2461 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
2462 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
2463 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
2464 const follows = followsRaw
2465 ? followsRaw.includes(',')
2466 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
2467 : followsRaw
2468 : undefined;
2469 const out = { ...preserved, date: dateVal };
2470 if (tTitle) out.title = tTitle;
2471 else delete out.title;
2472 if (project) out.project = project;
2473 else delete out.project;
2474 if (tags) out.tags = tags;
2475 else delete out.tags;
2476 if (causalChain) out.causal_chain_id = causalChain;
2477 else delete out.causal_chain_id;
2478 if (entity.length) out.entity = entity;
2479 else delete out.entity;
2480 if (episode) out.episode_id = episode;
2481 else delete out.episode_id;
2482 if (follows) out.follows = follows;
2483 else delete out.follows;
2484 return out;
2485 }
2486
2487 function fillDetailEditFieldsFromFrontmatter(fm) {
2488 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
2489 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2490 const savedProj = f.project != null ? String(f.project).trim() : '';
2491 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
2492 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
2493 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
2494 if (el('detail-edit-project')) {
2495 const inp = el('detail-edit-project');
2496 if (pathProj) {
2497 inp.value = pathProj;
2498 inp.readOnly = true;
2499 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
2500 } else {
2501 inp.readOnly = false;
2502 inp.title = '';
2503 inp.value = savedProj;
2504 }
2505 }
2506 const hint = el('detail-edit-project-hint');
2507 if (hint) {
2508 if (pathProj) {
2509 hint.classList.remove('hidden');
2510 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
2511 hint.textContent = mismatch
2512 ? 'Path implies project «' +
2513 pathProj +
2514 '»; saved frontmatter had «' +
2515 savedProj +
2516 '». Saving will store «' +
2517 pathProj +
2518 '» to match the path.'
2519 : 'Project slug matches vault path projects/' + pathProj + '/.';
2520 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
2521 } else {
2522 hint.classList.remove('hidden');
2523 hint.className = 'muted small detail-project-hint';
2524 hint.textContent =
2525 '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.';
2526 }
2527 }
2528 const pathTypoEl = el('detail-edit-path-typo-hint');
2529 if (pathTypoEl && currentOpenNote) {
2530 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
2531 if (sug) {
2532 pathTypoEl.textContent =
2533 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
2534 sug +
2535 '. Rename or move the file in your vault (path cannot be edited here).';
2536 pathTypoEl.className = 'muted small detail-project-hint warn';
2537 pathTypoEl.classList.remove('hidden');
2538 } else {
2539 pathTypoEl.textContent = '';
2540 pathTypoEl.className = 'muted small detail-project-hint hidden';
2541 pathTypoEl.classList.add('hidden');
2542 }
2543 }
2544 const tags = f.tags;
2545 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
2546 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
2547 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
2548 const ent = f.entity;
2549 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
2550 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
2551 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
2552 const fol = f.follows;
2553 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
2554 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
2555 }
2556
2557 async function loadFacets() {
2558 try {
2559 const savedProject = filterProject.value;
2560 const savedTag = filterTag.value;
2561 const savedFolder = filterFolder.value;
2562 const savedNetwork = filterNetwork ? filterNetwork.value : '';
2563 const savedWallet = filterWallet ? filterWallet.value : '';
2564 const facets = await fetchFacetsResolved();
2565 lastHubFacets = facets;
2566 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
2567 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
2568 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
2569 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
2570 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
2571 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
2572 // Phase 12 — blockchain filter dropdowns (hidden when no data)
2573 if (filterNetwork) {
2574 const nets = facets.networks || [];
2575 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
2576 filterNetwork.classList.toggle('hidden', nets.length === 0);
2577 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
2578 }
2579 if (filterWallet) {
2580 const wallets = facets.wallets || [];
2581 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
2582 filterWallet.classList.toggle('hidden', wallets.length === 0);
2583 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
2584 }
2585 renderFilterChips(facets);
2586 hydrateFullCreateProjectSlugSelect(facets);
2587 hydrateImportCreateProjectSlugSelect(facets);
2588 } catch (_) {
2589 renderFilterChips(null);
2590 lastHubFacets = null;
2591 hydrateFullCreateProjectSlugSelect(null);
2592 hydrateImportCreateProjectSlugSelect(null);
2593 }
2594 }
2595
2596 function normSlug(s) {
2597 return String(s || '')
2598 .toLowerCase()
2599 .replace(/[^a-z0-9-]/g, '-')
2600 .replace(/-+/g, '-')
2601 .replace(/^-|-$/g, '');
2602 }
2603
2604 /**
2605 * First path segment after `projects/` (vault-relative). Used so project frontmatter
2606 * stays aligned with on-disk layout (projects/<slug>/…).
2607 */
2608 function projectSlugFromProjectsPath(path) {
2609 if (!path || typeof path !== 'string') return null;
2610 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
2611 return m ? m[1] : null;
2612 }
2613
2614 /**
2615 * Common typo: vault path starts with `project/` instead of `projects/`.
2616 * Returns the same path with the corrected prefix, or null if no typo.
2617 */
2618 function projectsPathTypoSuggestion(path) {
2619 const p = String(path || '').trim();
2620 if (!p) return null;
2621 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2622 return null;
2623 }
2624
2625 function normalizeProjectKeyForSimilarity(s) {
2626 return String(s || '')
2627 .toLowerCase()
2628 .trim()
2629 .replace(/[\s_]+/g, '-')
2630 .replace(/-+/g, '-')
2631 .replace(/^-|-$/g, '');
2632 }
2633
2634 function levenshteinHub(a, b) {
2635 const m = a.length;
2636 const n = b.length;
2637 if (!m) return n;
2638 if (!n) return m;
2639 const row = new Array(n + 1);
2640 for (let j = 0; j <= n; j++) row[j] = j;
2641 for (let i = 1; i <= m; i++) {
2642 let prev = row[0];
2643 row[0] = i;
2644 for (let j = 1; j <= n; j++) {
2645 const cur = row[j];
2646 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2647 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2648 prev = cur;
2649 }
2650 }
2651 return row[n];
2652 }
2653
2654 /**
2655 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2656 * Exact normSlug match returns null (no warning).
2657 */
2658 function findSimilarFacetProject(userSlug, projectsArr) {
2659 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2660 const uNorm = normSlug(String(userSlug));
2661 if (!uNorm) return null;
2662 for (const p of projectsArr) {
2663 if (normSlug(String(p)) === uNorm) return null;
2664 }
2665 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2666 let best = null;
2667 let bestScore = Infinity;
2668 for (const p of projectsArr) {
2669 const pv = String(p).trim();
2670 if (!pv) continue;
2671 const pNorm = normSlug(pv);
2672 if (!pNorm) continue;
2673 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2674 let score = Infinity;
2675 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2676 if (score > 0) {
2677 const a = normalizeProjectKeyForSimilarity(userSlug);
2678 const b = normalizeProjectKeyForSimilarity(pv);
2679 const d = levenshteinHub(a, b);
2680 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2681 }
2682 if (score > 0) {
2683 const a = normalizeProjectKeyForSimilarity(userSlug);
2684 const b = normalizeProjectKeyForSimilarity(pv);
2685 const shorter = a.length <= b.length ? a : b;
2686 const longer = a.length <= b.length ? b : a;
2687 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2688 score = Math.min(score, longer.length - shorter.length + 0.5);
2689 }
2690 }
2691 if (score < bestScore) {
2692 bestScore = score;
2693 best = pv;
2694 }
2695 }
2696 return bestScore < 10 ? best : null;
2697 }
2698
2699 function collectProjectSubroots(slug, folderStrings) {
2700 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2701 const subs = new Set();
2702 for (const f of folderStrings || []) {
2703 if (!f || typeof f !== 'string') continue;
2704 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2705 if (!n.startsWith(prefix)) continue;
2706 const rest = n.slice(prefix.length);
2707 if (!rest) continue;
2708 const first = rest.split('/')[0];
2709 if (first) subs.add(first);
2710 }
2711 return [...subs].sort((a, b) => a.localeCompare(b));
2712 }
2713
2714 function fullCreatePathFilename(pathVal) {
2715 const t = String(pathVal || '').trim();
2716 const parts = t.split('/').filter(Boolean);
2717 const last = parts[parts.length - 1];
2718 if (last && /\.md$/i.test(last)) return last;
2719 return 'note-' + Date.now() + '.md';
2720 }
2721
2722 function mergeFolderStringsForSubroots() {
2723 const out = new Set();
2724 for (const f of lastVaultFoldersForCreate || []) {
2725 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2726 }
2727 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2728 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2729 }
2730 return [...out];
2731 }
2732
2733 function updateFullCreatePathLayoutVisibility() {
2734 const slugSel = el('full-create-project-slug');
2735 const subWrap = el('full-create-project-subroot-wrap');
2736 const nonProj = el('full-create-nonproject-folder-wrap');
2737 const subSel = el('full-create-project-subroot');
2738 if (!slugSel) return;
2739 const v = slugSel.value;
2740 const useProject = v && v !== '__custom__';
2741 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2742 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2743 if (subSel) subSel.disabled = !useProject;
2744 }
2745
2746 function refreshFullCreateSubrootSelect() {
2747 const slugSel = el('full-create-project-slug');
2748 const subSel = el('full-create-project-subroot');
2749 if (!slugSel || !subSel) return;
2750 const slug = slugSel.value;
2751 const preserve = subSel.value;
2752 if (!slug || slug === '__custom__') {
2753 subSel.innerHTML = '';
2754 subSel.disabled = true;
2755 return;
2756 }
2757 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2758 const head = document.createElement('option');
2759 head.value = '';
2760 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2761 subSel.innerHTML = '';
2762 subSel.appendChild(head);
2763 for (const s of subs) {
2764 const o = document.createElement('option');
2765 o.value = s;
2766 o.textContent = s;
2767 subSel.appendChild(o);
2768 }
2769 const custom = document.createElement('option');
2770 custom.value = '__custom_sub__';
2771 custom.textContent = 'Custom (edit path)';
2772 subSel.appendChild(custom);
2773 subSel.disabled = false;
2774 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2775 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2776 else if (subs.includes('inbox')) subSel.value = 'inbox';
2777 else if (subs.length === 1) subSel.value = subs[0];
2778 else subSel.value = '';
2779 }
2780
2781 function composeFullPathFromCreatePickers() {
2782 const slugSel = el('full-create-project-slug');
2783 const subSel = el('full-create-project-subroot');
2784 const pathInp = el('full-path');
2785 if (!slugSel || !pathInp) return;
2786 const slugVal = slugSel.value;
2787 if (!slugVal || slugVal === '__custom__') return;
2788 if (subSel && subSel.value === '__custom_sub__') return;
2789 const sub =
2790 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2791 const fname = fullCreatePathFilename(pathInp.value);
2792 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2793 pathInp.value = base;
2794 }
2795
2796 function syncFullCreatePickersFromPath() {
2797 const slugSel = el('full-create-project-slug');
2798 const subSel = el('full-create-project-subroot');
2799 const pathInp = el('full-path');
2800 if (!slugSel || !pathInp) return;
2801 const raw = pathInp.value.trim();
2802 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2803 if (!m) {
2804 slugSel.value = raw ? '__custom__' : '';
2805 refreshFullCreateSubrootSelect();
2806 updateFullCreatePathLayoutVisibility();
2807 return;
2808 }
2809 const diskSlug = m[1];
2810 const rest = m[2];
2811 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2812 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2813 if (match) slugSel.value = match;
2814 else slugSel.value = '__custom__';
2815 refreshFullCreateSubrootSelect();
2816 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2817 const segments = rest.split('/').filter(Boolean);
2818 const lastSeg = segments[segments.length - 1];
2819 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2820 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2821 const firstDir = dirParts[0] || '';
2822 const allowed = new Set(
2823 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2824 );
2825 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2826 else if (firstDir) subSel.value = '__custom_sub__';
2827 else subSel.value = '';
2828 }
2829 updateFullCreatePathLayoutVisibility();
2830 }
2831
2832 function hydrateFullCreateProjectSlugSelect(facets) {
2833 const sel = el('full-create-project-slug');
2834 if (!sel) return;
2835 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2836 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2837 const preserve = sel.value;
2838 sel.innerHTML =
2839 '<option value="">— Not under projects/ —</option>' +
2840 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2841 '<option value="__custom__">Custom (type full path)</option>';
2842 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2843 refreshFullCreateSubrootSelect();
2844 updateFullCreatePathLayoutVisibility();
2845 }
2846
2847 function updateImportPathLayoutVisibility() {
2848 const slugSel = el('import-create-project-slug');
2849 const subWrap = el('import-create-project-subroot-wrap');
2850 const nonProj = el('import-nonproject-folder-wrap');
2851 const subSel = el('import-create-project-subroot');
2852 if (!slugSel) return;
2853 const v = slugSel.value;
2854 const useProject = v && v !== '__custom__';
2855 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2856 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2857 if (subSel) subSel.disabled = !useProject;
2858 }
2859
2860 function refreshImportCreateSubrootSelect() {
2861 const slugSel = el('import-create-project-slug');
2862 const subSel = el('import-create-project-subroot');
2863 if (!slugSel || !subSel) return;
2864 const slug = slugSel.value;
2865 const preserve = subSel.value;
2866 if (!slug || slug === '__custom__') {
2867 subSel.innerHTML = '';
2868 subSel.disabled = true;
2869 return;
2870 }
2871 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2872 const head = document.createElement('option');
2873 head.value = '';
2874 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2875 subSel.innerHTML = '';
2876 subSel.appendChild(head);
2877 for (const s of subs) {
2878 const o = document.createElement('option');
2879 o.value = s;
2880 o.textContent = s;
2881 subSel.appendChild(o);
2882 }
2883 const custom = document.createElement('option');
2884 custom.value = '__custom_sub__';
2885 custom.textContent = 'Custom (edit path)';
2886 subSel.appendChild(custom);
2887 subSel.disabled = false;
2888 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2889 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2890 else if (subs.includes('inbox')) subSel.value = 'inbox';
2891 else if (subs.length === 1) subSel.value = subs[0];
2892 else subSel.value = '';
2893 }
2894
2895 function composeImportOutputDirFromPickers() {
2896 const slugSel = el('import-create-project-slug');
2897 const subSel = el('import-create-project-subroot');
2898 const outInp = el('import-output-dir');
2899 if (!slugSel || !outInp) return;
2900 const slugVal = slugSel.value;
2901 if (!slugVal || slugVal === '__custom__') return;
2902 if (subSel && subSel.value === '__custom_sub__') return;
2903 const sub =
2904 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2905 const subUse = sub || 'inbox';
2906 outInp.value = 'projects/' + slugVal + '/' + subUse;
2907 }
2908
2909 function syncImportPickersFromOutputDir() {
2910 const slugSel = el('import-create-project-slug');
2911 const subSel = el('import-create-project-subroot');
2912 const outInp = el('import-output-dir');
2913 if (!slugSel || !outInp) return;
2914 const raw = outInp.value.trim().replace(/\/+$/, '');
2915 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2916 if (!m) {
2917 slugSel.value = raw ? '__custom__' : '';
2918 refreshImportCreateSubrootSelect();
2919 updateImportPathLayoutVisibility();
2920 return;
2921 }
2922 const diskSlug = m[1];
2923 const rest = m[2] || '';
2924 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2925 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2926 if (match) slugSel.value = match;
2927 else slugSel.value = '__custom__';
2928 refreshImportCreateSubrootSelect();
2929 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2930 const segments = rest.split('/').filter(Boolean);
2931 const firstDir = segments[0] || '';
2932 const allowed = new Set(
2933 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2934 );
2935 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2936 else if (firstDir) subSel.value = '__custom_sub__';
2937 else subSel.value = '';
2938 }
2939 updateImportPathLayoutVisibility();
2940 }
2941
2942 function hydrateImportCreateProjectSlugSelect(facets) {
2943 const sel = el('import-create-project-slug');
2944 if (!sel) return;
2945 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2946 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2947 const preserve = sel.value;
2948 sel.innerHTML =
2949 '<option value="">— Not under projects/ —</option>' +
2950 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2951 '<option value="__custom__">Custom (type full path)</option>';
2952 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2953 refreshImportCreateSubrootSelect();
2954 updateImportPathLayoutVisibility();
2955 }
2956
2957 function syncImportFolderSelectToOutputDir() {
2958 const outInp = el('import-output-dir');
2959 const sel = el('import-vault-folder');
2960 if (!outInp || !sel) return;
2961 const p = outInp.value.trim().replace(/\/+$/, '');
2962 if (!p) return;
2963 let best = '__custom__';
2964 let bestLen = -1;
2965 for (const opt of sel.options) {
2966 const v = opt.value;
2967 if (v === '__custom__') continue;
2968 if (p === v || p.startsWith(v + '/')) {
2969 if (v.length > bestLen) {
2970 best = v;
2971 bestLen = v.length;
2972 }
2973 }
2974 }
2975 sel.value = bestLen >= 0 ? best : '__custom__';
2976 }
2977
2978 function defaultImportOutputDir() {
2979 const slugSel = el('import-create-project-slug');
2980 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2981 const subSel = el('import-create-project-subroot');
2982 const sub =
2983 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2984 const subUse = sub || 'inbox';
2985 return 'projects/' + slugSel.value + '/' + subUse;
2986 }
2987 const sel = el('import-vault-folder');
2988 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2989 return folder;
2990 }
2991
2992 function getImportProjectAndOutputDir() {
2993 const outInp = el('import-output-dir');
2994 const slugSel = el('import-create-project-slug');
2995 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2996 if (raw) {
2997 const sug = projectsPathTypoSuggestion(raw);
2998 if (sug) {
2999 return {
3000 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
3001 project: '',
3002 outputDir: undefined,
3003 };
3004 }
3005 }
3006 const outputDir = raw || undefined;
3007 let project = '';
3008 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
3009 project = normSlug(slugSel.value);
3010 }
3011 if (!project && outputDir) {
3012 const m = outputDir.match(/^projects\/([^/]+)/);
3013 if (m) project = normSlug(m[1]);
3014 }
3015 return { err: null, project: project || '', outputDir: outputDir || undefined };
3016 }
3017
3018 function updateFullCreateSimilarInlineHint() {
3019 const hint = el('full-path-similar-hint');
3020 const btn = el('btn-full-path-use-similar-project');
3021 const pathInp = el('full-path');
3022 if (!hint || !pathInp) return;
3023 const notePath = pathInp.value.trim();
3024 const slug = projectSlugFromProjectsPath(notePath);
3025 const similar =
3026 slug && (lastHubFacets && lastHubFacets.projects)
3027 ? findSimilarFacetProject(slug, lastHubFacets.projects)
3028 : null;
3029 if (similar && notePath.startsWith('projects/')) {
3030 hint.textContent =
3031 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
3032 hint.className = 'muted small detail-project-hint warn';
3033 hint.classList.remove('hidden');
3034 if (btn) {
3035 btn.classList.remove('hidden');
3036 btn.onclick = () => {
3037 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
3038 pathInp.value = fixed;
3039 syncFolderSelectToPathInput();
3040 syncFullCreatePickersFromPath();
3041 syncFullProjectFromPath();
3042 updateFullPathProjectTypoHint();
3043 updateFullCreateSimilarInlineHint();
3044 };
3045 }
3046 } else {
3047 hint.textContent = '';
3048 hint.className = 'muted small detail-project-hint hidden';
3049 hint.classList.add('hidden');
3050 if (btn) {
3051 btn.classList.add('hidden');
3052 btn.onclick = null;
3053 }
3054 }
3055 }
3056
3057 function scheduleFullCreateSimilarHint() {
3058 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
3059 fullPathSimilarDebounceTimer = window.setTimeout(() => {
3060 fullPathSimilarDebounceTimer = 0;
3061 updateFullCreateSimilarInlineHint();
3062 }, 220);
3063 }
3064
3065 function openFullCreateSimilarModal(notePath, suggestedSlug) {
3066 const modal = el('modal-create-similar-project');
3067 const body = el('modal-create-similar-project-body');
3068 if (!modal || !body) return;
3069 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
3070 fullCreateSimilarModalPendingPath = notePath;
3071 const bad = projectSlugFromProjectsPath(notePath) || '…';
3072 body.textContent =
3073 'Your path starts with projects/' +
3074 bad +
3075 '/ but an existing project slug is «' +
3076 suggestedSlug +
3077 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
3078 modal.classList.remove('hidden');
3079 const focusBtn = el('btn-modal-create-similar-use-existing');
3080 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
3081 }
3082
3083 function closeFullCreateSimilarModal() {
3084 const modal = el('modal-create-similar-project');
3085 if (modal) modal.classList.add('hidden');
3086 fullCreateSimilarModalSuggestedSlug = '';
3087 fullCreateSimilarModalPendingPath = '';
3088 }
3089
3090 /** True when any list filter used by loadNotes / Quick chips is set. */
3091 function listFacetFiltersActive() {
3092 if (filterProject.value) return true;
3093 if (filterTag.value) return true;
3094 if (filterFolder.value) return true;
3095 if (filterNetwork && filterNetwork.value) return true;
3096 if (filterWallet && filterWallet.value) return true;
3097 const fps = el('filter-payment-status');
3098 if (fps && fps.value) return true;
3099 if (filterSince && filterSince.value) return true;
3100 if (filterUntil && filterUntil.value) return true;
3101 if (filterContentScope && filterContentScope.value) return true;
3102 return false;
3103 }
3104
3105 function clearListFacetFilters() {
3106 filterProject.value = '';
3107 filterTag.value = '';
3108 filterFolder.value = '';
3109 if (filterNetwork) filterNetwork.value = '';
3110 if (filterWallet) filterWallet.value = '';
3111 const fps = el('filter-payment-status');
3112 if (fps) fps.value = '';
3113 if (filterSince) filterSince.value = '';
3114 if (filterUntil) filterUntil.value = '';
3115 if (filterContentScope) filterContentScope.value = '';
3116 }
3117
3118 function renderFilterChips(facets) {
3119 filterChipsEl.innerHTML = '';
3120 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
3121
3122 const header = document.createElement('div');
3123 header.className = 'filter-chips-header';
3124
3125 const label = document.createElement('span');
3126 label.className = 'toolbar-label';
3127 label.textContent = 'Quick tags';
3128 label.title = 'Quick tags: project, tag, folder, and network filter chips (not the key glossary)';
3129
3130 const toggle = document.createElement('button');
3131 toggle.type = 'button';
3132 toggle.className = 'filter-chips-toggle';
3133 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
3134 toggle.setAttribute('aria-controls', 'filter-chips-panel');
3135 toggle.title = filterChipsExpanded
3136 ? 'Hide Quick tags filter chips'
3137 : 'Show Quick tags filter chips';
3138 toggle.setAttribute(
3139 'aria-label',
3140 filterChipsExpanded
3141 ? 'Collapse Quick tags filter chips'
3142 : 'Expand Quick tags filter chips',
3143 );
3144 toggle.innerHTML =
3145 '<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>';
3146 toggle.onclick = () => {
3147 filterChipsExpanded = !filterChipsExpanded;
3148 try {
3149 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
3150 } catch (_) {}
3151 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
3152 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
3153 toggle.title = filterChipsExpanded
3154 ? 'Hide Quick tags filter chips'
3155 : 'Show Quick tags filter chips';
3156 toggle.setAttribute(
3157 'aria-label',
3158 filterChipsExpanded
3159 ? 'Collapse Quick tags filter chips'
3160 : 'Expand Quick tags filter chips',
3161 );
3162 };
3163
3164 header.appendChild(label);
3165 header.appendChild(toggle);
3166 filterChipsEl.appendChild(header);
3167
3168 const panel = document.createElement('div');
3169 panel.id = 'filter-chips-panel';
3170 panel.className = 'filter-chips-panel';
3171 panel.setAttribute('role', 'region');
3172 panel.setAttribute('aria-label', 'Quick tags filter chips');
3173 filterChipsEl.appendChild(panel);
3174
3175 const allBtn = document.createElement('button');
3176 allBtn.type = 'button';
3177 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
3178 allBtn.textContent = 'All';
3179 allBtn.title =
3180 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
3181 allBtn.onclick = () => {
3182 searchQuery.value = '';
3183 clearListFacetFilters();
3184 switchNotesView('list');
3185 loadNotes();
3186 renderFilterChips(null);
3187 };
3188 panel.appendChild(allBtn);
3189
3190 const apply = (f) => {
3191 if (!f) return;
3192 (f.projects || []).slice(0, 12).forEach((p) => {
3193 const b = document.createElement('button');
3194 b.type = 'button';
3195 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
3196 b.textContent = 'project:' + p;
3197 b.onclick = () => {
3198 searchQuery.value = '';
3199 filterProject.value = p;
3200 filterTag.value = '';
3201 filterFolder.value = '';
3202 switchNotesView('list');
3203 loadNotes();
3204 renderFilterChips(null);
3205 };
3206 panel.appendChild(b);
3207 });
3208 (f.tags || []).slice(0, 10).forEach((t) => {
3209 const b = document.createElement('button');
3210 b.type = 'button';
3211 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
3212 b.textContent = 'tag:' + t;
3213 b.onclick = () => {
3214 searchQuery.value = '';
3215 filterTag.value = t;
3216 filterProject.value = '';
3217 filterFolder.value = '';
3218 switchNotesView('list');
3219 loadNotes();
3220 renderFilterChips(null);
3221 };
3222 panel.appendChild(b);
3223 });
3224 (f.folders || []).slice(0, 12).forEach((folder) => {
3225 const b = document.createElement('button');
3226 b.type = 'button';
3227 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
3228 b.textContent = 'folder:' + folder;
3229 b.onclick = () => {
3230 searchQuery.value = '';
3231 filterFolder.value = folder;
3232 filterProject.value = '';
3233 filterTag.value = '';
3234 switchNotesView('list');
3235 loadNotes();
3236 renderFilterChips(null);
3237 };
3238 panel.appendChild(b);
3239 });
3240 // Phase 12 — network chips
3241 (f.networks || []).slice(0, 8).forEach((net) => {
3242 const b = document.createElement('button');
3243 b.type = 'button';
3244 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
3245 b.textContent = 'net:' + net;
3246 b.onclick = () => {
3247 searchQuery.value = '';
3248 if (filterNetwork) filterNetwork.value = net;
3249 switchNotesView('list');
3250 loadNotes();
3251 renderFilterChips(null);
3252 };
3253 panel.appendChild(b);
3254 });
3255 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
3256 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
3257 const payStatuses = ['pending', 'settled', 'failed'];
3258 payStatuses.forEach((ps) => {
3259 const b = document.createElement('button');
3260 b.type = 'button';
3261 b.className = 'chip-btn chip-blockchain';
3262 b.textContent = 'status:' + ps;
3263 b.onclick = () => {
3264 searchQuery.value = '';
3265 const fpsEl = el('filter-payment-status');
3266 if (fpsEl) fpsEl.value = ps;
3267 switchNotesView('list');
3268 loadNotes();
3269 renderFilterChips(null);
3270 };
3271 panel.appendChild(b);
3272 });
3273 }
3274 };
3275 if (facets) apply(facets);
3276 else fetchFacetsResolved().then(apply).catch(() => {});
3277 }
3278
3279 function getPresets() {
3280 try {
3281 const raw = localStorage.getItem(PRESETS_KEY);
3282 return raw ? JSON.parse(raw) : [];
3283 } catch (_) {
3284 return [];
3285 }
3286 }
3287
3288 function savePreset() {
3289 const name = (presetNameInput.value || '').trim();
3290 if (!name) return;
3291 const presets = getPresets().filter((p) => p.name !== name);
3292 presets.push({
3293 name,
3294 project: filterProject.value,
3295 tag: filterTag.value,
3296 folder: filterFolder.value,
3297 since: filterSince?.value || '',
3298 until: filterUntil?.value || '',
3299 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
3300 });
3301 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
3302 presetNameInput.value = '';
3303 renderPresets();
3304 }
3305
3306 function renderPresets() {
3307 presetsListEl.innerHTML = '';
3308 getPresets().forEach((p) => {
3309 const b = document.createElement('button');
3310 b.type = 'button';
3311 b.className = 'preset-pill';
3312 b.textContent = p.name;
3313 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(' ');
3314 b.onclick = () => {
3315 filterProject.value = p.project || '';
3316 filterTag.value = p.tag || '';
3317 filterFolder.value = p.folder || '';
3318 if (filterSince) filterSince.value = p.since || '';
3319 if (filterUntil) filterUntil.value = p.until || '';
3320 if (filterContentScope) filterContentScope.value = p.content_scope || '';
3321 switchNotesView('list');
3322 loadNotes();
3323 renderFilterChips(null);
3324 };
3325 presetsListEl.appendChild(b);
3326 });
3327 }
3328
3329 el('btn-save-preset').onclick = savePreset;
3330
3331 function renderNoteRow(n) {
3332 const title = n.title || n.path;
3333 const isLog = hubRowIsApprovalLog(n);
3334 const chips = [];
3335 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
3336 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3337 const meta = [n.date].filter(Boolean).join(' · ');
3338 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3339 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3340 return (
3341 '<div class="' +
3342 rowClass +
3343 '" data-path="' +
3344 escapeHtml(n.path) +
3345 '"><span class="row-title">' +
3346 escapeHtml(title) +
3347 badge +
3348 '</span><div class="row-chips">' +
3349 chips.join('') +
3350 '</div>' +
3351 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
3352 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
3353 '</div>'
3354 );
3355 }
3356
3357 function bindNoteClicks(container) {
3358 container.querySelectorAll('.list-item').forEach((item) => {
3359 item.onclick = () => openNote(item.dataset.path);
3360 const delBtn = item.querySelector('.list-item-delete');
3361 if (delBtn) {
3362 delBtn.onclick = async (e) => {
3363 e.stopPropagation();
3364 const path = item.dataset.path;
3365 if (!path) return;
3366 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
3367 try {
3368 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
3369 if (typeof showToast === 'function') showToast('Deleted: ' + path);
3370 hubMarkSemanticIndexStale();
3371 if (currentOpenNote && currentOpenNote.path === path) {
3372 currentOpenNote = null;
3373 resetDetailSectionSourceState();
3374 hideDetailPanelChrome();
3375 }
3376 loadNotes();
3377 loadFacets();
3378 } catch (err) {
3379 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
3380 }
3381 };
3382 }
3383 });
3384 }
3385
3386 function hasActiveNoteListFilters() {
3387 if (filterProject && filterProject.value) return true;
3388 if (filterTag && filterTag.value) return true;
3389 if (filterFolder && filterFolder.value) return true;
3390 if (filterSince && filterSince.value) return true;
3391 if (filterUntil && filterUntil.value) return true;
3392 if (filterContentScope && filterContentScope.value) return true;
3393 if (filterNetwork && filterNetwork.value) return true;
3394 if (filterWallet && filterWallet.value) return true;
3395 const paymentStatusEl = el('filter-payment-status');
3396 if (paymentStatusEl && paymentStatusEl.value) return true;
3397 return false;
3398 }
3399
3400 function readOnboardingDismissedSync() {
3401 try {
3402 const raw = localStorage.getItem('knowtation_onboarding_v1');
3403 if (!raw) return false;
3404 const o = JSON.parse(raw);
3405 return Boolean(o && o.v === 1 && o.status === 'dismissed');
3406 } catch (_) {
3407 return false;
3408 }
3409 }
3410
3411 function isSearchResultsView() {
3412 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
3413 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
3414 }
3415
3416 function updateEmptyVaultStripVisibility() {
3417 const strip = el('hub-empty-vault-strip');
3418 if (!strip) return;
3419 const mainVisible = main && !main.classList.contains('hidden');
3420 const notesTab = getActiveHubMainTab() === 'notes';
3421 const q = searchQuery && String(searchQuery.value).trim();
3422 const show =
3423 Boolean(mainVisible && token) &&
3424 readOnboardingDismissedSync() &&
3425 hubBrowseListEmptyUnfiltered &&
3426 notesTab &&
3427 !q &&
3428 !isSearchResultsView();
3429 strip.classList.toggle('hidden', !show);
3430 }
3431
3432 async function loadNotes() {
3433 const q = new URLSearchParams();
3434 q.set('limit', '100');
3435 if (filterFolder.value) q.set('folder', filterFolder.value);
3436 if (filterProject.value) q.set('project', filterProject.value);
3437 if (filterTag.value) q.set('tag', filterTag.value);
3438 if (filterSince && filterSince.value) q.set('since', filterSince.value);
3439 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
3440 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
3441 if (filterContentClass && filterContentClass.value) q.set('content_class', filterContentClass.value);
3442 // Phase 12 — blockchain filters
3443 const networkVal = filterNetwork ? filterNetwork.value : '';
3444 const walletVal = filterWallet ? filterWallet.value : '';
3445 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
3446 if (networkVal) q.set('network', networkVal);
3447 if (walletVal) q.set('wallet_address', walletVal);
3448 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
3449 notesList.innerHTML = loadingHtml;
3450 notesTotal.textContent = '';
3451 try {
3452 const out = await api('/api/v1/notes?' + q.toString());
3453 let notes = (out.notes || []).map(normalizeHubListItem);
3454 notes = applyVaultListFilters(notes, {
3455 folder: filterFolder.value,
3456 project: filterProject.value,
3457 tag: filterTag.value,
3458 since: filterSince?.value || '',
3459 until: filterUntil?.value || '',
3460 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
3461 content_class: filterContentClass && filterContentClass.value ? filterContentClass.value : '',
3462 network: networkVal,
3463 wallet_address: walletVal,
3464 payment_status: paymentStatusVal,
3465 });
3466 notes = applySortedNotesClient(notes);
3467 const totalCount = notes.length;
3468 notes = notes.slice(0, 100);
3469 if (notes.length === 0) {
3470 notesList.innerHTML =
3471 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
3472 const ea = el('empty-add');
3473 if (ea) ea.onclick = () => openCreateModal();
3474 notesTotal.textContent = 'Total: 0';
3475 } else {
3476 notesList.innerHTML = notes.map(renderNoteRow).join('');
3477 notesTotal.textContent = 'Total: ' + totalCount;
3478 bindNoteClicks(notesList);
3479 listSelectedIndex = 0;
3480 updateListSelection();
3481 }
3482 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
3483 updateEmptyVaultStripVisibility();
3484 } catch (e) {
3485 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3486 notesTotal.textContent = '';
3487 hubBrowseListEmptyUnfiltered = false;
3488 updateEmptyVaultStripVisibility();
3489 }
3490 }
3491
3492 function switchHubMainTab(name) {
3493 closeHubMoreSheet();
3494 document.querySelectorAll('[data-tab].tab').forEach((t) => {
3495 t.classList.toggle('active', t.dataset.tab === name);
3496 });
3497 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3498 syncHubListSortUI(name);
3499 refreshNewProposalTabVisibility();
3500 const panel = el(
3501 'tab-' +
3502 (name === 'notes'
3503 ? 'notes'
3504 : name === 'activity'
3505 ? 'activity'
3506 : name === 'suggested'
3507 ? 'suggested'
3508 : 'problem'),
3509 );
3510 if (panel) panel.classList.remove('hidden');
3511 if (name === 'notes') {
3512 const graphPanel = el('notes-view-graph');
3513 if (graphPanel && !graphPanel.classList.contains('hidden')) {
3514 switchNotesView('list');
3515 } else {
3516 syncHubRailChrome(name);
3517 syncModeToolbars(name);
3518 }
3519 loadNotes();
3520 updateNeedsYouBanner(hubReviewBadgePrevCount);
3521 } else {
3522 syncHubRailChrome(name);
3523 syncModeToolbars(name);
3524 if (name === 'activity') loadActivity();
3525 if (name === 'suggested' || name === 'problem') loadProposals();
3526 updateEmptyVaultStripVisibility();
3527 updateNeedsYouBanner(hubReviewBadgePrevCount);
3528 }
3529 }
3530
3531 function updateListSelection() {
3532 const container = notesList;
3533 const items = container.querySelectorAll('.list-item');
3534 if (items.length === 0) { listSelectedIndex = 0; return; }
3535 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
3536 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
3537 const sel = items[listSelectedIndex];
3538 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3539 }
3540
3541 btnApplyFilters.onclick = () => {
3542 switchNotesView('list');
3543 loadNotes();
3544 renderFilterChips(null);
3545 syncVaultAdvancedFiltersOpen();
3546 };
3547
3548 if (filterContentScope) {
3549 filterContentScope.addEventListener('change', () => {
3550 switchNotesView('list');
3551 loadNotes();
3552 renderFilterChips(null);
3553 });
3554 }
3555
3556 function formatSearchScopeSummary() {
3557 const parts = [];
3558 if (filterProject.value) parts.push('project: ' + filterProject.value);
3559 if (filterTag.value) parts.push('tag: ' + filterTag.value);
3560 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
3561 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
3562 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
3563 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
3564 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
3565 return parts.length ? parts.join(' · ') : '';
3566 }
3567
3568 function semanticMatchStrengthLabel(score) {
3569 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3570 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3571 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
3572 }
3573
3574 function keywordMatchStrengthLabel(score) {
3575 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3576 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3577 return 'Keyword match ~' + pct + '% (text overlap)';
3578 }
3579
3580 if (btnClearSearch) {
3581 btnClearSearch.onclick = () => {
3582 searchQuery.value = '';
3583 clearListFacetFilters();
3584 switchNotesView('list');
3585 switchHubMainTab('notes');
3586 renderFilterChips(null);
3587 const adv = el('hub-search-advanced');
3588 if (adv && !hasActiveNoteListFilters()) adv.open = false;
3589 };
3590 }
3591
3592 function showToast(message, isError = false) {
3593 const toast = document.createElement('div');
3594 toast.className = 'toast' + (isError ? ' toast-err' : '');
3595 toast.textContent = message;
3596 toast.setAttribute('role', 'status');
3597 document.body.appendChild(toast);
3598 requestAnimationFrame(() => toast.classList.add('toast-show'));
3599 setTimeout(() => {
3600 toast.classList.remove('toast-show');
3601 setTimeout(() => toast.remove(), 300);
3602 }, 3000);
3603 }
3604
3605 const proposalFilterApply = el('proposal-filter-apply');
3606 if (proposalFilterApply) {
3607 proposalFilterApply.onclick = () => {
3608 loadProposals();
3609 loadActivity();
3610 syncPendingEvalQuickChip();
3611 };
3612 }
3613 const proposalFilterClear = el('proposal-filter-clear');
3614 if (proposalFilterClear) {
3615 proposalFilterClear.onclick = () => {
3616 const lf = el('proposal-filter-label');
3617 const sf = el('proposal-filter-source');
3618 const pf = el('proposal-filter-path-prefix');
3619 const pe = el('proposal-filter-pending-eval');
3620 const rq = el('proposal-filter-review-queue');
3621 const rs = el('proposal-filter-review-severity');
3622 if (lf) lf.value = '';
3623 if (sf) sf.value = '';
3624 if (pf) pf.value = '';
3625 if (pe) pe.checked = false;
3626 if (rq) rq.value = '';
3627 if (rs) rs.value = '';
3628 loadProposals();
3629 loadActivity();
3630 syncPendingEvalQuickChip();
3631 };
3632 }
3633 const pendingEvalChip = el('proposal-pending-eval-chip');
3634 if (pendingEvalChip) {
3635 pendingEvalChip.onclick = () => {
3636 const pe = el('proposal-filter-pending-eval');
3637 if (!pe) return;
3638 pe.checked = !pe.checked;
3639 syncPendingEvalQuickChip();
3640 loadProposals();
3641 loadActivity();
3642 };
3643 }
3644
3645 const hubListSortEl = hubListSortGetSelect();
3646 if (hubListSortEl) {
3647 hubListSortEl.addEventListener('change', () => {
3648 const tab = getActiveHubMainTab();
3649 try {
3650 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3651 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3652 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3653 }
3654 } catch (_) {}
3655 if (tab === 'notes') loadNotes();
3656 else if (tab === 'activity') loadActivity();
3657 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3658 });
3659 }
3660
3661 if (btnReindex) {
3662 btnReindex.onclick = async () => {
3663 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3664 try {
3665 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3666 // (see api() helper). Bridge may return one of three shapes:
3667 // 200 {ok:true, ...} → sync completed
3668 // 202 {status:'background', ...} → routed to bridge-index-background fn
3669 // 409 {status:'already_running'} → another background job in flight
3670 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3671 if (out && out.status === 'background') {
3672 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3673 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3674 } else if (out && out.status === 'already_running') {
3675 showToast(out.message || 'A background re-index is already running for this vault.');
3676 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3677 } else {
3678 const n = out.notesProcessed ?? 0;
3679 const c = out.chunksIndexed ?? 0;
3680 const skipped = out.chunksSkippedCached ?? 0;
3681 const embedded = out.chunksEmbedded ?? c;
3682 const detail = skipped > 0
3683 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3684 : '';
3685 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3686 hubClearSemanticIndexStale();
3687 loadFacets();
3688 loadNotes();
3689 hubLoadIndexStatus().catch(() => {});
3690 }
3691 } catch (e) {
3692 showToast(e.message || 'Re-index failed', true);
3693 }
3694 });
3695 };
3696 }
3697
3698 /*
3699 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3700 * Reads from `GET /api/v1/index/status` which both sync and background paths
3701 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3702 * background job is in flight so the line flips from
3703 * "Re-indexing in background…" → "Last indexed: just now"
3704 * without the user needing to click anything.
3705 */
3706 let _hubIndexStatusPollTimer = null;
3707 function hubFormatRelativeTime(epochMs) {
3708 if (!Number.isFinite(epochMs)) return '';
3709 const ageMs = Date.now() - epochMs;
3710 if (ageMs < 0) return 'just now';
3711 const sec = Math.round(ageMs / 1000);
3712 if (sec < 45) return 'just now';
3713 const min = Math.round(sec / 60);
3714 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3715 const hr = Math.round(min / 60);
3716 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3717 const days = Math.round(hr / 24);
3718 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3719 }
3720 async function hubLoadIndexStatus(opts) {
3721 opts = opts || {};
3722 const el = document.getElementById('hub-index-status');
3723 if (!el) return;
3724 let status;
3725 try {
3726 status = await api('/api/v1/index/status', { method: 'GET' });
3727 } catch (_) {
3728 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3729 el.textContent = '';
3730 el.classList.remove('hub-index-status-running');
3731 return;
3732 }
3733 if (status && status.inProgress) {
3734 el.textContent = 'Re-indexing in background…';
3735 el.classList.add('hub-index-status-running');
3736 // Keep polling so the line auto-clears when the background job finishes.
3737 // 5-second cadence matches typical embedding batch completion granularity
3738 // and stays well under any sane rate limit.
3739 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3740 _hubIndexStatusPollTimer = setInterval(() => {
3741 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3742 }, 5000);
3743 }
3744 return;
3745 }
3746 // No in-flight job — stop polling if we were.
3747 if (_hubIndexStatusPollTimer != null) {
3748 clearInterval(_hubIndexStatusPollTimer);
3749 _hubIndexStatusPollTimer = null;
3750 }
3751 el.classList.remove('hub-index-status-running');
3752 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3753 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3754 el.textContent = 'Last indexed: ' + rel;
3755 el.title =
3756 'Last successful index: ' +
3757 (status.lastIndexed.lastIndexedAt || '') +
3758 ' · ' +
3759 (status.lastIndexed.chunksIndexed || 0) +
3760 ' chunks · mode: ' +
3761 (status.lastIndexed.mode || 'sync');
3762 } else {
3763 el.textContent = '';
3764 el.title = '';
3765 }
3766 }
3767 // Kick off an initial status load once the user is logged in (the API call
3768 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3769 // that already happens after token validation succeeds.
3770 hubLoadIndexStatus().catch(() => {});
3771
3772 const hubIndexStaleRun = el('hub-index-stale-run');
3773 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3774 if (hubIndexStaleRun && btnReindex) {
3775 hubIndexStaleRun.onclick = () => {
3776 btnReindex.click();
3777 };
3778 }
3779 if (hubIndexStaleDismiss) {
3780 hubIndexStaleDismiss.onclick = () => {
3781 hubClearSemanticIndexStale();
3782 };
3783 }
3784
3785 function proposalFilterQuerySuffix() {
3786 const params = [];
3787 const lab = el('proposal-filter-label');
3788 const src = el('proposal-filter-source');
3789 const pre = el('proposal-filter-path-prefix');
3790 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3791 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3792 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3793 const pe = el('proposal-filter-pending-eval');
3794 if (pe && pe.checked) params.push('evaluation_status=pending');
3795 const rq = el('proposal-filter-review-queue');
3796 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3797 const rs = el('proposal-filter-review-severity');
3798 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3799 return params.length ? '&' + params.join('&') : '';
3800 }
3801
3802 // Discard a proposal directly from the list without opening the detail panel.
3803 async function discardProposalInline(id, itemEl) {
3804 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3805 try {
3806 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3807 if (typeof showToast === 'function') showToast('Proposal discarded.');
3808 const panel = el('detail-panel');
3809 if (panel && !panel.classList.contains('hidden')) {
3810 hideDetailPanelChrome();
3811 }
3812 loadProposals();
3813 loadActivity();
3814 } catch (err) {
3815 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3816 }
3817 }
3818
3819 async function loadProposals() {
3820 void refreshReviewBadge();
3821 syncPendingEvalQuickChip();
3822 const SI = hubShellIa();
3823 const primaryCta =
3824 SI && typeof SI.emptyReviewPrimaryCtaLabel === 'function'
3825 ? SI.emptyReviewPrimaryCtaLabel()
3826 : 'New proposal';
3827 const secondaryCta =
3828 SI && typeof SI.emptyReviewSecondaryCtaLabel === 'function'
3829 ? SI.emptyReviewSecondaryCtaLabel()
3830 : 'How Review works';
3831 const canCreate = hubUserCanWriteNotes();
3832 const emptySuggested =
3833 '<div class="empty-state empty-state-suggested">' +
3834 '<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>' +
3835 '<p class="empty-state-suggested-actions">' +
3836 (canCreate
3837 ? '<button type="button" class="btn-primary" id="empty-suggested-new">' +
3838 escapeHtml(primaryCta) +
3839 '</button>'
3840 : '') +
3841 '<button type="button" class="btn-secondary" id="empty-suggested-how-to">' +
3842 escapeHtml(secondaryCta) +
3843 '</button>' +
3844 '</p>' +
3845 '</div>';
3846 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3847 const fq = proposalFilterQuerySuffix();
3848 [
3849 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3850 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3851 ].forEach(({ kind, status, empty: emptyHtml }) => {
3852 const container = el('proposals-' + kind);
3853 if (!container) return;
3854 container.innerHTML = loadingHtml;
3855 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3856 .then((out) => {
3857 let list = out.proposals || [];
3858 list = applySortedProposalsClient(list);
3859 if (list.length === 0) {
3860 container.innerHTML = emptyHtml;
3861 if (kind === 'suggested') {
3862 proposalListIds = [];
3863 clearReviewSplitPosition();
3864 const how = container.querySelector('#empty-suggested-how-to');
3865 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3866 const neu = container.querySelector('#empty-suggested-new');
3867 if (neu) neu.onclick = () => openCreateProposalModal({});
3868 const peChip = el('proposal-pending-eval-chip');
3869 if (peChip && !peChip.classList.contains('hidden')) {
3870 // chip remains available above empty state when policy requires eval
3871 }
3872 }
3873 return;
3874 }
3875 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3876 if (kind === 'suggested') {
3877 proposalListIds = list.map((p) => String(p.proposal_id));
3878 proposalListSelectedIndex = 0;
3879 }
3880 container.innerHTML = list
3881 .map((p) => {
3882 const srcChip = p.source
3883 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3884 : '';
3885 const pendingChip =
3886 SI && typeof SI.reviewRowNeedsPendingEvalChip === 'function'
3887 ? SI.reviewRowNeedsPendingEvalChip(p.evaluation_status)
3888 : String(p.evaluation_status || '').toLowerCase() === 'pending';
3889 const pendingHtml = pendingChip
3890 ? '<span class="proposal-chip proposal-chip-pending-eval">Pending eval</span>'
3891 : '';
3892 const rel =
3893 SI && typeof SI.formatRelativeTime === 'function'
3894 ? SI.formatRelativeTime(p.updated_at || p.created_at)
3895 : '';
3896 const timeHtml = rel
3897 ? '<span class="row-time">' + escapeHtml(rel) + '</span>'
3898 : p.updated_at
3899 ? '<span class="row-time">' +
3900 escapeHtml(calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) +
3901 '</span>'
3902 : '';
3903 const discardBtn = canDiscard
3904 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3905 : '';
3906 return (
3907 '<div class="list-item review-row" data-id="' +
3908 escapeHtml(p.proposal_id) +
3909 '"><span class="row-title">' +
3910 escapeHtml(p.path) +
3911 '</span><div class="row-meta">' +
3912 srcChip +
3913 pendingHtml +
3914 timeHtml +
3915 '</div>' +
3916 discardBtn +
3917 '</div>'
3918 );
3919 })
3920 .join('');
3921 container.querySelectorAll('.list-item').forEach((item, idx) => {
3922 item.onclick = () => {
3923 proposalListSelectedIndex = idx;
3924 updateProposalListSelection(container);
3925 if (kind === 'suggested') {
3926 setReviewSplitPosition(idx + 1, list.length);
3927 }
3928 openProposal(item.dataset.id);
3929 };
3930 const db = item.querySelector('.list-item-delete');
3931 if (db) {
3932 db.onclick = (e) => {
3933 e.stopPropagation();
3934 discardProposalInline(item.dataset.id, item);
3935 };
3936 }
3937 });
3938 if (kind === 'suggested') updateProposalListSelection(container);
3939 })
3940 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3941 });
3942 }
3943
3944 async function loadActivity() {
3945 const container = el('proposals-activity');
3946 if (!container) return;
3947 container.innerHTML = loadingHtml;
3948 try {
3949 const fq = proposalFilterQuerySuffix();
3950 const out = await api('/api/v1/proposals?limit=100' + fq);
3951 let list = out.proposals || [];
3952 list = applySortedProposalsClient(list);
3953 if (list.length === 0) {
3954 container.innerHTML =
3955 '<div class="empty-state empty-state-activity">' +
3956 '<p>No proposal activity yet.</p>' +
3957 '<p class="muted small">Pending reviews from agents or the CLI appear under <strong>Review</strong> first; this view is the timeline once things move.</p>' +
3958 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Review</button></p>' +
3959 '</div>';
3960 const go = container.querySelector('#empty-activity-goto-suggested');
3961 if (go) go.onclick = () => switchHubMainTab('suggested');
3962 return;
3963 }
3964 const canDiscard = hubUserCanWriteNotes();
3965 container.innerHTML = list
3966 .map((p) => {
3967 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3968 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3969 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3970 // approved records stay as-is unless the user opens them.
3971 const showDiscard = canDiscard && p.status !== 'approved';
3972 const discardBtn = showDiscard
3973 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3974 : '';
3975 return (
3976 '<div class="list-item activity-item ' +
3977 statusClass +
3978 '" data-id="' +
3979 escapeHtml(p.proposal_id) +
3980 '"><span class="row-title">' +
3981 escapeHtml(p.path) +
3982 '</span><div class="status">' +
3983 escapeHtml(p.status) +
3984 ' · ' +
3985 escapeHtml(date) +
3986 '</div>' + discardBtn + '</div>'
3987 );
3988 })
3989 .join('');
3990 container.querySelectorAll('.list-item').forEach((item) => {
3991 item.onclick = () => openProposal(item.dataset.id);
3992 const db = item.querySelector('.list-item-delete');
3993 if (db) {
3994 db.onclick = (e) => {
3995 e.stopPropagation();
3996 discardProposalInline(item.dataset.id, item);
3997 };
3998 }
3999 });
4000 } catch (e) {
4001 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
4002 }
4003 }
4004
4005 async function runVaultSearch() {
4006 const query = searchQuery.value.trim();
4007 if (!query) return;
4008 hubBrowseListEmptyUnfiltered = false;
4009 updateEmptyVaultStripVisibility();
4010 const activeMainTab = getActiveHubMainTab();
4011 const useKeyword = searchMode && searchMode.value === 'keyword';
4012 if (activeMainTab && activeMainTab !== 'notes') {
4013 showToast(useKeyword ? 'Keyword results are shown under Vault.' : 'Semantic results are shown under Vault.');
4014 }
4015 switchNotesView('list');
4016 document.querySelectorAll('[data-tab].tab').forEach((t) => {
4017 t.classList.toggle('active', t.dataset.tab === 'notes');
4018 });
4019 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
4020 const tabNotes = el('tab-notes');
4021 if (tabNotes) tabNotes.classList.remove('hidden');
4022 setProposalFiltersBarVisible(false);
4023 refreshNewProposalTabVisibility();
4024 syncHubRailChrome('notes');
4025 syncModeToolbars('notes');
4026 syncHubListSortUI('notes');
4027 notesList.innerHTML = loadingHtml;
4028 notesTotal.textContent = '';
4029 const scopeSummary = formatSearchScopeSummary();
4030 const scopeSuffix = scopeSummary
4031 ? ' · scope: ' + scopeSummary
4032 : ' · scope: entire vault (use dropdowns to narrow)';
4033 try {
4034 const body = { query, limit: 20 };
4035 if (useKeyword) body.mode = 'keyword';
4036 if (filterProject.value) body.project = filterProject.value;
4037 if (filterTag.value) body.tag = filterTag.value;
4038 if (filterFolder.value) body.folder = filterFolder.value;
4039 if (filterSince && filterSince.value) body.since = filterSince.value;
4040 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
4041 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
4042 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
4043 const results = out.results || [];
4044 if (results.length === 0) {
4045 notesList.innerHTML = useKeyword
4046 ? '<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>'
4047 : '<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>';
4048 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
4049 return;
4050 }
4051 notesList.innerHTML = results
4052 .map((r) => {
4053 const chips = [];
4054 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
4055 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
4056 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
4057 const pathStr = String(r.path || '').replace(/\\/g, '/');
4058 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
4059 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
4060 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
4061 return (
4062 '<div class="' +
4063 rowClass +
4064 '" data-path="' +
4065 escapeHtml(r.path) +
4066 '"><span class="row-title">' +
4067 escapeHtml(r.path) +
4068 badge +
4069 '</span><div class="row-chips">' +
4070 chips.join('') +
4071 '</div>' +
4072 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
4073 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
4074 '</div>'
4075 );
4076 })
4077 .join('');
4078 notesTotal.textContent =
4079 results.length +
4080 (useKeyword ? ' keyword' : ' semantic') +
4081 ' result' +
4082 (results.length === 1 ? '' : 's') +
4083 scopeSuffix;
4084 bindNoteClicks(notesList);
4085 listSelectedIndex = 0;
4086 updateListSelection();
4087 } catch (e) {
4088 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
4089 notesTotal.textContent = '';
4090 }
4091 }
4092
4093 btnSearch.onclick = () => {
4094 void runVaultSearch();
4095 };
4096
4097 searchQuery.addEventListener('keydown', (e) => {
4098 if (e.key === 'Enter') {
4099 e.preventDefault();
4100 void runVaultSearch();
4101 }
4102 });
4103 searchQuery.addEventListener('input', () => {
4104 updateEmptyVaultStripVisibility();
4105 });
4106
4107 function switchNotesView(view) {
4108 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
4109 el('notes-view-list').classList.toggle('hidden', view !== 'list');
4110 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
4111 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
4112 if (view === 'calendar') renderCalendar();
4113 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
4114 syncHubRailChrome(getActiveHubMainTab());
4115 syncModeToolbars(getActiveHubMainTab());
4116 }
4117
4118 document.querySelectorAll('.view-tab').forEach((t) => {
4119 t.onclick = () => switchNotesView(t.dataset.view);
4120 });
4121
4122 function ymd(d) {
4123 const y = d.getFullYear();
4124 const m = String(d.getMonth() + 1).padStart(2, '0');
4125 const day = String(d.getDate()).padStart(2, '0');
4126 return y + '-' + m + '-' + day;
4127 }
4128
4129 async function renderCalendar() {
4130 const grid = el('calendar-grid');
4131 const title = el('cal-title');
4132 const dayList = el('calendar-day-list');
4133 const dayNotes = el('calendar-day-notes');
4134 dayList.classList.add('hidden');
4135 grid.classList.remove('hidden');
4136 el('calendar-nav').classList.remove('hidden');
4137
4138 const y = calendarMonth.getFullYear();
4139 const m = calendarMonth.getMonth();
4140 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
4141
4142 grid.innerHTML = loadingHtml;
4143 const first = new Date(y, m, 1);
4144 const last = new Date(y, m + 1, 0);
4145 const since = ymd(first);
4146 const until = ymd(last);
4147
4148 let notesInMonth = [];
4149 try {
4150 const q = new URLSearchParams({ since, until, limit: '100' });
4151 const out = await api('/api/v1/notes?' + q.toString());
4152 notesInMonth = (out.notes || [])
4153 .map(normalizeHubListItem)
4154 .filter((n) => {
4155 const ds = noteSortOrCalendarDay(n);
4156 return ds >= since && ds <= until;
4157 });
4158 } catch (_) {
4159 notesInMonth = [];
4160 }
4161
4162 const byDay = {};
4163 notesInMonth.forEach((n) => {
4164 const ds = noteSortOrCalendarDay(n);
4165 if (ds >= since && ds <= until) {
4166 byDay[ds] = (byDay[ds] || 0) + 1;
4167 }
4168 });
4169
4170 const startPad = first.getDay();
4171 const daysInMonth = last.getDate();
4172 const cells = [];
4173 const prevLast = new Date(y, m, 0).getDate();
4174 for (let i = 0; i < startPad; i++) {
4175 const d = prevLast - startPad + i + 1;
4176 cells.push({ out: true, day: d, key: null });
4177 }
4178 for (let d = 1; d <= daysInMonth; d++) {
4179 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
4180 }
4181 let nextMonthDay = 1;
4182 while (cells.length % 7 !== 0 || cells.length < 42) {
4183 cells.push({ out: true, day: nextMonthDay++, key: null });
4184 }
4185
4186 const today = ymd(new Date());
4187 grid.innerHTML = cells
4188 .map((c) => {
4189 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
4190 const cnt = byDay[c.key] || 0;
4191 const isToday = c.key === today;
4192 return (
4193 '<div class="cal-cell' +
4194 (isToday ? ' today' : '') +
4195 '" data-day="' +
4196 escapeHtml(c.key) +
4197 '"><span class="cal-day-num">' +
4198 c.day +
4199 '</span>' +
4200 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
4201 '</div>'
4202 );
4203 })
4204 .join('');
4205
4206 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
4207 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
4208 });
4209 }
4210
4211 el('cal-prev').onclick = () => {
4212 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
4213 renderCalendar();
4214 };
4215 el('cal-next').onclick = () => {
4216 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
4217 renderCalendar();
4218 };
4219 el('cal-back').onclick = () => {
4220 el('calendar-day-list').classList.add('hidden');
4221 el('calendar-grid').classList.remove('hidden');
4222 el('calendar-nav').classList.remove('hidden');
4223 };
4224
4225 function showCalendarDay(dayKey, notesInMonth) {
4226 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
4227 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
4228 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
4229 bindNoteClicks(el('calendar-day-notes'));
4230 el('calendar-grid').classList.add('hidden');
4231 el('calendar-nav').classList.add('hidden');
4232 el('calendar-day-list').classList.remove('hidden');
4233 }
4234
4235 async function fetchNotesForDashboard() {
4236 const all = [];
4237 let offset = 0;
4238 const limit = 100;
4239 let total = Infinity;
4240 while (offset < 500 && all.length < total) {
4241 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
4242 total = out.total ?? 0;
4243 const batch = (out.notes || []).map(normalizeHubListItem);
4244 all.push(...batch);
4245 if (batch.length < limit) break;
4246 offset += limit;
4247 }
4248 return { notes: all, total };
4249 }
4250
4251 async function renderDashboard() {
4252 chartInstances.forEach((c) => c.destroy());
4253 chartInstances = [];
4254 const cards = el('dashboard-cards');
4255 const foot = el('dashboard-footnote');
4256 cards.innerHTML = loadingHtml;
4257 foot.textContent = '';
4258
4259 let notes, total;
4260 try {
4261 const r = await fetchNotesForDashboard();
4262 notes = r.notes;
4263 total = r.total;
4264 } catch (e) {
4265 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
4266 return;
4267 }
4268
4269 const weekAgo = new Date();
4270 weekAgo.setDate(weekAgo.getDate() - 7);
4271 const weekStr = ymd(weekAgo);
4272 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
4273
4274 const byProject = {};
4275 const byTag = {};
4276 const byWeek = {};
4277 notes.forEach((n) => {
4278 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
4279 (n.tags || []).forEach((t) => {
4280 byTag[t] = (byTag[t] || 0) + 1;
4281 });
4282 const ds = noteSortOrCalendarDay(n);
4283 if (ds) {
4284 const w = ds.slice(0, 7);
4285 byWeek[w] = (byWeek[w] || 0) + 1;
4286 }
4287 });
4288
4289 const topProjects = Object.entries(byProject)
4290 .sort((a, b) => b[1] - a[1])
4291 .slice(0, 8);
4292 const topTags = Object.entries(byTag)
4293 .sort((a, b) => b[1] - a[1])
4294 .slice(0, 8);
4295 const weeks = Object.keys(byWeek).sort();
4296
4297 cards.innerHTML =
4298 '<div class="dash-card"><div class="dash-value">' +
4299 total +
4300 '</div><div class="dash-label">Notes (indexed)</div></div>' +
4301 '<div class="dash-card"><div class="dash-value">' +
4302 thisWeek +
4303 '</div><div class="dash-label">Last 7 days</div></div>' +
4304 '<div class="dash-card"><div class="dash-value">' +
4305 Object.keys(byProject).length +
4306 '</div><div class="dash-label">Projects</div></div>' +
4307 '<div class="dash-card"><div class="dash-value">' +
4308 Object.keys(byTag).length +
4309 '</div><div class="dash-label">Tags</div></div>';
4310
4311 if (notes.length < total) {
4312 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
4313 }
4314
4315 if (typeof Chart === 'undefined') {
4316 foot.textContent += ' Chart.js failed to load.';
4317 return;
4318 }
4319
4320 const commonOpts = {
4321 responsive: true,
4322 maintainAspectRatio: false,
4323 plugins: { legend: { labels: { color: '#a1a1a1' } } },
4324 scales: {
4325 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4326 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4327 },
4328 };
4329
4330 const ctxP = el('chart-projects').getContext('2d');
4331 chartInstances.push(
4332 new Chart(ctxP, {
4333 type: 'bar',
4334 data: {
4335 labels: topProjects.map((x) => x[0]),
4336 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
4337 },
4338 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
4339 })
4340 );
4341
4342 const ctxT = el('chart-tags').getContext('2d');
4343 chartInstances.push(
4344 new Chart(ctxT, {
4345 type: 'doughnut',
4346 data: {
4347 labels: topTags.map((x) => x[0]),
4348 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
4349 },
4350 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
4351 })
4352 );
4353
4354 const ctxL = el('chart-timeline').getContext('2d');
4355 chartInstances.push(
4356 new Chart(ctxL, {
4357 type: 'line',
4358 data: {
4359 labels: weeks,
4360 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 }],
4361 },
4362 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
4363 })
4364 );
4365 }
4366
4367 function resetDuplicateCreateState() {
4368 pendingDuplicateDeleteSource = null;
4369 const ban = el('duplicate-source-banner');
4370 if (ban) ban.classList.add('hidden');
4371 const chk = el('duplicate-delete-after-save');
4372 if (chk) chk.checked = false;
4373 const mt = el('modal-create-title');
4374 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
4375 const fs = el('btn-full-save');
4376 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
4377 }
4378
4379 function openCreateModal() {
4380 resetDuplicateCreateState();
4381 closeCreateProposalModal();
4382 closeFullCreateSimilarModal();
4383 hideDetailPanelChrome();
4384 el('modal-create').classList.remove('hidden');
4385 el('create-msg-quick').textContent = '';
4386 el('create-msg-quick').className = 'create-msg';
4387 el('create-msg-full').textContent = '';
4388 el('create-msg-full').className = 'create-msg';
4389 fullCreateSimilarOverrideOnce = false;
4390 if (token) {
4391 void (async () => {
4392 await refreshFullPathFolderSelect();
4393 if (!lastHubFacets) {
4394 try {
4395 lastHubFacets = await fetchFacetsResolved();
4396 } catch (_) {}
4397 }
4398 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4399 })();
4400 }
4401 }
4402
4403 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
4404 function suggestDuplicateVaultPath(srcPath) {
4405 const t = String(srcPath || '')
4406 .replace(/\\/g, '/')
4407 .trim();
4408 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
4409 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
4410 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
4411 }
4412
4413 function tagsInputFromFrontmatter(tagsVal) {
4414 if (tagsVal == null) return '';
4415 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
4416 return String(tagsVal).trim();
4417 }
4418
4419 /**
4420 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
4421 * Optional checkbox deletes the source path after a successful save (different path only).
4422 */
4423 async function openDuplicateNoteModal() {
4424 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
4425 if (!token) {
4426 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
4427 return;
4428 }
4429 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
4430 closeCreateProposalModal();
4431 closeFullCreateSimilarModal();
4432 el('modal-create').classList.remove('hidden');
4433 el('create-msg-quick').textContent = '';
4434 el('create-msg-quick').className = 'create-msg';
4435 el('create-msg-full').textContent = '';
4436 el('create-msg-full').className = 'create-msg';
4437 fullCreateSimilarOverrideOnce = false;
4438 const mt = el('modal-create-title');
4439 if (mt) mt.textContent = 'Duplicate note';
4440 const fs = el('btn-full-save');
4441 if (fs) fs.textContent = 'Save duplicate';
4442 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
4443 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
4444 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
4445 if (tabFull) tabFull.classList.add('active');
4446 if (tabQuick) tabQuick.classList.remove('active');
4447 el('create-quick').classList.add('hidden');
4448 el('create-full').classList.remove('hidden');
4449 if (token) {
4450 try {
4451 await refreshFullPathFolderSelect();
4452 if (!lastHubFacets) {
4453 try {
4454 lastHubFacets = await fetchFacetsResolved();
4455 } catch (_) {}
4456 }
4457 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4458 } catch (_) {}
4459 }
4460 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
4461 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
4462 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
4463 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
4464 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
4465 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
4466 if (el('full-entity')) {
4467 const ent = fm.entity;
4468 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
4469 }
4470 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
4471 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
4472 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
4473 if (el('full-path')) {
4474 el('full-path').value = sug;
4475 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
4476 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
4477 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
4478 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
4479 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
4480 }
4481 const dsp = el('duplicate-source-path');
4482 if (dsp) dsp.textContent = currentOpenNote.path;
4483 const ban = el('duplicate-source-banner');
4484 if (ban) ban.classList.remove('hidden');
4485 const chk = el('duplicate-delete-after-save');
4486 if (chk) chk.checked = false;
4487 }
4488
4489 function closeCreateModal() {
4490 closeFullCreateSimilarModal();
4491 resetDuplicateCreateState();
4492 el('modal-create').classList.add('hidden');
4493 }
4494 function closeCreateProposalModal() {
4495 const m = el('modal-create-proposal');
4496 if (m) m.classList.add('hidden');
4497 const pathInput = el('proposal-create-path');
4498 if (pathInput) pathInput.readOnly = false;
4499 }
4500 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
4501 function openCreateProposalModal(opts) {
4502 if (!token) {
4503 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
4504 return;
4505 }
4506 if (!hubUserCanWriteNotes()) {
4507 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
4508 return;
4509 }
4510 closeCreateModal();
4511 closeImportModal();
4512 hideDetailPanelChrome();
4513 const modal = el('modal-create-proposal');
4514 const pathInput = el('proposal-create-path');
4515 const hint = el('modal-create-proposal-hint');
4516 const bodyEl = el('proposal-create-body');
4517 const intentEl = el('proposal-create-intent');
4518 const msgEl = el('proposal-create-msg');
4519 if (!modal || !pathInput || !bodyEl || !intentEl) return;
4520 if (opts && opts.fromNote) {
4521 pathInput.readOnly = true;
4522 pathInput.value = opts.path || '';
4523 if (hint)
4524 hint.textContent =
4525 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
4526 } else {
4527 pathInput.readOnly = false;
4528 pathInput.value = (opts && opts.path) || '';
4529 if (hint)
4530 hint.textContent =
4531 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in Review.';
4532 }
4533 bodyEl.value = (opts && opts.body) || '';
4534 intentEl.value = (opts && opts.intent) || '';
4535 if (msgEl) {
4536 msgEl.textContent = '';
4537 msgEl.className = 'create-msg';
4538 }
4539 modal.classList.remove('hidden');
4540 }
4541 btnNewNote.onclick = openCreateModal;
4542 el('modal-create-backdrop').onclick = closeCreateModal;
4543 el('modal-create-close').onclick = closeCreateModal;
4544
4545 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
4546 const modalCreateProposalClose = el('modal-create-proposal-close');
4547 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
4548 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
4549
4550 const btnNewProposal = el('btn-new-proposal');
4551 if (btnNewProposal) {
4552 btnNewProposal.onclick = () => openCreateProposalModal({});
4553 }
4554
4555 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
4556 if (btnProposalCreateSubmit) {
4557 btnProposalCreateSubmit.onclick = async () => {
4558 const pathInput = el('proposal-create-path');
4559 const bodyInput = el('proposal-create-body');
4560 const intentInput = el('proposal-create-intent');
4561 const msgEl = el('proposal-create-msg');
4562 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
4563 if (!rawPath) {
4564 if (msgEl) {
4565 msgEl.textContent = 'Path is required.';
4566 msgEl.className = 'create-msg err';
4567 }
4568 return;
4569 }
4570 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
4571 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
4572 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
4573 try {
4574 await api('/api/v1/proposals', {
4575 method: 'POST',
4576 body: JSON.stringify({
4577 path: rawPath,
4578 body,
4579 ...(intent ? { intent } : {}),
4580 source: 'hub_ui',
4581 }),
4582 });
4583 closeCreateProposalModal();
4584 if (typeof showToast === 'function') showToast('Proposal submitted');
4585 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
4586 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
4587 const suggestedTab = document.querySelector('[data-tab="suggested"]');
4588 const suggestedPanel = el('tab-suggested');
4589 if (suggestedTab) suggestedTab.classList.add('active');
4590 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
4591 syncHubListSortUI('suggested');
4592 syncModeToolbars('suggested');
4593 refreshNewProposalTabVisibility();
4594 loadProposals();
4595 } catch (e) {
4596 if (msgEl) {
4597 msgEl.textContent = e.message || 'Proposal failed';
4598 msgEl.className = 'create-msg err';
4599 }
4600 }
4601 });
4602 };
4603 }
4604
4605 function syncImportSheetsBlock() {
4606 const sel = el('import-source-type');
4607 const block = el('import-sheets-block');
4608 if (block && sel) block.hidden = sel.value !== 'google-sheets';
4609 }
4610
4611 function openImportModal(preselectSourceType) {
4612 if (!token) {
4613 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
4614 return;
4615 }
4616 closeCreateModal();
4617 closeCreateProposalModal();
4618 hideDetailPanelChrome();
4619 el('modal-import').classList.remove('hidden');
4620 el('import-msg').textContent = '';
4621 if (importFileEl) importFileEl.value = '';
4622 if (importFileFolderEl) importFileFolderEl.value = '';
4623 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4624 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4625 setImportBatchAria('');
4626 clearImportDropPending();
4627 const urlIn = el('import-url');
4628 if (urlIn) urlIn.value = '';
4629 const sid = el('import-spreadsheet-id');
4630 const srange = el('import-sheets-range');
4631 if (sid) sid.value = '';
4632 if (srange) srange.value = '';
4633 const importSel = el('import-source-type');
4634 if (importSel && preselectSourceType) {
4635 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
4636 if (hasOption) importSel.value = preselectSourceType;
4637 }
4638 syncImportSheetsBlock();
4639 const outDirEl = el('import-output-dir');
4640 if (outDirEl) outDirEl.value = '';
4641 void (async () => {
4642 await refreshImportVaultFolderSelect();
4643 if (!lastHubFacets) {
4644 try {
4645 lastHubFacets = await fetchFacetsResolved();
4646 } catch (_) {}
4647 }
4648 hydrateImportCreateProjectSlugSelect(lastHubFacets);
4649 const out = el('import-output-dir');
4650 if (out) out.value = defaultImportOutputDir();
4651 syncImportFolderSelectToOutputDir();
4652 syncImportPickersFromOutputDir();
4653 updateImportPathLayoutVisibility();
4654 })();
4655 }
4656 function closeImportModal() {
4657 el('modal-import').classList.add('hidden');
4658 clearImportDropPending();
4659 }
4660 if (btnImport) btnImport.onclick = openImportModal;
4661 el('modal-import-backdrop').onclick = closeImportModal;
4662 el('modal-import-close').onclick = closeImportModal;
4663 const importSourceTypeEl = el('import-source-type');
4664 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
4665
4666 function closeProjectsHelpModal() {
4667 const m = el('modal-projects-help');
4668 if (m) m.classList.add('hidden');
4669 }
4670 function openProjectsHelpModal() {
4671 closeCreateModal();
4672 closeCreateProposalModal();
4673 hideDetailPanelChrome();
4674 const m = el('modal-projects-help');
4675 if (m) m.classList.remove('hidden');
4676 }
4677 const btnProjectsHelp = el('btn-projects-help');
4678 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
4679 const btnFullProjectHelp = el('btn-full-project-help');
4680 if (btnFullProjectHelp) {
4681 btnFullProjectHelp.onclick = () => {
4682 const m = el('modal-projects-help');
4683 if (m) m.classList.remove('hidden');
4684 };
4685 }
4686 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
4687 const modalProjectsHelpClose = el('modal-projects-help-close');
4688 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
4689 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
4690
4691 if (btnImportChooseFolder && importFileFolderEl) {
4692 btnImportChooseFolder.onclick = () => {
4693 importFileFolderEl.click();
4694 };
4695 }
4696 if (importFileFolderEl) {
4697 importFileFolderEl.addEventListener('change', () => {
4698 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4699 clearImportDropPending();
4700 if (importFileEl) importFileEl.value = '';
4701 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4702 }
4703 });
4704 }
4705 if (importFileEl) {
4706 importFileEl.addEventListener('change', () => {
4707 clearImportDropPending();
4708 if (importFileFolderEl) importFileFolderEl.value = '';
4709 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4710 });
4711 }
4712 if (importDropZoneEl) {
4713 let dragOverCount = 0;
4714 const setOver = (on) => {
4715 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4716 else importDropZoneEl.classList.remove('import-drop-zone--over');
4717 };
4718 importDropZoneEl.addEventListener('dragenter', (e) => {
4719 e.preventDefault();
4720 dragOverCount += 1;
4721 setOver(true);
4722 });
4723 importDropZoneEl.addEventListener('dragleave', (e) => {
4724 e.preventDefault();
4725 dragOverCount = Math.max(0, dragOverCount - 1);
4726 if (dragOverCount === 0) setOver(false);
4727 });
4728 importDropZoneEl.addEventListener('dragover', (e) => {
4729 e.preventDefault();
4730 e.stopPropagation();
4731 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4732 });
4733 importDropZoneEl.addEventListener('drop', (e) => {
4734 e.preventDefault();
4735 e.stopPropagation();
4736 dragOverCount = 0;
4737 setOver(false);
4738 const msgEl = el('import-msg');
4739 const p = (async () => {
4740 if (!e.dataTransfer) {
4741 if (msgEl) {
4742 msgEl.textContent = 'Drop did not include any files.';
4743 msgEl.className = 'create-msg err';
4744 }
4745 return;
4746 }
4747 let files;
4748 try {
4749 files = await collectFilesFromDataTransfer(e.dataTransfer);
4750 } catch (dropErr) {
4751 if (msgEl) {
4752 msgEl.textContent =
4753 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4754 msgEl.className = 'create-msg err';
4755 }
4756 return;
4757 }
4758 if (!files || files.length === 0) {
4759 if (msgEl) {
4760 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4761 msgEl.className = 'create-msg err';
4762 }
4763 return;
4764 }
4765 importPendingDropFiles = files;
4766 if (importFileEl) importFileEl.value = '';
4767 if (importFileFolderEl) importFileFolderEl.value = '';
4768 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4769 updateImportDropStatusUi();
4770 if (msgEl) {
4771 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4772 msgEl.className = 'create-msg';
4773 }
4774 })();
4775 p.catch((err) => {
4776 if (el('import-msg')) {
4777 const msg = el('import-msg');
4778 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4779 msg.className = 'create-msg err';
4780 }
4781 });
4782 });
4783 }
4784 if (importBatchCancelBtn) {
4785 importBatchCancelBtn.onclick = () => {
4786 if (importBatchAbort) importBatchAbort.abort();
4787 };
4788 }
4789
4790 /**
4791 * @param {string} postPath
4792 * @param {FormData} formData
4793 * @param {Record<string, string>} importHeaders
4794 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4795 */
4796 async function hubPostImportOnce(postPath, formData, importHeaders) {
4797 let res;
4798 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4799 try {
4800 res = await fetch(postPath, {
4801 method: 'POST',
4802 cache: 'no-store',
4803 headers: importHeaders,
4804 body: formData,
4805 });
4806 break;
4807 } catch (importErr) {
4808 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4809 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4810 await new Promise((r) => setTimeout(r, 3000));
4811 continue;
4812 }
4813 return { ok: false, errText: em, status: 0 };
4814 }
4815 }
4816 const text = await res.text();
4817 let data = {};
4818 try {
4819 data = text ? JSON.parse(text) : {};
4820 } catch (_) {
4821 data = {};
4822 }
4823 if (!res.ok) {
4824 let apiErr = '';
4825 if (data && typeof data === 'object') {
4826 const parts = [data.error, data.message, data.detail].filter(
4827 (x) => x != null && String(x).trim().length > 0,
4828 );
4829 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4830 }
4831 if (!apiErr && text) {
4832 const t = text.trim();
4833 if (t.startsWith('<')) {
4834 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4835 } else {
4836 apiErr = t.slice(0, 280);
4837 }
4838 }
4839 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4840 }
4841 return { ok: true, data };
4842 }
4843
4844 el('btn-import-submit').onclick = async () => {
4845 const importSubmitBtn = el('btn-import-submit');
4846 const sourceType = el('import-source-type').value;
4847 const fileInput = el('import-file');
4848 const urlInput = el('import-url');
4849 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4850 const msgEl = el('import-msg');
4851 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4852 const kz = globalThis.knowtationHubImportZip;
4853
4854 if (!token) {
4855 msgEl.textContent = 'Sign in to import.';
4856 msgEl.className = 'create-msg err';
4857 return;
4858 }
4859 const useUrlImport = urlTrim.length > 0;
4860 if (sourceType === 'url' && !useUrlImport) {
4861 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4862 msgEl.className = 'create-msg err';
4863 return;
4864 }
4865 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4866 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4867 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4868 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4869 const fileArr = usedDrop
4870 ? importPendingDropFiles
4871 : usedFolder
4872 ? Array.from(importFileFolderEl.files)
4873 : fileInput && fileInput.files
4874 ? Array.from(fileInput.files)
4875 : [];
4876 if (sourceType === 'google-sheets' && !useUrlImport) {
4877 if (!sheetId) {
4878 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4879 msgEl.className = 'create-msg err';
4880 return;
4881 }
4882 if (fileArr.length > 0) {
4883 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4884 msgEl.className = 'create-msg err';
4885 return;
4886 }
4887 }
4888 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4889 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4890 msgEl.className = 'create-msg err';
4891 return;
4892 }
4893 if (sourceType === 'notion' && fileArr.length > 1) {
4894 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4895 msgEl.className = 'create-msg err';
4896 return;
4897 }
4898
4899 const dest = getImportProjectAndOutputDir();
4900 if (dest.err) {
4901 msgEl.textContent = dest.err;
4902 msgEl.className = 'create-msg err';
4903 return;
4904 }
4905 const project = dest.project || '';
4906 const outputDir = dest.outputDir;
4907 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4908 const urlModeEl = el('import-url-mode');
4909 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4910 const importPostPath = apiBase + '/api/v1/import';
4911 const urlPostPath = apiBase + '/api/v1/import-url';
4912 const mode =
4913 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4914 ? kz.getHubImportFileMode(sourceType, fileArr)
4915 : 'direct';
4916
4917 if (!useUrlImport && !kz && fileArr.length > 1) {
4918 msgEl.textContent =
4919 '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.';
4920 msgEl.className = 'create-msg err';
4921 return;
4922 }
4923
4924 if (!useUrlImport && mode === 'client_zip' && !kz) {
4925 msgEl.textContent =
4926 '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.';
4927 msgEl.className = 'create-msg err';
4928 return;
4929 }
4930 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4931 msgEl.textContent =
4932 'Too many files for one batch (max ' +
4933 HUB_IMPORT_MAX_SEQUENTIAL +
4934 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4935 msgEl.className = 'create-msg err';
4936 return;
4937 }
4938
4939 if (useUrlImport) {
4940 const jsonBody = { url: urlTrim, mode: urlMode };
4941 if (project) jsonBody.project = project;
4942 if (outputDir) jsonBody.output_dir = outputDir;
4943 if (tags) jsonBody.tags = tags;
4944 msgEl.textContent = 'Importing…';
4945 msgEl.className = 'create-msg';
4946 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4947 try {
4948 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4949 const importVaultId = getCurrentVaultId();
4950 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4951 let res;
4952 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4953 try {
4954 res = await fetch(urlPostPath, {
4955 method: 'POST',
4956 cache: 'no-store',
4957 headers: importHeaders,
4958 body: JSON.stringify(jsonBody),
4959 });
4960 break;
4961 } catch (importErr) {
4962 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4963 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4964 await new Promise((r) => setTimeout(r, 3000));
4965 continue;
4966 }
4967 throw importErr;
4968 }
4969 }
4970 const text = await res.text();
4971 let data = {};
4972 try {
4973 data = text ? JSON.parse(text) : {};
4974 } catch (_) {
4975 data = {};
4976 }
4977 if (!res.ok) {
4978 let apiErr = '';
4979 if (data && typeof data === 'object') {
4980 const parts = [data.error, data.message, data.detail].filter(
4981 (x) => x != null && String(x).trim().length > 0,
4982 );
4983 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4984 }
4985 if (!apiErr && text) {
4986 const t = text.trim();
4987 if (t.startsWith('<')) {
4988 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4989 } else {
4990 apiErr = t.slice(0, 280);
4991 }
4992 }
4993 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4994 msgEl.className = 'create-msg err';
4995 return;
4996 }
4997 const count = data.count ?? data.imported?.length ?? 0;
4998 if (count === 0) {
4999 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
5000 msgEl.className = 'create-msg warn';
5001 } else {
5002 msgEl.textContent = 'Imported ' + count + ' note(s).';
5003 msgEl.className = 'create-msg ok';
5004 }
5005 if (count > 0) hubMarkSemanticIndexStale();
5006 if (typeof loadNotes === 'function') loadNotes();
5007 if (typeof loadFacets === 'function') loadFacets();
5008 if (typeof showToast === 'function') showToast('Import complete');
5009 setTimeout(() => closeImportModal(), 1500);
5010 } catch (e) {
5011 const raw = e && e.message ? String(e.message) : 'Import failed';
5012 const isNetwork =
5013 raw === 'Failed to fetch' ||
5014 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
5015 msgEl.textContent = isNetwork
5016 ? raw +
5017 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
5018 : raw;
5019 msgEl.className = 'create-msg err';
5020 }
5021 });
5022 return;
5023 }
5024
5025 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
5026 const importVaultId = getCurrentVaultId();
5027 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
5028
5029 if (mode === 'sequential') {
5030 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
5031 importBatchAbort = new AbortController();
5032 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
5033 msgEl.className = 'create-msg';
5034 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
5035 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
5036 const failures = [];
5037 let totalImported = 0;
5038 let okN = 0;
5039 for (let i = 0; i < fileArr.length; i++) {
5040 if (importBatchAbort && importBatchAbort.signal.aborted) {
5041 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
5042 break;
5043 }
5044 const f = fileArr[i];
5045 try {
5046 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
5047 } catch (limErr) {
5048 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
5049 continue;
5050 }
5051 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
5052 const fd = new FormData();
5053 fd.append('source_type', sourceType);
5054 fd.append('file', f);
5055 if (project) fd.append('project', project);
5056 if (outputDir) fd.append('output_dir', outputDir);
5057 if (tags) fd.append('tags', tags);
5058 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
5059 if (r.ok && r.data) {
5060 const c = r.data.count ?? r.data.imported?.length ?? 0;
5061 totalImported += typeof c === 'number' ? c : 0;
5062 okN++;
5063 } else {
5064 failures.push({ name: f.name, err: r.errText || 'error' });
5065 }
5066 }
5067 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
5068 importBatchAbort = null;
5069 const fl = failures.length
5070 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
5071 : '.';
5072 msgEl.textContent =
5073 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
5074 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
5075 setImportBatchAria(msgEl.textContent);
5076 if (totalImported > 0) hubMarkSemanticIndexStale();
5077 if (typeof loadNotes === 'function') loadNotes();
5078 if (typeof loadFacets === 'function') loadFacets();
5079 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
5080 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
5081 });
5082 return;
5083 }
5084
5085 msgEl.textContent = 'Importing…';
5086 msgEl.className = 'create-msg';
5087 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
5088 try {
5089 if (sourceType === 'google-sheets') {
5090 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
5091 ? el('import-spreadsheet-id').value.trim()
5092 : '';
5093 if (!sid) {
5094 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
5095 msgEl.className = 'create-msg err';
5096 return;
5097 }
5098 const rEl = el('import-sheets-range');
5099 const range = rEl && rEl.value ? rEl.value.trim() : '';
5100 const fd = new FormData();
5101 fd.append('source_type', 'google-sheets');
5102 fd.append('spreadsheet_id', sid);
5103 if (range) fd.append('sheets_range', range);
5104 if (project) fd.append('project', project);
5105 if (outputDir) fd.append('output_dir', outputDir);
5106 if (tags) fd.append('tags', tags);
5107 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
5108 if (!r.ok) {
5109 msgEl.textContent = r.errText || 'Import failed';
5110 msgEl.className = 'create-msg err';
5111 return;
5112 }
5113 const data = r.data || {};
5114 const count = data.count ?? data.imported?.length ?? 0;
5115 if (count === 0) {
5116 msgEl.textContent =
5117 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
5118 msgEl.className = 'create-msg warn';
5119 } else {
5120 msgEl.textContent = 'Imported ' + count + ' note(s).';
5121 msgEl.className = 'create-msg ok';
5122 }
5123 if (count > 0) hubMarkSemanticIndexStale();
5124 if (typeof loadNotes === 'function') loadNotes();
5125 if (typeof loadFacets === 'function') loadFacets();
5126 if (typeof showToast === 'function') showToast('Import complete');
5127 setTimeout(() => closeImportModal(), 1500);
5128 return;
5129 }
5130 const dupWarn = [];
5131 const warnFn = (s) => {
5132 dupWarn.push(s);
5133 };
5134 /** @type {FormData} */
5135 let formData;
5136 if (mode === 'client_zip' && kz) {
5137 const blob = await kz.buildImportZipBlob(fileArr, {
5138 signal: null,
5139 warn: warnFn,
5140 });
5141 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
5142 formData = new FormData();
5143 formData.append('source_type', sourceType);
5144 formData.append('file', fileOut);
5145 if (project) formData.append('project', project);
5146 if (outputDir) formData.append('output_dir', outputDir);
5147 if (tags) formData.append('tags', tags);
5148 if (dupWarn.length) {
5149 msgEl.className = 'create-msg';
5150 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
5151 }
5152 } else {
5153 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
5154 try {
5155 kz.assertSingleFileWithinLimit(fileArr[0]);
5156 } catch (e1) {
5157 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
5158 msgEl.className = 'create-msg err';
5159 return;
5160 }
5161 }
5162 formData = new FormData();
5163 formData.append('source_type', sourceType);
5164 formData.append('file', fileArr[0]);
5165 if (project) formData.append('project', project);
5166 if (outputDir) formData.append('output_dir', outputDir);
5167 if (tags) formData.append('tags', tags);
5168 }
5169 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
5170 if (!r.ok) {
5171 msgEl.textContent = r.errText || 'Import failed';
5172 msgEl.className = 'create-msg err';
5173 return;
5174 }
5175 const data = r.data || {};
5176 const count = data.count ?? data.imported?.length ?? 0;
5177 let extra = '';
5178 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
5179 if (count === 0) {
5180 const zeroMsg =
5181 sourceType === 'markdown'
5182 ? '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).'
5183 : sourceType === 'pdf'
5184 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
5185 : sourceType === 'docx'
5186 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
5187 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
5188 msgEl.textContent = zeroMsg + extra;
5189 msgEl.className = 'create-msg warn';
5190 } else {
5191 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
5192 msgEl.className = 'create-msg ok';
5193 }
5194 if (count > 0) hubMarkSemanticIndexStale();
5195 if (typeof loadNotes === 'function') loadNotes();
5196 if (typeof loadFacets === 'function') loadFacets();
5197 if (typeof showToast === 'function') showToast('Import complete');
5198 setTimeout(() => closeImportModal(), 1500);
5199 } catch (e) {
5200 const raw = e && e.message ? String(e.message) : 'Import failed';
5201 if (e && e.name === 'AbortError') {
5202 msgEl.textContent = 'Cancelled.';
5203 } else {
5204 const isNetwork =
5205 raw === 'Failed to fetch' ||
5206 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
5207 msgEl.textContent = isNetwork
5208 ? raw +
5209 ' — 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.'
5210 : raw;
5211 }
5212 msgEl.className = 'create-msg err';
5213 }
5214 });
5215 };
5216
5217 function openHowToUse(tabId, scrollToId) {
5218 const id = tabId || 'setup';
5219 el('modal-how-to-use').classList.remove('hidden');
5220 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
5221 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
5222 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
5223 if (scrollToId) {
5224 requestAnimationFrame(() => {
5225 const target = document.getElementById(scrollToId);
5226 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
5227 });
5228 }
5229 }
5230 function closeHowToUse() {
5231 el('modal-how-to-use').classList.add('hidden');
5232 }
5233 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
5234 const btnLoginHowToUse = el('btn-login-how-to-use');
5235 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
5236 const btnSettingsHelp = el('btn-settings-help');
5237 if (btnSettingsHelp) {
5238 btnSettingsHelp.onclick = () => {
5239 closeSettings();
5240 openHowToUse('knowledge-agents');
5241 };
5242 }
5243 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
5244 el('modal-how-to-use-close').onclick = closeHowToUse;
5245
5246 document.querySelectorAll('.how-to-tab').forEach((tab) => {
5247 tab.addEventListener('click', () => {
5248 const id = tab.dataset.howToTab;
5249 document.querySelectorAll('.how-to-tab').forEach((t) => {
5250 t.classList.toggle('active', t.dataset.howToTab === id);
5251 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
5252 });
5253 document.querySelectorAll('.how-to-panel').forEach((p) => {
5254 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
5255 });
5256 });
5257 });
5258
5259 const modalHowTo = el('modal-how-to-use');
5260 if (modalHowTo) {
5261 modalHowTo.addEventListener('click', (e) => {
5262 const t = e.target;
5263 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
5264 e.preventDefault();
5265 openHowToUse('consolidation');
5266 }
5267 });
5268 }
5269
5270 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
5271 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
5272 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
5273 btnHowToOpenOnboarding.addEventListener('click', () => {
5274 closeHowToUse();
5275 void openOnboardingWizard({ restart: false });
5276 });
5277 }
5278 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
5279 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
5280 btnEmptyStripWizard.dataset.knowtationBound = '1';
5281 btnEmptyStripWizard.addEventListener('click', () => {
5282 void openOnboardingWizard({ restart: true });
5283 });
5284 }
5285 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
5286 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
5287 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
5288 btnEmptyStripGettingStarted.addEventListener('click', () => {
5289 openHowToUse('getting-started');
5290 });
5291 }
5292
5293 function openTokenSavingsHowToFromSettings() {
5294 closeSettings();
5295 openHowToUse('token-savings');
5296 }
5297 const btnConsolToken = el('btn-consol-how-token-savings');
5298 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
5299 const btnIntegToken = el('btn-integrations-how-token-savings');
5300 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
5301 const btnAgentsToken = el('btn-agents-how-token-savings');
5302 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
5303
5304 function openSettings() {
5305 refreshApiBaseFootgunBanner();
5306 closeCreateModal();
5307 el('modal-settings').classList.remove('hidden');
5308 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
5309 document.querySelectorAll('.settings-panel').forEach((p) => {
5310 p.classList.toggle('active', p.id === 'settings-panel-backup');
5311 });
5312 syncAccentUI();
5313 syncThemeUI();
5314 syncColorPaletteUI();
5315 refreshIntegApiStatus();
5316 el('settings-sync-msg').textContent = '';
5317 el('settings-sync-msg').className = 'settings-msg';
5318 el('settings-save-msg').textContent = '';
5319 el('settings-save-msg').className = 'settings-msg';
5320 const policyMsg = el('settings-proposal-policy-msg');
5321 if (policyMsg) {
5322 policyMsg.textContent = '';
5323 policyMsg.className = 'settings-msg';
5324 }
5325 el('settings-mode-display').textContent = 'Loading…';
5326 el('settings-vault-display').textContent = 'Loading…';
5327 el('settings-git-status').textContent = 'Loading…';
5328 const ghStatus = el('settings-github-status');
5329 if (ghStatus) ghStatus.textContent = 'Loading…';
5330 fetchSettingsForBackupModal()
5331 .then((s) => {
5332 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
5333 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
5334 throw new Error(
5335 '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.',
5336 );
5337 }
5338 applySettingsPayloadToHubChrome(s);
5339 const roleEl = el('settings-role-display');
5340 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
5341 const userIdEl = el('settings-user-id');
5342 if (userIdEl) userIdEl.textContent = s.user_id || '—';
5343 const vaultDisplay = s.vault_path_display || '—';
5344 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
5345 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
5346 el('settings-vault-display').textContent = vaultDisplay;
5347 const configureSection = el('settings-configure-backup-section');
5348 const configureHr = el('settings-hr-configure');
5349 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
5350 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
5351 const vg = s.vault_git || {};
5352 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
5353 const step1 = document.getElementById('setup-step-1');
5354 const step4 = document.getElementById('setup-step-4');
5355 const step1Label = el('setup-step-1-label');
5356 const step1Hint = el('setup-step-1-hint');
5357 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
5358 if (step1Hint) {
5359 step1Hint.textContent = isHosted
5360 ? 'Your notes live in your hosted vault'
5361 : 'Set below under Configure backup';
5362 }
5363 if (step1) {
5364 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
5365 step1.classList.toggle('setup-step-done', done);
5366 const icon = step1.querySelector('.setup-step-icon');
5367 if (icon) icon.textContent = done ? '✓' : '';
5368 }
5369 if (step4) {
5370 const done = !!(vg.enabled && vg.has_remote);
5371 step4.classList.toggle('setup-step-done', done);
5372 const icon = step4.querySelector('.setup-step-icon');
5373 if (icon) icon.textContent = done ? '✓' : '';
5374 }
5375 let gitText = 'Not configured';
5376 if (vg.enabled && vg.has_remote) {
5377 gitText = 'Configured';
5378 if (vg.auto_commit) gitText += ' (auto-commit on)';
5379 if (vg.auto_push) gitText += ', auto-push on';
5380 } else if (vg.enabled) gitText = 'Enabled but no remote set';
5381 el('settings-git-status').textContent = gitText;
5382 const evalReqEl = el('settings-proposal-eval-required');
5383 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
5384 const hintsEl = el('settings-proposal-hints-enabled');
5385 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
5386 const enrichStatusEl = el('settings-proposal-enrich-enabled');
5387 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
5388 const evApEl = el('settings-evaluator-may-approve');
5389 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
5390 const syncBtn = el('btn-settings-sync');
5391 const isAdmin = s.role === 'admin';
5392 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
5393 const saveSetupBtn = el('btn-settings-save');
5394 if (saveSetupBtn) {
5395 saveSetupBtn.disabled = false;
5396 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
5397 }
5398 const teamTab = el('settings-tab-team');
5399 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
5400 const vaultsTab = el('settings-tab-vaults');
5401 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
5402 const policyAdmin = el('settings-proposal-policy-admin');
5403 const storedPolicy = s.proposal_policy_stored || {};
5404 const policyLocks = s.proposal_policy_env_locked || {};
5405 if (policyAdmin) {
5406 policyAdmin.classList.toggle('hidden', !isAdmin);
5407 const cEval = el('settings-policy-eval');
5408 const cHints = el('settings-policy-hints');
5409 const cEnrich = el('settings-policy-enrich');
5410 if (cEval && cHints && cEnrich) {
5411 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
5412 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
5413 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
5414 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
5415 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
5416 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
5417 const lockHint =
5418 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5419 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
5420 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
5421 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
5422 }
5423 }
5424 const connectBtn = el('btn-connect-github');
5425 const ghStatus = el('settings-github-status');
5426 const hostedGhHint = el('settings-hosted-connect-github-hint');
5427 if (s.github_connect_available) {
5428 if (connectBtn) {
5429 connectBtn.classList.remove('hidden');
5430 connectBtn.onclick = () => {
5431 const base = apiBase.replace(/\/$/, '');
5432 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
5433 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
5434 };
5435 }
5436 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
5437 } else {
5438 if (connectBtn) {
5439 connectBtn.classList.add('hidden');
5440 connectBtn.onclick = null;
5441 }
5442 if (ghStatus) ghStatus.textContent = '—';
5443 }
5444 if (hostedGhHint) {
5445 const vd = s.vault_path_display || '';
5446 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
5447 }
5448 const hostedRepoSection = el('settings-hosted-backup-repo-section');
5449 const hostedRepoInput = el('settings-hosted-repo');
5450 if (hostedRepoSection) {
5451 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
5452 }
5453 if (hostedRepoInput && isHosted && s.github_connect_available) {
5454 if (!hostedRepoInput.value.trim()) {
5455 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
5456 }
5457 if (!hostedRepoInput.dataset.knowtationBound) {
5458 hostedRepoInput.dataset.knowtationBound = '1';
5459 hostedRepoInput.addEventListener('input', () => {
5460 const syncBtn = el('btn-settings-sync');
5461 if (!syncBtn || !lastBackupSettingsPayload) return;
5462 const vd = lastBackupSettingsPayload.vault_path_display || '';
5463 const ih = (vd + '').toLowerCase() === 'canister';
5464 if (ih && lastBackupSettingsPayload.github_connect_available) {
5465 const vg = lastBackupSettingsPayload.vault_git || {};
5466 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
5467 }
5468 });
5469 }
5470 }
5471 const ed = s.embedding_display || {};
5472 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
5473 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
5474 const ollamaRow = el('agents-ollama-row');
5475 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
5476 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
5477 applyChatProviderSettings(s);
5478 const apiRow = el('settings-api-base-row');
5479 const apiDisp = el('settings-api-base-display');
5480 if (apiRow && apiDisp) {
5481 if (isLocalHubHostname()) {
5482 apiRow.classList.remove('hidden');
5483 apiDisp.textContent = apiBase;
5484 } else {
5485 apiRow.classList.add('hidden');
5486 }
5487 }
5488 refreshApiBaseFootgunBanner();
5489 void refreshBulkDeletePresetDropdowns();
5490 })
5491 .catch((e) => {
5492 const syncMsg = el('settings-sync-msg');
5493 if (syncMsg) {
5494 const m = e && e.message ? String(e.message) : 'Could not load settings.';
5495 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
5496 syncMsg.className = 'settings-msg err';
5497 }
5498 if (typeof console !== 'undefined' && console.error) {
5499 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
5500 }
5501 const hostedGhHint = el('settings-hosted-connect-github-hint');
5502 if (hostedGhHint) hostedGhHint.classList.add('hidden');
5503 const roleEl = el('settings-role-display');
5504 if (roleEl) roleEl.textContent = '—';
5505 const userIdEl = el('settings-user-id');
5506 if (userIdEl) userIdEl.textContent = '—';
5507 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
5508 el('settings-vault-display').textContent = '—';
5509 el('settings-git-status').textContent = 'Could not load';
5510 const evalReqErr = el('settings-proposal-eval-required');
5511 if (evalReqErr) evalReqErr.textContent = '—';
5512 const hintsErr = el('settings-proposal-hints-enabled');
5513 if (hintsErr) hintsErr.textContent = '—';
5514 const enrichErr = el('settings-proposal-enrich-enabled');
5515 if (enrichErr) enrichErr.textContent = '—';
5516 const evApErr = el('settings-evaluator-may-approve');
5517 if (evApErr) evApErr.textContent = '—';
5518 const configureSection = el('settings-configure-backup-section');
5519 const configureHr = el('settings-hr-configure');
5520 if (configureSection) configureSection.style.display = '';
5521 if (configureHr) configureHr.style.display = '';
5522 const ghStatus = el('settings-github-status');
5523 if (ghStatus) ghStatus.textContent = '—';
5524 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
5525 const apiRowErr = el('settings-api-base-row');
5526 const apiDispErr = el('settings-api-base-display');
5527 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
5528 apiRowErr.classList.remove('hidden');
5529 apiDispErr.textContent = apiBase;
5530 }
5531 refreshApiBaseFootgunBanner();
5532 });
5533 api('/api/v1/setup')
5534 .then((u) => {
5535 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
5536 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
5537 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
5538 })
5539 .catch(() => {});
5540 }
5541 function closeSettings() {
5542 el('modal-settings').classList.add('hidden');
5543 }
5544 function openSettingsBillingTab() {
5545 openSettings();
5546 document.querySelectorAll('.settings-tab').forEach((t) => {
5547 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
5548 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
5549 });
5550 document.querySelectorAll('.settings-panel').forEach((p) => {
5551 p.classList.toggle('active', p.id === 'settings-panel-billing');
5552 });
5553 loadBillingPanel();
5554 }
5555
5556 function openSettingsIntegrationsTab() {
5557 openSettings();
5558 document.querySelectorAll('.settings-tab').forEach((t) => {
5559 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
5560 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
5561 });
5562 document.querySelectorAll('.settings-panel').forEach((p) => {
5563 p.classList.toggle('active', p.id === 'settings-panel-integrations');
5564 });
5565 refreshIntegApiStatus();
5566 applyMuseBridgePanel(lastBackupSettingsPayload);
5567 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
5568 }
5569
5570 if (btnSettings) btnSettings.onclick = openSettings;
5571
5572 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
5573 if (btnSettingsSetupGuide) {
5574 btnSettingsSetupGuide.addEventListener('click', () => {
5575 closeSettings();
5576 void openOnboardingWizard({ restart: true });
5577 });
5578 }
5579
5580 const btnProposalPolicySave = el('btn-proposal-policy-save');
5581 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
5582 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
5583 btnProposalPolicySave.addEventListener('click', async () => {
5584 const msg = el('settings-proposal-policy-msg');
5585 if (msg) {
5586 msg.textContent = '';
5587 msg.className = 'settings-msg';
5588 }
5589 try {
5590 await api('/api/v1/settings/proposal-policy', {
5591 method: 'POST',
5592 body: JSON.stringify({
5593 proposal_evaluation_required: el('settings-policy-eval').checked,
5594 review_hints_enabled: el('settings-policy-hints').checked,
5595 enrich_enabled: el('settings-policy-enrich').checked,
5596 }),
5597 });
5598 if (msg) {
5599 msg.textContent = 'Saved.';
5600 msg.className = 'settings-msg ok';
5601 }
5602 const fresh = await fetchSettingsForBackupModal();
5603 applySettingsPayloadToHubChrome(fresh);
5604 const evalReqEl = el('settings-proposal-eval-required');
5605 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
5606 const hintsEl2 = el('settings-proposal-hints-enabled');
5607 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
5608 const enrichEl2 = el('settings-proposal-enrich-enabled');
5609 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
5610 const st = fresh.proposal_policy_stored || {};
5611 const lk = fresh.proposal_policy_env_locked || {};
5612 const ce = el('settings-policy-eval');
5613 const ch = el('settings-policy-hints');
5614 const cr = el('settings-policy-enrich');
5615 if (ce && ch && cr) {
5616 ce.checked = Boolean(st.proposal_evaluation_required);
5617 ch.checked = Boolean(st.review_hints_enabled);
5618 cr.checked = Boolean(st.enrich_enabled);
5619 ce.disabled = Boolean(lk.proposal_evaluation_required);
5620 ch.disabled = Boolean(lk.review_hints_enabled);
5621 cr.disabled = Boolean(lk.enrich_enabled);
5622 const lockHint =
5623 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5624 ce.title = lk.proposal_evaluation_required ? lockHint : '';
5625 ch.title = lk.review_hints_enabled ? lockHint : '';
5626 cr.title = lk.enrich_enabled ? lockHint : '';
5627 }
5628 } catch (e) {
5629 if (msg) {
5630 msg.textContent = e && e.message ? String(e.message) : String(e);
5631 msg.className = 'settings-msg err';
5632 }
5633 }
5634 });
5635 }
5636 el('modal-settings-backdrop').onclick = closeSettings;
5637 el('modal-settings-close').onclick = closeSettings;
5638
5639 el('btn-copy-env-agentception').onclick = () => {
5640 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
5641 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
5642 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
5643 const lines = [];
5644 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
5645 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
5646 }
5647 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
5648 const snippet = lines.join('\n');
5649 const msg = el('agents-copy-msg');
5650 if (navigator.clipboard && navigator.clipboard.writeText) {
5651 navigator.clipboard.writeText(snippet).then(() => {
5652 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
5653 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
5654 }).catch(() => {
5655 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
5656 });
5657 } else {
5658 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
5659 }
5660 };
5661
5662 function refreshIntegApiStatus() {
5663 var dot = el('integ-api-status');
5664 if (!dot) return;
5665 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
5666 dot.classList.toggle('active', hasToken);
5667 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
5668 }
5669
5670 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
5671 let activeIntegGuide = null;
5672
5673 function closeIntegGuideModal() {
5674 const modal = el('modal-integ-guide');
5675 if (modal) modal.classList.add('hidden');
5676 activeIntegGuide = null;
5677 }
5678
5679 function openIntegGuideModal(guide) {
5680 const mod = globalThis.HubIntegrationGuides;
5681 if (!mod || !guide) return;
5682 const modal = el('modal-integ-guide');
5683 const iconEl = el('modal-integ-guide-icon');
5684 const nameEl = el('modal-integ-guide-name');
5685 const leadEl = el('modal-integ-guide-lead');
5686 const contentEl = el('modal-integ-guide-content');
5687 const importBtn = el('btn-integ-guide-import');
5688 const teamBtn = el('btn-integ-guide-team');
5689 const msgEl = el('modal-integ-guide-msg');
5690 if (!modal || !contentEl) return;
5691 activeIntegGuide = guide;
5692 if (iconEl) iconEl.textContent = guide.icon || '';
5693 if (nameEl) nameEl.textContent = guide.name || 'Integration';
5694 if (leadEl) {
5695 leadEl.textContent =
5696 guide.kind === 'capture'
5697 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
5698 : guide.desc || 'Import files or exports into your vault.';
5699 }
5700 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5701 if (msgEl) msgEl.textContent = '';
5702 if (importBtn) {
5703 const importSel = el('import-source-type');
5704 const canPreselect =
5705 guide.hubImport &&
5706 guide.sourceType &&
5707 importSel &&
5708 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5709 const showImport =
5710 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5711 importBtn.classList.toggle('hidden', !showImport);
5712 importBtn.textContent =
5713 guide.id === 'hermes'
5714 ? 'Open Import (Markdown)'
5715 : guide.id === 'imports'
5716 ? 'Open Import'
5717 : 'Open Import';
5718 }
5719 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5720 modal.classList.remove('hidden');
5721 }
5722
5723 let integGuideControlsBound = false;
5724
5725 function bindIntegrationGuideModalControlsOnce() {
5726 if (integGuideControlsBound) return;
5727 integGuideControlsBound = true;
5728 const backdrop = el('modal-integ-guide-backdrop');
5729 const closeBtn = el('modal-integ-guide-close');
5730 const importBtn = el('btn-integ-guide-import');
5731 const teamBtn = el('btn-integ-guide-team');
5732 const contentEl = el('modal-integ-guide-content');
5733 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5734 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5735 if (contentEl) {
5736 contentEl.addEventListener('click', (ev) => {
5737 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5738 if (!btn) return;
5739 const text = btn.getAttribute('data-copy') || '';
5740 const msgEl = el('modal-integ-guide-msg');
5741 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5742 navigator.clipboard.writeText(text).then(() => {
5743 if (msgEl) {
5744 msgEl.textContent = 'Copied.';
5745 msgEl.className = 'settings-msg ok';
5746 }
5747 setTimeout(() => {
5748 if (msgEl) msgEl.textContent = '';
5749 }, 2000);
5750 }).catch(() => {
5751 if (msgEl) {
5752 msgEl.textContent = 'Copy failed';
5753 msgEl.className = 'settings-msg err';
5754 }
5755 });
5756 } else if (msgEl) {
5757 msgEl.textContent = 'Clipboard not available';
5758 msgEl.className = 'settings-msg err';
5759 }
5760 });
5761 }
5762 if (importBtn) {
5763 importBtn.onclick = () => {
5764 const guide = activeIntegGuide;
5765 closeIntegGuideModal();
5766 closeSettings();
5767 const preselect =
5768 guide && guide.id === 'hermes'
5769 ? 'markdown'
5770 : guide && guide.sourceType
5771 ? guide.sourceType
5772 : undefined;
5773 openImportModal(preselect);
5774 };
5775 }
5776 if (teamBtn) {
5777 teamBtn.onclick = () => {
5778 closeIntegGuideModal();
5779 openSettings();
5780 document.querySelectorAll('.settings-tab').forEach((t) => {
5781 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5782 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5783 });
5784 document.querySelectorAll('.settings-panel').forEach((p) => {
5785 p.classList.toggle('active', p.id === 'settings-panel-team');
5786 });
5787 };
5788 }
5789 document.addEventListener('click', (ev) => {
5790 const tile =
5791 ev.target instanceof Element
5792 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5793 : null;
5794 if (!tile) return;
5795 const mod = globalThis.HubIntegrationGuides;
5796 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5797 if (typeof showToast === 'function') {
5798 showToast('Integration details still loading — try again in a moment.', true);
5799 }
5800 scheduleIntegrationGuidesInit(0);
5801 return;
5802 }
5803 const id = tile.getAttribute('data-integ-id');
5804 const guide = id ? mod.getIntegrationGuide(id) : null;
5805 if (guide) {
5806 ev.preventDefault();
5807 openIntegGuideModal(guide);
5808 }
5809 });
5810 }
5811
5812 function scheduleIntegrationGuidesInit(attempt) {
5813 bindIntegrationGuideModalControlsOnce();
5814 if (globalThis.HubIntegrationGuides) return;
5815 if (attempt >= 80) return;
5816 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5817 }
5818
5819 scheduleIntegrationGuidesInit(0);
5820
5821 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5822 if (btnCopyMcpPrime) {
5823 btnCopyMcpPrime.onclick = () => {
5824 const base = String(apiBase || '').replace(/\/$/, '');
5825 const vaultId = getCurrentVaultId() || 'default';
5826 const msg = el('integrations-hub-api-copy-msg');
5827 const payload = {
5828 schema: 'knowtation.hub_copy_prime/v1',
5829 mcp_read_resource_uri: 'knowtation://hosted/prime',
5830 instructions:
5831 'Non-secret snapshot (no JWT): gateway URL, optional KNOWTATION_MCP_URL, vault id. For secrets and which URL to use for REST vs MCP vs local CLI, use "Copy Hub URL, token & vault" and read ' +
5832 INTEGRATION_DOC_URL,
5833 KNOWTATION_HUB_URL: base,
5834 KNOWTATION_HUB_VAULT_ID: vaultId,
5835 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5836 };
5837 const snippet = JSON.stringify(payload, null, 2);
5838 if (navigator.clipboard && navigator.clipboard.writeText) {
5839 navigator.clipboard.writeText(snippet).then(() => {
5840 if (msg) {
5841 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5842 msg.className = 'settings-msg';
5843 }
5844 setTimeout(() => {
5845 if (msg) msg.textContent = '';
5846 }, 2800);
5847 }).catch(() => {
5848 if (msg) {
5849 msg.textContent = 'Copy failed';
5850 msg.className = 'settings-msg err';
5851 }
5852 });
5853 } else if (msg) {
5854 msg.textContent = 'Clipboard not available';
5855 msg.className = 'settings-msg err';
5856 }
5857 };
5858 }
5859
5860 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5861 if (btnCopyHubApiEnv) {
5862 btnCopyHubApiEnv.onclick = async () => {
5863 const msg = el('integrations-hub-api-copy-msg');
5864 const fresh = await ensureFreshHumanSession(120);
5865 if (!fresh.ok) {
5866 if (msg) {
5867 msg.textContent =
5868 fresh.code === 'session_unavailable'
5869 ? 'Session could not be checked. Retry.'
5870 : 'Session expired. Sign in again before copying.';
5871 msg.className = 'settings-msg err';
5872 }
5873 return;
5874 }
5875 const hubTok = fresh.token;
5876 const vaultId = getCurrentVaultId() || 'default';
5877 const base = String(apiBase || '').replace(/\/$/, '');
5878 const copyLines = [
5879 'KNOWTATION_HUB_URL=' + base,
5880 'KNOWTATION_HUB_TOKEN=' + hubTok,
5881 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5882 ];
5883 if (mcpPublicUrl !== '') {
5884 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5885 }
5886 copyLines.push('');
5887 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5888 copyLines.push(
5889 '# Example curl (append these headers to any Hub REST call): ' +
5890 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5891 '-H "Content-Type: application/json" ' +
5892 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5893 );
5894 const snippet = copyLines.join('\n');
5895 if (navigator.clipboard && navigator.clipboard.writeText) {
5896 navigator.clipboard.writeText(snippet).then(() => {
5897 if (msg) {
5898 msg.textContent = 'Copied session access token (expires — not for always-on agents).';
5899 msg.className = 'settings-msg';
5900 }
5901 refreshIntegApiStatus();
5902 setTimeout(() => {
5903 if (msg) msg.textContent = '';
5904 }, 3500);
5905 }).catch(() => {
5906 if (msg) {
5907 msg.textContent = 'Copy failed';
5908 msg.className = 'settings-msg err';
5909 }
5910 });
5911 } else if (msg) {
5912 msg.textContent = 'Clipboard not available';
5913 msg.className = 'settings-msg err';
5914 }
5915 };
5916 }
5917
5918 /** Settings → Integrations → Connect cloud agent (RFC 8628 device approval). */
5919 /** Device auth mounts on the persistent MCP host — not Netlify api.knowtation.store. */
5920 function deviceAuthBase() {
5921 if (mcpPublicUrl) {
5922 try {
5923 const u = new URL(mcpPublicUrl);
5924 return u.origin;
5925 } catch (_) { /* fall through */ }
5926 }
5927 return String(apiBase || '').replace(/\/$/, '');
5928 }
5929
5930 function setDeviceConnectMsg(text, isErr) {
5931 const msg = el('device-connect-msg');
5932 if (!msg) return;
5933 msg.textContent = text || '';
5934 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5935 }
5936
5937 async function refreshDevicePendingList() {
5938 const list = el('device-pending-list');
5939 if (!list) return;
5940 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5941 if (!hubTok) {
5942 list.innerHTML = '<li>Sign in to see pending agent codes.</li>';
5943 return;
5944 }
5945 try {
5946 const res = await fetch(deviceAuthBase() + '/api/v1/auth/device/pending', {
5947 headers: { Authorization: 'Bearer ' + hubTok },
5948 credentials: 'omit',
5949 });
5950 if (!res.ok) {
5951 list.innerHTML = '<li>Pending list unavailable on this host (device auth mounts on the persistent MCP gateway).</li>';
5952 return;
5953 }
5954 const data = await res.json();
5955 const pending = Array.isArray(data.pending) ? data.pending : [];
5956 if (pending.length === 0) {
5957 list.innerHTML = '<li>No pending cloud-agent codes.</li>';
5958 return;
5959 }
5960 list.innerHTML = pending
5961 .map(function (p) {
5962 const code = String(p.userCode || '').replace(/[<>&]/g, '');
5963 const name = String(p.clientName || p.clientId || 'agent').replace(/[<>&]/g, '');
5964 return '<li><strong>' + code + '</strong> — ' + name + '</li>';
5965 })
5966 .join('');
5967 } catch (_) {
5968 list.innerHTML = '<li>Could not load pending codes.</li>';
5969 }
5970 }
5971
5972 async function postDeviceApproveOrDeny(path) {
5973 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5974 const input = el('device-user-code-input');
5975 const userCode = input ? String(input.value || '').trim() : '';
5976 if (!hubTok) {
5977 setDeviceConnectMsg('Sign in first.', true);
5978 return;
5979 }
5980 if (!userCode) {
5981 setDeviceConnectMsg('Enter the user code shown by your agent.', true);
5982 return;
5983 }
5984 try {
5985 const res = await fetch(deviceAuthBase() + path, {
5986 method: 'POST',
5987 headers: {
5988 Authorization: 'Bearer ' + hubTok,
5989 'Content-Type': 'application/json',
5990 },
5991 credentials: 'omit',
5992 body: JSON.stringify({
5993 user_code: userCode,
5994 vault_id: getCurrentVaultId() || 'default',
5995 }),
5996 });
5997 const data = await res.json().catch(function () { return {}; });
5998 if (!res.ok) {
5999 setDeviceConnectMsg(data.error || ('Request failed (' + res.status + ')'), true);
6000 return;
6001 }
6002 setDeviceConnectMsg(path.indexOf('deny') >= 0 ? 'Denied.' : 'Approved — agent can finish polling.', false);
6003 if (input) input.value = '';
6004 refreshDevicePendingList();
6005 } catch (_) {
6006 setDeviceConnectMsg('Network error talking to device auth endpoint.', true);
6007 }
6008 }
6009
6010 const btnDeviceApprove = el('btn-device-approve');
6011 if (btnDeviceApprove) {
6012 btnDeviceApprove.onclick = function () {
6013 postDeviceApproveOrDeny('/api/v1/auth/device/approve');
6014 };
6015 }
6016 const btnDeviceDeny = el('btn-device-deny');
6017 if (btnDeviceDeny) {
6018 btnDeviceDeny.onclick = function () {
6019 postDeviceApproveOrDeny('/api/v1/auth/device/deny');
6020 };
6021 }
6022 const btnDeviceRefreshPending = el('btn-device-refresh-pending');
6023 if (btnDeviceRefreshPending) {
6024 btnDeviceRefreshPending.onclick = function () {
6025 refreshDevicePendingList();
6026 };
6027 }
6028 const btnCopyCloudSetupPack = el('btn-copy-cloud-setup-pack');
6029 if (btnCopyCloudSetupPack) {
6030 btnCopyCloudSetupPack.onclick = function () {
6031 const pack =
6032 '# Knowtation cloud agent setup (NO SECRETS)\n' +
6033 '# MCP URL: https://mcp.knowtation.store/mcp\n' +
6034 '# Prefer: Hub Settings → Integrations → Connect cloud agent (device code)\n' +
6035 '# Interim (Hostinger Hermes): desktop mcp-remote OAuth → copy ~/.mcp-auth/mcp-remote-* to agent HOME\n' +
6036 '# → Hermes stdio: npx -y mcp-remote https://mcp.knowtation.store/mcp\n' +
6037 '# DO NOT: paste Hub session JWT into always-on .env\n' +
6038 '# DO NOT: use api.knowtation.store/mcp or Netlify /mcp\n' +
6039 '# Full guide: docs/AGENT-INTEGRATION.md (Always-on cloud agents)\n';
6040 if (navigator.clipboard && navigator.clipboard.writeText) {
6041 navigator.clipboard.writeText(pack).then(function () {
6042 setDeviceConnectMsg('Copied non-secret setup pack.', false);
6043 }).catch(function () {
6044 setDeviceConnectMsg('Copy failed', true);
6045 });
6046 } else {
6047 setDeviceConnectMsg('Clipboard not available', true);
6048 }
6049 };
6050 }
6051 try {
6052 var _ucParams = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
6053 var _uc = _ucParams ? _ucParams.get('user_code') : null;
6054 if (!_uc && typeof location !== 'undefined' && location.hash && location.hash.indexOf('user_code=') >= 0) {
6055 var _hq = location.hash.split('?')[1] || '';
6056 _uc = new URLSearchParams(_hq).get('user_code');
6057 }
6058 if (_uc && el('device-user-code-input')) {
6059 el('device-user-code-input').value = String(_uc).toUpperCase();
6060 }
6061 } catch (_) { /* ignore */ }
6062
6063 /** Settings → Integrations → Agent credentials (REST / Paperclip / cron) — Phase C. */
6064 function setAgentCredMsg(text, isErr) {
6065 const msg = el('agent-cred-msg');
6066 if (!msg) return;
6067 msg.textContent = text || '';
6068 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
6069 }
6070
6071 function agentCredSessionRefusal(resp) {
6072 if (!resp) return 'Session unavailable.';
6073 if (resp.sessionCode === 'session_unavailable') return 'Session could not be checked. Retry.';
6074 if (resp.sessionCode === 'session_expired' || resp.status === 401) {
6075 return 'Session expired. Sign in again.';
6076 }
6077 return null;
6078 }
6079
6080 function formatAgentCredTs(ms) {
6081 if (ms == null || !Number.isFinite(Number(ms))) return '—';
6082 try {
6083 return new Date(Number(ms)).toISOString().slice(0, 19) + 'Z';
6084 } catch (_) {
6085 return '—';
6086 }
6087 }
6088
6089 /** Populate vault multi-select (freeze §8); default current vault selected. */
6090 function refreshAgentCredVaultSelect() {
6091 const sel = el('agent-cred-vault-select');
6092 if (!sel) return;
6093 const current = String(getCurrentVaultId() || 'default');
6094 const s = lastBackupSettingsPayload;
6095 let allowed = [];
6096 if (s && Array.isArray(s.allowed_vault_ids) && s.allowed_vault_ids.length) {
6097 allowed = s.allowed_vault_ids.map(String).filter(Boolean);
6098 } else if (s && Array.isArray(s.vault_list)) {
6099 allowed = s.vault_list
6100 .map(function (v) {
6101 return v && v.id != null ? String(v.id) : '';
6102 })
6103 .filter(Boolean);
6104 }
6105 if (allowed.length === 0) allowed = [current];
6106 if (allowed.indexOf(current) < 0) allowed = [current].concat(allowed);
6107 const prev = Array.prototype.slice
6108 .call(sel.selectedOptions || [])
6109 .map(function (o) {
6110 return o.value;
6111 });
6112 sel.innerHTML = '';
6113 allowed.forEach(function (vid) {
6114 const opt = document.createElement('option');
6115 opt.value = vid;
6116 opt.textContent = vid;
6117 opt.selected = prev.length ? prev.indexOf(vid) >= 0 : vid === current;
6118 sel.appendChild(opt);
6119 });
6120 if (!sel.selectedOptions || sel.selectedOptions.length === 0) {
6121 const fallback =
6122 Array.prototype.find.call(sel.options, function (o) {
6123 return o.value === current;
6124 }) || sel.options[0];
6125 if (fallback) fallback.selected = true;
6126 }
6127 }
6128
6129 function selectedAgentCredVaultIds() {
6130 const sel = el('agent-cred-vault-select');
6131 if (!sel) return [getCurrentVaultId() || 'default'];
6132 const picked = Array.prototype.slice.call(sel.selectedOptions || []).map(function (o) { return String(o.value || '').trim(); }).filter(Boolean);
6133 if (picked.length) return picked.slice(0, 32);
6134 return [getCurrentVaultId() || 'default'];
6135 }
6136
6137 function syncAgentCredWriteWarn() {
6138 const warn = el('agent-cred-write-warn');
6139 const box = el('agent-cred-scope-write');
6140 if (!warn || !box) return;
6141 warn.style.display = box.checked ? 'block' : 'none';
6142 }
6143
6144 function syncAgentCredStoreBanner(opts) {
6145 const banner = el('agent-cred-store-banner');
6146 if (!banner) return;
6147 const show = Boolean(opts && opts.show);
6148 banner.style.display = show ? 'block' : 'none';
6149 banner.textContent = show && opts.copy ? String(opts.copy) : '';
6150 }
6151
6152 async function refreshAgentCredList() {
6153 const list = el('agent-cred-list');
6154 if (!list) return;
6155 try {
6156 const resp = await hubApiResponse('/api/v1/auth/agent/credentials', {
6157 method: 'GET',
6158 credentials: 'omit',
6159 });
6160 const sessionMsg = agentCredSessionRefusal(resp);
6161 if (sessionMsg) {
6162 syncAgentCredStoreBanner({ show: false });
6163 list.innerHTML = '<li>' + sessionMsg + '</li>';
6164 return;
6165 }
6166 const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
6167 const code = data && data.code ? String(data.code) : '';
6168 if (!resp.ok) {
6169 if (resp.status === 503 && code === 'AGENT_CREDENTIAL_STORE_INCONSISTENT') {
6170 syncAgentCredStoreBanner({
6171 show: true,
6172 copy:
6173 'Agent credential store is inconsistent. Do not remint. Existing robots should retry; this is not a dead credential.',
6174 });
6175 } else if (resp.status === 503 && code === 'AGENT_CREDENTIAL_STORE_UNAVAILABLE') {
6176 syncAgentCredStoreBanner({
6177 show: true,
6178 copy: 'Agent credential store is temporarily unavailable. Do not remint. Retry.',
6179 });
6180 } else {
6181 syncAgentCredStoreBanner({ show: false });
6182 }
6183 list.innerHTML =
6184 '<li>Agent credentials unavailable on this host (' +
6185 (code || String(resp.status)) +
6186 ').</li>';
6187 return;
6188 }
6189 const store = data.store && typeof data.store === 'object' ? data.store : {};
6190 if (store.wipe_required) {
6191 syncAgentCredStoreBanner({
6192 show: true,
6193 copy:
6194 'Operator wipe required on the agent credential store. Robots will fail exchange until reminted after the wipe. This is not a browser session blip.',
6195 });
6196 } else {
6197 syncAgentCredStoreBanner({ show: false });
6198 }
6199 const creds = Array.isArray(data.credentials) ? data.credentials : [];
6200 if (creds.length === 0) {
6201 if (store.wipe_required || store.inconsistent) {
6202 list.innerHTML = '<li>Agent credential store requires operator attention.</li>';
6203 } else {
6204 list.innerHTML = '<li>No agent credentials yet.</li>';
6205 }
6206 return;
6207 }
6208 list.innerHTML = creds
6209 .map(function (c) {
6210 const id = String(c.id || '').replace(/[<>&"]/g, '');
6211 const name = String(c.name || '').replace(/[<>&"]/g, '');
6212 const scopes = (Array.isArray(c.scopes) ? c.scopes : []).join(' ').replace(/[<>&"]/g, '');
6213 const vaults = (Array.isArray(c.vault_ids) ? c.vault_ids : []).join(', ').replace(/[<>&"]/g, '') || '—';
6214 const created = formatAgentCredTs(c.created_at);
6215 const lastSuccess = formatAgentCredTs(c.last_used_at);
6216 const lastFailure = c.last_failure_code
6217 ? String(c.last_failure_code).replace(/[<>&"]/g, '') +
6218 (c.last_failure_at ? ' @ ' + formatAgentCredTs(c.last_failure_at) : '')
6219 : '—';
6220 const revokedAt = c.revoked_at ? formatAgentCredTs(c.revoked_at) : '—';
6221 const expires = formatAgentCredTs(c.expires_at);
6222 return (
6223 '<li><strong>' +
6224 name +
6225 '</strong> — vaults: ' +
6226 vaults +
6227 '; created: ' +
6228 created +
6229 '; last successful exchange: ' +
6230 lastSuccess +
6231 '; last failure code: ' +
6232 lastFailure +
6233 '; revoked-at: ' +
6234 revokedAt +
6235 '; scopes: ' +
6236 scopes +
6237 '; expires: ' +
6238 expires +
6239 (c.revoked ? ' (revoked)' : '') +
6240 ' <button type="button" class="btn-secondary btn-agent-cred-revoke" data-id="' +
6241 id +
6242 '">Revoke</button> <button type="button" class="btn-secondary btn-agent-cred-rotate" data-id="' +
6243 id +
6244 '">Rotate</button></li>'
6245 );
6246 })
6247 .join('');
6248 list.querySelectorAll('.btn-agent-cred-revoke').forEach(function (btn) {
6249 btn.onclick = async function () {
6250 const id = btn.getAttribute('data-id');
6251 const resp = await hubApiResponse(
6252 '/api/v1/auth/agent/credentials/' + encodeURIComponent(id),
6253 { method: 'DELETE', credentials: 'omit' },
6254 );
6255 const refuse = agentCredSessionRefusal(resp);
6256 if (refuse) {
6257 setAgentCredMsg(refuse, true);
6258 return;
6259 }
6260 setAgentCredMsg(resp.ok ? 'Revoked.' : 'Revoke failed', !resp.ok);
6261 refreshAgentCredList();
6262 };
6263 });
6264 list.querySelectorAll('.btn-agent-cred-rotate').forEach(function (btn) {
6265 btn.onclick = async function () {
6266 const id = btn.getAttribute('data-id');
6267 const resp = await hubApiResponse(
6268 '/api/v1/auth/agent/credentials/' + encodeURIComponent(id) + '/rotate',
6269 { method: 'POST', credentials: 'omit' },
6270 );
6271 const refuse = agentCredSessionRefusal(resp);
6272 if (refuse) {
6273 setAgentCredMsg(refuse, true);
6274 return;
6275 }
6276 const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
6277 if (!resp.ok) {
6278 setAgentCredMsg(data.error || 'Rotate failed', true);
6279 return;
6280 }
6281 const once = el('agent-cred-once');
6282 const pack =
6283 'KNOWTATION_HUB_URL=' +
6284 String(apiBase || '').replace(/\/$/, '') +
6285 '\nKNOWTATION_HUB_VAULT_ID=' +
6286 (getCurrentVaultId() || 'default') +
6287 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
6288 String(data.credential || '') +
6289 '\n';
6290 if (once) {
6291 once.style.display = 'block';
6292 once.textContent = pack + '\n# Shown once — copy now.';
6293 }
6294 if (navigator.clipboard && navigator.clipboard.writeText) {
6295 navigator.clipboard.writeText(pack).catch(function () {});
6296 }
6297 setAgentCredMsg('Rotated — new secret copied (shown once).', false);
6298 refreshAgentCredList();
6299 };
6300 });
6301 } catch (_) {
6302 syncAgentCredStoreBanner({ show: false });
6303 list.innerHTML = '<li>Could not load agent credentials.</li>';
6304 }
6305 }
6306
6307 const btnAgentCredMint = el('btn-agent-cred-mint');
6308 if (btnAgentCredMint) {
6309 btnAgentCredMint.onclick = async function () {
6310 const nameEl = el('agent-cred-name-input');
6311 const name = nameEl ? String(nameEl.value || '').trim() : '';
6312 if (!name) {
6313 setAgentCredMsg('Enter a name.', true);
6314 return;
6315 }
6316 const scopes = [];
6317 if (el('agent-cred-scope-propose') && el('agent-cred-scope-propose').checked) scopes.push('propose');
6318 if (el('agent-cred-scope-read') && el('agent-cred-scope-read').checked) scopes.push('vault:read');
6319 if (el('agent-cred-scope-ingest') && el('agent-cred-scope-ingest').checked) scopes.push('ingest:automation');
6320 if (el('agent-cred-scope-write') && el('agent-cred-scope-write').checked) scopes.push('vault:write');
6321 try {
6322 const resp = await hubApiResponse('/api/v1/auth/agent/credentials', {
6323 method: 'POST',
6324 credentials: 'omit',
6325 body: JSON.stringify({
6326 name: name,
6327 vault_ids: selectedAgentCredVaultIds(),
6328 scopes: scopes.length ? scopes : ['propose', 'vault:read'],
6329 }),
6330 });
6331 const refuse = agentCredSessionRefusal(resp);
6332 if (refuse) {
6333 setAgentCredMsg(refuse, true);
6334 return;
6335 }
6336 const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
6337 if (!resp.ok) {
6338 setAgentCredMsg(data.error || data.code || 'Mint failed', true);
6339 return;
6340 }
6341 const packVault =
6342 (Array.isArray(data.vault_ids) && data.vault_ids[0]) ||
6343 selectedAgentCredVaultIds()[0] ||
6344 getCurrentVaultId() ||
6345 'default';
6346 const pack =
6347 'KNOWTATION_HUB_URL=' +
6348 String(apiBase || '').replace(/\/$/, '') +
6349 '\nKNOWTATION_HUB_VAULT_ID=' +
6350 packVault +
6351 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
6352 String(data.credential || '') +
6353 '\n';
6354 const once = el('agent-cred-once');
6355 if (once) {
6356 once.style.display = 'block';
6357 once.textContent = pack + '\n# Shown once — copy now. Store in Paperclip secrets.';
6358 }
6359 if (navigator.clipboard && navigator.clipboard.writeText) {
6360 await navigator.clipboard.writeText(pack);
6361 }
6362 setAgentCredMsg('Minted — env block copied (secret shown once).', false);
6363 refreshAgentCredList();
6364 } catch (_) {
6365 setAgentCredMsg('Network error minting credential.', true);
6366 }
6367 };
6368 }
6369 const btnAgentCredRefresh = el('btn-agent-cred-refresh');
6370 if (btnAgentCredRefresh) {
6371 btnAgentCredRefresh.onclick = function () {
6372 refreshAgentCredVaultSelect();
6373 refreshAgentCredList();
6374 };
6375 }
6376 const agentCredWriteBox = el('agent-cred-scope-write');
6377 if (agentCredWriteBox) {
6378 agentCredWriteBox.onchange = syncAgentCredWriteWarn;
6379 syncAgentCredWriteWarn();
6380 }
6381 function syncAgentCredIngestWarn() {
6382 const warn = el('agent-cred-ingest-warn');
6383 const box = el('agent-cred-scope-ingest');
6384 if (!warn || !box) return;
6385 warn.style.display = box.checked ? 'block' : 'none';
6386 }
6387 const agentCredIngestBox = el('agent-cred-scope-ingest');
6388 if (agentCredIngestBox) {
6389 agentCredIngestBox.onchange = syncAgentCredIngestWarn;
6390 syncAgentCredIngestWarn();
6391 }
6392 try {
6393 refreshAgentCredVaultSelect();
6394 refreshAgentCredList();
6395 } catch (_) { /* ignore */ }
6396 refreshDevicePendingList();
6397
6398 const btnSettingsMuseSave = el('btn-settings-muse-save');
6399 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
6400 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
6401 btnSettingsMuseSave.addEventListener('click', async () => {
6402 const msg = el('settings-muse-msg');
6403 if (msg) {
6404 msg.textContent = '';
6405 msg.className = 'settings-msg';
6406 }
6407 const input = el('settings-muse-url');
6408 const url = input ? String(input.value || '').trim() : '';
6409 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
6410 try {
6411 await api('/api/v1/settings/muse', {
6412 method: 'POST',
6413 body: JSON.stringify({ url }),
6414 });
6415 if (msg) {
6416 msg.textContent = 'Saved.';
6417 msg.className = 'settings-msg ok';
6418 }
6419 const s = await api('/api/v1/settings');
6420 applySettingsPayloadToHubChrome(s);
6421 } catch (e) {
6422 if (msg) {
6423 msg.textContent =
6424 e && e.code === 'ENV_CONFLICT'
6425 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
6426 : (e && e.message) || 'Save failed';
6427 msg.className = 'settings-msg err';
6428 }
6429 }
6430 });
6431 });
6432 }
6433
6434 document.querySelectorAll('.settings-tab').forEach((tab) => {
6435 tab.addEventListener('click', () => {
6436 const id = tab.dataset.settingsTab;
6437 document.querySelectorAll('.settings-tab').forEach((t) => {
6438 t.classList.toggle('active', t.dataset.settingsTab === id);
6439 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
6440 });
6441 document.querySelectorAll('.settings-panel').forEach((p) => {
6442 p.classList.toggle('active', p.id === 'settings-panel-' + id);
6443 });
6444 if (id === 'team') {
6445 loadTeamRolesList();
6446 loadInvitesList();
6447 }
6448 if (id === 'integrations') {
6449 refreshDevicePendingList();
6450 }
6451 if (id === 'vaults') loadVaultsPanel();
6452 if (id === 'billing') loadBillingPanel();
6453 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
6454 if (id === 'consolidation') loadConsolidationSettings();
6455 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
6456 if (id === 'automation') loadIngestRulesPanel();
6457 });
6458 });
6459
6460 async function loadIngestRulesPanel() {
6461 const tbody = el('ingest-rules-tbody');
6462 const tmplList = el('ingest-templates-list');
6463 const msg = el('ingest-rules-msg');
6464 if (!tbody) return;
6465 try {
6466 const data = await api('/api/v1/automation/ingest-rules');
6467 const rules = Array.isArray(data.rules) ? data.rules : [];
6468 const templates = Array.isArray(data.templates) ? data.templates : [];
6469 if (!rules.length) {
6470 tbody.innerHTML = '<tr><td colspan="6">No rules yet.</td></tr>';
6471 } else {
6472 tbody.innerHTML = rules.map((r) => {
6473 const match = r.match || {};
6474 const summary = ['credential_name', 'path_prefix', 'content_class', 'intent']
6475 .filter((k) => match[k])
6476 .map((k) => k + '=' + match[k])
6477 .join(', ');
6478 return '<tr data-rule-id="' + String(r.rule_id || '') + '">' +
6479 '<td>' + String(r.label || '') + '</td>' +
6480 '<td>' + summary + '</td>' +
6481 '<td>' + String(r.disposition || '') + '</td>' +
6482 '<td>' + (r.enabled ? 'yes' : 'no') + '</td>' +
6483 '<td><input type="number" class="ingest-rule-priority-input settings-input" min="0" max="10000" value="' + String(r.priority ?? 100) + '" data-rule-id="' + String(r.rule_id || '') + '" /></td>' +
6484 '<td><button type="button" class="btn-secondary btn-ingest-toggle" data-rule-id="' + String(r.rule_id || '') + '">' + (r.enabled ? 'Disable' : 'Enable') + '</button> ' +
6485 '<button type="button" class="btn-secondary btn-ingest-delete" data-rule-id="' + String(r.rule_id || '') + '">Delete</button></td></tr>';
6486 }).join('');
6487 }
6488 if (tmplList) {
6489 tmplList.innerHTML = templates.map((t) =>
6490 '<li>' + String(t.label || t.rule_id) + ' <button type="button" class="btn-secondary btn-ingest-from-template" data-template-id="' + String(t.rule_id || '') + '">Add to my rules</button></li>'
6491 ).join('');
6492 }
6493 tbody.querySelectorAll('.btn-ingest-toggle').forEach((btn) => {
6494 btn.onclick = async () => {
6495 const id = btn.getAttribute('data-rule-id');
6496 const next = rules.map((r) => r.rule_id === id ? { ...r, enabled: !r.enabled } : r);
6497 await api('/api/v1/automation/ingest-rules', { method: 'PUT', body: JSON.stringify({ rules: next }) });
6498 loadIngestRulesPanel();
6499 };
6500 });
6501 tbody.querySelectorAll('.btn-ingest-delete').forEach((btn) => {
6502 btn.onclick = async () => {
6503 const id = btn.getAttribute('data-rule-id');
6504 await api('/api/v1/automation/ingest-rules/' + encodeURIComponent(id), { method: 'DELETE' });
6505 loadIngestRulesPanel();
6506 };
6507 });
6508 tbody.querySelectorAll('.ingest-rule-priority-input').forEach((inp) => {
6509 inp.onchange = async () => {
6510 const id = inp.getAttribute('data-rule-id');
6511 const pri = parseInt(inp.value, 10);
6512 const next = rules.map((r) => r.rule_id === id ? { ...r, priority: pri } : r);
6513 await api('/api/v1/automation/ingest-rules', { method: 'PUT', body: JSON.stringify({ rules: next }) });
6514 loadIngestRulesPanel();
6515 };
6516 });
6517 if (tmplList) {
6518 tmplList.querySelectorAll('.btn-ingest-from-template').forEach((btn) => {
6519 btn.onclick = async () => {
6520 const enable = el('ingest-template-enable') && el('ingest-template-enable').checked;
6521 await api('/api/v1/automation/ingest-rules/from-template', {
6522 method: 'POST',
6523 body: JSON.stringify({ template_id: btn.getAttribute('data-template-id'), enable: Boolean(enable) }),
6524 });
6525 loadIngestRulesPanel();
6526 };
6527 });
6528 }
6529 } catch (e) {
6530 if (msg) msg.textContent = (e && e.message) || 'Could not load ingest rules.';
6531 }
6532 }
6533
6534 const btnIngestRuleSave = el('btn-ingest-rule-save');
6535 if (btnIngestRuleSave) {
6536 btnIngestRuleSave.onclick = async () => {
6537 const msg = el('ingest-rules-msg');
6538 try {
6539 await api('/api/v1/automation/ingest-rules', {
6540 method: 'POST',
6541 body: JSON.stringify({
6542 label: el('ingest-rule-label') ? el('ingest-rule-label').value : '',
6543 priority: el('ingest-rule-priority') ? parseInt(el('ingest-rule-priority').value, 10) : 100,
6544 disposition: el('ingest-rule-disposition') ? el('ingest-rule-disposition').value : 'review_queue',
6545 content_class: el('ingest-rule-content-class') && el('ingest-rule-content-class').value ? el('ingest-rule-content-class').value : null,
6546 match: {
6547 credential_name: el('ingest-rule-match-name') && el('ingest-rule-match-name').value ? el('ingest-rule-match-name').value : null,
6548 path_prefix: el('ingest-rule-match-prefix') && el('ingest-rule-match-prefix').value ? el('ingest-rule-match-prefix').value : null,
6549 },
6550 }),
6551 });
6552 if (msg) msg.textContent = 'Saved.';
6553 loadIngestRulesPanel();
6554 } catch (e) {
6555 if (msg) msg.textContent = (e && e.message) || 'Save failed.';
6556 }
6557 };
6558 }
6559
6560 function formatTokenCount(n) {
6561 if (n == null || !Number.isFinite(Number(n))) return '—';
6562 return Number(n).toLocaleString();
6563 }
6564
6565 function formatTokenCountShort(n) {
6566 if (n == null || !Number.isFinite(Number(n))) return '—';
6567 const v = Number(n);
6568 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
6569 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
6570 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
6571 return String(v);
6572 }
6573
6574 /**
6575 * Update the token usage progress bar.
6576 * @param {number} used - tokens used this period
6577 * @param {number|null} included - tokens included (null = unlimited)
6578 */
6579 function updateUsageBar(fillId, used, included) {
6580 const fill = el(fillId);
6581 if (!fill) return;
6582 if (included == null) {
6583 fill.style.width = '15%';
6584 fill.className = 'billing-usage-bar-fill';
6585 return;
6586 }
6587 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
6588 fill.style.width = pct + '%';
6589 fill.className =
6590 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
6591 }
6592
6593 const TIER_LABELS = {
6594 free: 'Free',
6595 plus: 'Plus',
6596 growth: 'Growth',
6597 pro: 'Pro',
6598 beta: 'Beta',
6599 starter: 'Plus',
6600 team: 'Team',
6601 };
6602
6603 const TIER_CSS_CLASSES = {
6604 free: 'tier-free',
6605 plus: 'tier-plus',
6606 growth: 'tier-growth',
6607 pro: 'tier-pro',
6608 beta: 'tier-beta',
6609 starter: 'tier-plus',
6610 team: 'tier-pro',
6611 };
6612
6613 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
6614
6615 const TIER_PLAN_DATA = [
6616 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
6617 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
6618 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
6619 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
6620 ];
6621
6622 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
6623 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
6624
6625 /**
6626 * Render the plan comparison grid into #billing-plan-grid.
6627 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
6628 */
6629 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
6630 const grid = el('billing-plan-grid');
6631 if (!grid) return;
6632
6633 const normalized =
6634 currentTier === 'starter' ? 'plus'
6635 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
6636 : currentTier;
6637 const currentRank = TIER_ORDER.indexOf(normalized);
6638
6639 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
6640 const rank = TIER_ORDER.indexOf(tier);
6641 const isCurrent = rank === currentRank;
6642 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
6643
6644 let ctaHtml = '';
6645 if (isCurrent) {
6646 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
6647 } else if (isUpgrade) {
6648 const label = hasSub
6649 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
6650 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
6651 ctaHtml =
6652 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
6653 tier + '">' + label + '</button>';
6654 }
6655
6656 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
6657 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
6658
6659 return (
6660 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
6661 '<div class="billing-plan-card-header">' +
6662 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
6663 '<span class="billing-plan-card-price">' + price + '</span>' +
6664 '</div>' +
6665 '<ul class="billing-plan-card-features">' +
6666 '<li>' + searches + '</li>' +
6667 '<li>' + indexJobs + '</li>' +
6668 '<li>' + notes + '</li>' +
6669 consolLine +
6670 packLine +
6671 '</ul>' +
6672 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
6673 '</div>'
6674 );
6675 });
6676
6677 grid.innerHTML = cards.join('');
6678
6679 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
6680 btn.addEventListener('click', async () => {
6681 const tier = btn.dataset.tier;
6682 setButtonBusy(btn, true, 'Redirecting\u2026');
6683 try {
6684 await redirectToCheckout({ tier });
6685 } catch (e) {
6686 setButtonBusy(btn, false);
6687 const msg = el('billing-panel-msg');
6688 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
6689 }
6690 });
6691 });
6692 }
6693
6694 /**
6695 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
6696 * @param {{ price_id?: string, tier?: string }} opts
6697 */
6698 async function redirectToCheckout(opts) {
6699 const resp = await api('/api/v1/billing/checkout', {
6700 method: 'POST',
6701 headers: { 'Content-Type': 'application/json' },
6702 body: JSON.stringify({
6703 ...opts,
6704 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
6705 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
6706 }),
6707 });
6708 if (resp && resp.url) {
6709 window.location.href = resp.url;
6710 }
6711 }
6712
6713 /**
6714 * Redirect to Stripe Customer Portal.
6715 */
6716 async function redirectToPortal() {
6717 const resp = await api('/api/v1/billing/portal', {
6718 method: 'POST',
6719 headers: { 'Content-Type': 'application/json' },
6720 body: JSON.stringify({
6721 return_url: window.location.origin + window.location.pathname + '?open=billing',
6722 }),
6723 });
6724 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
6725 if (!url) {
6726 throw new Error(
6727 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
6728 );
6729 }
6730 window.location.assign(url);
6731 }
6732
6733 async function loadBillingPanel() {
6734 const msg = el('billing-panel-msg');
6735 const tierEl = el('billing-tier');
6736 const searchesUsedEl = el('billing-searches-used');
6737 const searchesIncEl = el('billing-searches-included');
6738 const indexJobsUsedEl = el('billing-index-jobs-used');
6739 const indexJobsIncEl = el('billing-index-jobs-included');
6740 const packEl = el('billing-pack-balance');
6741 const packRow = el('billing-pack-balance-row');
6742 const periodEl = el('billing-period');
6743 const renewalEl = el('billing-renewal');
6744 const credEl = el('billing-credits-used');
6745 const credRow = el('billing-credits-row');
6746 const polEl = el('billing-indexing-policy');
6747 const noteCap = el('billing-note-cap');
6748 const refreshBtn = el('btn-billing-refresh');
6749 const upgradeBtn = el('btn-billing-upgrade');
6750 const manageBtn = el('btn-billing-manage');
6751 const packSection = el('billing-pack-section');
6752 if (!tierEl || !searchesUsedEl) return;
6753 if (msg) msg.textContent = '';
6754 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
6755
6756 const setDash = () => {
6757 tierEl.textContent = '—';
6758 tierEl.className = 'billing-plan-badge tier-beta';
6759 if (searchesUsedEl) searchesUsedEl.textContent = '—';
6760 if (searchesIncEl) searchesIncEl.textContent = '—';
6761 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
6762 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
6763 if (packEl) packEl.textContent = '0';
6764 if (packRow) packRow.style.display = 'none';
6765 if (periodEl) periodEl.textContent = '—';
6766 if (renewalEl) renewalEl.textContent = '';
6767 if (credEl) credEl.textContent = '—';
6768 if (credRow) credRow.style.display = 'none';
6769 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
6770 if (noteCap) noteCap.textContent = '—';
6771 if (packSection) packSection.style.display = 'none';
6772 if (upgradeBtn) upgradeBtn.style.display = 'none';
6773 if (manageBtn) manageBtn.style.display = 'none';
6774 updateUsageBar('billing-searches-bar-fill', 0, 0);
6775 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
6776 updateUsageBar('billing-consol-bar-fill', 0, 0);
6777 const consolUsedReset = el('billing-consol-used');
6778 const consolIncReset = el('billing-consol-included');
6779 if (consolUsedReset) consolUsedReset.textContent = '—';
6780 if (consolIncReset) consolIncReset.textContent = '—';
6781 renderBillingPlanGrid('beta', false, false);
6782 };
6783
6784 if (!token) {
6785 setDash();
6786 if (msg) msg.textContent = 'Sign in to view billing usage.';
6787 if (refreshBtn) setButtonBusy(refreshBtn, false);
6788 return;
6789 }
6790
6791 try {
6792 const d = await api('/api/v1/billing/summary');
6793 const tier = d.tier != null ? String(d.tier) : 'beta';
6794
6795 // Plan badge
6796 tierEl.textContent = TIER_LABELS[tier] || tier;
6797 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
6798
6799 // Renewal date
6800 if (renewalEl) {
6801 const pe = d.period_end;
6802 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
6803 }
6804
6805 // Plan comparison grid
6806 const hasSub = Boolean(d.has_active_subscription);
6807 const isFreeTier = tier === 'free' || tier === 'beta';
6808 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
6809
6810 // Legacy upgrade button stays hidden (grid handles upgrades now)
6811 if (upgradeBtn) upgradeBtn.style.display = 'none';
6812 // Manage button: visible for active subscribers to reach the Stripe portal
6813 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
6814
6815 // Searches usage bar
6816 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
6817 const searchesInc = d.monthly_searches_included ?? null;
6818 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
6819 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
6820 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
6821
6822 // Index jobs usage bar
6823 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
6824 const indexJobsInc = d.monthly_index_jobs_included ?? null;
6825 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
6826 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
6827 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
6828
6829 // Consolidation jobs usage bar
6830 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
6831 const consolInc = d.monthly_consolidation_jobs_included ?? null;
6832 const consolUsedEl = el('billing-consol-used');
6833 const consolIncEl = el('billing-consol-included');
6834 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
6835 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
6836 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
6837
6838 // Pack balance
6839 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
6840 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
6841 if (packEl) {
6842 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
6843 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
6844 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
6845 let packText = formatTokenCountShort(packBal) +
6846 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
6847 if (packConsolPasses > 0) {
6848 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
6849 }
6850 packEl.textContent = packText;
6851 }
6852 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
6853
6854 // Period
6855 if (periodEl) {
6856 const ps = d.period_start;
6857 const pe = d.period_end;
6858 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
6859 }
6860
6861 // Note cap
6862 if (noteCap) {
6863 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
6864 }
6865
6866 // Legacy credits row (only show if non-zero)
6867 const mu = Number(d.monthly_used_cents) || 0;
6868 const mi = Number(d.monthly_included_effective_cents) || 0;
6869 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
6870 if (credEl && (mu > 0 || mi > 0)) {
6871 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
6872 }
6873
6874 // Token policy
6875 if (polEl) {
6876 const pol = d.indexing_tokens_policy;
6877 if (pol && String(pol).trim()) {
6878 polEl.textContent = String(pol).trim();
6879 polEl.style.display = '';
6880 } else {
6881 polEl.style.display = 'none';
6882 }
6883 }
6884
6885 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
6886 if (packSection) {
6887 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
6888 packSection.style.display = showPacks ? '' : 'none';
6889 }
6890
6891 if (msg) {
6892 msg.textContent = '';
6893 msg.className = 'settings-intro small muted';
6894 }
6895 } catch (e) {
6896 setDash();
6897 const m = e && e.message ? String(e.message) : String(e);
6898 if (msg) {
6899 msg.textContent =
6900 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
6901 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
6902 : m;
6903 msg.className = 'settings-intro small err';
6904 }
6905 }
6906 if (refreshBtn) setButtonBusy(refreshBtn, false);
6907 }
6908
6909 const btnBillingRefresh = el('btn-billing-refresh');
6910 if (btnBillingRefresh) {
6911 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
6912 }
6913
6914 const btnBillingUpgrade = el('btn-billing-upgrade');
6915 if (btnBillingUpgrade) {
6916 btnBillingUpgrade.addEventListener('click', async () => {
6917 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
6918 try {
6919 await redirectToCheckout({ tier: 'plus' });
6920 } catch (e) {
6921 setButtonBusy(btnBillingUpgrade, false);
6922 const packMsg = el('billing-panel-msg');
6923 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
6924 }
6925 });
6926 }
6927
6928 const btnBillingManage = el('btn-billing-manage');
6929 if (btnBillingManage) {
6930 btnBillingManage.addEventListener('click', async () => {
6931 const panelMsg = el('billing-panel-msg');
6932 if (panelMsg) {
6933 panelMsg.textContent = '';
6934 panelMsg.className = 'settings-intro small muted';
6935 }
6936 setButtonBusy(btnBillingManage, true, 'Redirecting…');
6937 try {
6938 await redirectToPortal();
6939 } catch (e) {
6940 setButtonBusy(btnBillingManage, false);
6941 const errText = e?.message || 'Could not open billing portal.';
6942 if (panelMsg) {
6943 panelMsg.textContent = errText;
6944 panelMsg.className = 'settings-intro small err';
6945 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6946 }
6947 }
6948 });
6949 }
6950
6951 // Token pack purchase buttons
6952 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
6953 btn.addEventListener('click', async () => {
6954 const pack = btn.dataset.pack;
6955 const packMsgEl = el('billing-pack-msg');
6956 setButtonBusy(btn, true, 'Redirecting…');
6957 if (packMsgEl) packMsgEl.textContent = '';
6958 try {
6959 await redirectToCheckout({ pack_size: pack });
6960 } catch (e) {
6961 setButtonBusy(btn, false);
6962 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
6963 }
6964 });
6965 });
6966
6967 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
6968 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
6969 const arr = Array.isArray(vaults) ? vaults : [];
6970 if (arr.length === 0) {
6971 return isHosted
6972 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
6973 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
6974 }
6975 const items = arr
6976 .map((v) => {
6977 if (!v || v.id == null) return '';
6978 const id = escapeHtml(String(v.id).trim());
6979 const lab =
6980 v.label != null && String(v.label).trim()
6981 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
6982 : '';
6983 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
6984 const pathHtml = pathRaw
6985 ? escapeHtml(pathRaw)
6986 : '<span class="muted">—</span>';
6987 return (
6988 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
6989 id +
6990 '</code>' +
6991 lab +
6992 '</div><div class="vaults-summary-path muted small">' +
6993 pathHtml +
6994 '</div></li>'
6995 );
6996 })
6997 .filter(Boolean)
6998 .join('');
6999 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
7000 }
7001
7002 function collectVaultIdsForAccessForm(vaults, settingsRes) {
7003 const set = new Set(['default']);
7004 const allowed =
7005 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
7006 allowed.forEach((id) => {
7007 if (id != null && String(id).trim()) set.add(String(id).trim());
7008 });
7009 (vaults || []).forEach((v) => {
7010 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
7011 });
7012 return Array.from(set).sort((a, b) => {
7013 if (a === 'default') return -1;
7014 if (b === 'default') return 1;
7015 return a.localeCompare(b);
7016 });
7017 }
7018
7019 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
7020 if (!selectEl) return;
7021 const uids = new Set();
7022 (roleIds || []).forEach((id) => {
7023 if (id != null && String(id).trim()) uids.add(String(id).trim());
7024 });
7025 if (currentUserId != null && String(currentUserId).trim()) {
7026 uids.add(String(currentUserId).trim());
7027 }
7028 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
7029 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
7030 sorted.forEach((uid) => {
7031 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
7032 });
7033 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
7034 selectEl.innerHTML = html;
7035 }
7036
7037 function renderAccessVaultCheckboxes(vaultIds) {
7038 const wrap = el('access-form-vault-checkboxes');
7039 if (!wrap) return;
7040 if (!vaultIds.length) {
7041 wrap.innerHTML =
7042 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
7043 return;
7044 }
7045 wrap.innerHTML = vaultIds
7046 .map((id) => {
7047 const idAttr = escapeHtml(id);
7048 return (
7049 '<label><input type="checkbox" name="hub-access-vault" value="' +
7050 idAttr +
7051 '"> <code>' +
7052 idAttr +
7053 '</code></label>'
7054 );
7055 })
7056 .join('');
7057 }
7058
7059 function parseVaultAccessFromTextarea() {
7060 const accessText = el('vault-access-json');
7061 try {
7062 const access = JSON.parse((accessText && accessText.value) || '{}');
7063 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
7064 } catch (_) {
7065 return {};
7066 }
7067 }
7068
7069 function refreshAccessRulesSummary(access) {
7070 const wrap = el('access-rules-summary');
7071 if (!wrap) return;
7072 if (typeof access !== 'object' || access === null) access = {};
7073 const keys = Object.keys(access);
7074 if (keys.length === 0) {
7075 wrap.innerHTML =
7076 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
7077 return;
7078 }
7079 wrap.innerHTML = keys
7080 .sort((a, b) => a.localeCompare(b))
7081 .map((uid) => {
7082 const arr = access[uid];
7083 const vaults =
7084 Array.isArray(arr) && arr.length
7085 ? arr.map((x) => escapeHtml(String(x))).join(', ')
7086 : '<span class="muted">(invalid)</span>';
7087 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
7088 })
7089 .join('');
7090 }
7091
7092 function accessFormToggleOtherInput() {
7093 const sel = el('access-form-user-select');
7094 const wrap = el('access-form-user-other-wrap');
7095 const other = el('access-form-user-other');
7096 if (!sel || !wrap) return;
7097 const show = sel.value === '__other__';
7098 wrap.classList.toggle('hidden', !show);
7099 if (!show && other) other.value = '';
7100 }
7101
7102 function accessFormSyncCheckboxesFromAccessJson() {
7103 const sel = el('access-form-user-select');
7104 const other = el('access-form-user-other');
7105 if (!sel) return;
7106 let uid = '';
7107 if (sel.value === '__other__') {
7108 uid = ((other && other.value) || '').trim();
7109 } else {
7110 uid = (sel.value || '').trim();
7111 }
7112 const access = parseVaultAccessFromTextarea();
7113 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
7114 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
7115 cb.checked = allowed.indexOf(cb.value) !== -1;
7116 });
7117 }
7118
7119 function getAccessFormResolvedUserId() {
7120 const sel = el('access-form-user-select');
7121 const other = el('access-form-user-other');
7122 if (!sel) return '';
7123 if (sel.value === '__other__') return ((other && other.value) || '').trim();
7124 return (sel.value || '').trim();
7125 }
7126
7127 const accessUserSel = el('access-form-user-select');
7128 if (accessUserSel) {
7129 accessUserSel.addEventListener('change', () => {
7130 accessFormToggleOtherInput();
7131 accessFormSyncCheckboxesFromAccessJson();
7132 });
7133 }
7134 const accessUserOther = el('access-form-user-other');
7135 if (accessUserOther) {
7136 accessUserOther.addEventListener('input', () => {
7137 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
7138 accessFormSyncCheckboxesFromAccessJson();
7139 }
7140 });
7141 }
7142 const scopeUserSelInit = el('scope-form-user-select');
7143 if (scopeUserSelInit) {
7144 scopeUserSelInit.addEventListener('change', () => {
7145 const inp = el('scope-form-user-id');
7146 if (scopeUserSelInit.value === '__other__') {
7147 if (inp) inp.focus();
7148 } else if (scopeUserSelInit.value && inp) {
7149 inp.value = scopeUserSelInit.value;
7150 }
7151 });
7152 }
7153
7154 function populateVaultListExistingSelect(vaults) {
7155 const sel = el('vault-list-form-existing');
7156 if (!sel) return;
7157 let html = '<option value="">New vault</option>';
7158 (vaults || []).forEach((v) => {
7159 if (v && v.id != null && String(v.id).trim()) {
7160 const id = String(v.id).trim();
7161 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
7162 }
7163 });
7164 sel.innerHTML = html;
7165 }
7166
7167 function parseVaultsJsonArrayFromTextarea() {
7168 const ta = el('vaults-json');
7169 try {
7170 const arr = JSON.parse((ta && ta.value) || '[]');
7171 return Array.isArray(arr) ? arr : [];
7172 } catch (_) {
7173 return null;
7174 }
7175 }
7176
7177 function fillVaultListFormFromExisting() {
7178 const sel = el('vault-list-form-existing');
7179 const idInp = el('vault-list-form-id');
7180 const pathInp = el('vault-list-form-path');
7181 const labelInp = el('vault-list-form-label');
7182 if (!sel) return;
7183 if (!sel.value) {
7184 if (idInp) {
7185 idInp.value = '';
7186 idInp.readOnly = false;
7187 }
7188 if (pathInp) pathInp.value = '';
7189 if (labelInp) labelInp.value = '';
7190 return;
7191 }
7192 const vaults = parseVaultsJsonArrayFromTextarea();
7193 if (!vaults) return;
7194 const v = vaults.find((x) => x && String(x.id) === sel.value);
7195 if (v) {
7196 if (idInp) {
7197 idInp.value = String(v.id);
7198 idInp.readOnly = true;
7199 }
7200 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
7201 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
7202 }
7203 }
7204
7205 function toggleVaultsInfoPanel(panelId) {
7206 const panel = el(panelId);
7207 const modal = el('modal-settings');
7208 if (!panel || !modal) return;
7209 const wasHidden = panel.classList.contains('hidden');
7210 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
7211 if (wasHidden) panel.classList.remove('hidden');
7212 }
7213
7214 const modalSettingsForVaultsInfo = el('modal-settings');
7215 if (modalSettingsForVaultsInfo) {
7216 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
7217 const infoBtn = e.target.closest('.btn-settings-info');
7218 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
7219 e.stopPropagation();
7220 const tid = infoBtn.getAttribute('data-settings-info-target');
7221 if (tid) toggleVaultsInfoPanel(tid);
7222 return;
7223 }
7224 if (
7225 !e.target.closest('.settings-info-panel') &&
7226 !e.target.closest('.btn-settings-info')
7227 ) {
7228 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
7229 p.classList.add('hidden');
7230 });
7231 }
7232 });
7233 }
7234
7235 const vaultListExistingSel = el('vault-list-form-existing');
7236 if (vaultListExistingSel) {
7237 vaultListExistingSel.addEventListener('change', () => {
7238 fillVaultListFormFromExisting();
7239 const msg = el('vault-list-form-msg');
7240 if (msg) msg.textContent = '';
7241 });
7242 }
7243
7244 const btnVaultListFormApply = el('btn-vault-list-form-apply');
7245 if (btnVaultListFormApply) {
7246 btnVaultListFormApply.onclick = () => {
7247 const msg = el('vault-list-form-msg');
7248 const ta = el('vaults-json');
7249 const idInp = el('vault-list-form-id');
7250 const pathInp = el('vault-list-form-path');
7251 const labelInp = el('vault-list-form-label');
7252 const vaults = parseVaultsJsonArrayFromTextarea();
7253 if (!vaults) {
7254 if (msg) {
7255 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
7256 msg.className = 'settings-msg err';
7257 }
7258 return;
7259 }
7260 const id = ((idInp && idInp.value) || '').trim();
7261 const path = ((pathInp && pathInp.value) || '').trim();
7262 const label = ((labelInp && labelInp.value) || '').trim();
7263 if (!id || !path) {
7264 if (msg) {
7265 msg.textContent = 'Enter vault id and folder path.';
7266 msg.className = 'settings-msg err';
7267 }
7268 return;
7269 }
7270 const entry = { id, path };
7271 if (label) entry.label = label;
7272 const idx = vaults.findIndex((x) => x && String(x.id) === id);
7273 if (idx >= 0) {
7274 vaults[idx] = Object.assign({}, vaults[idx], entry);
7275 } else {
7276 if (idInp && idInp.readOnly) {
7277 if (msg) {
7278 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
7279 msg.className = 'settings-msg err';
7280 }
7281 return;
7282 }
7283 vaults.push(entry);
7284 }
7285 if (ta) ta.value = JSON.stringify(vaults, null, 2);
7286 populateVaultListExistingSelect(vaults);
7287 const sel = el('vault-list-form-existing');
7288 if (sel) sel.value = '';
7289 fillVaultListFormFromExisting();
7290 const lc = el('vaults-list-container');
7291 if (lc && !isHostedHubFromSettings()) {
7292 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
7293 }
7294 if (msg) {
7295 msg.textContent = 'Updated. Click Save vault list to persist.';
7296 msg.className = 'settings-msg ok';
7297 }
7298 };
7299 }
7300
7301 async function loadVaultsPanel() {
7302 const listContainer = el('vaults-list-container');
7303 const serverView = el('vaults-server-view');
7304 const vaultsJson = el('vaults-json');
7305 const accessText = el('vault-access-json');
7306 const scopeText = el('scope-json');
7307 const helpHostedBlock = el('vaults-help-hosted-block');
7308 const helpSelfBlock = el('vaults-help-self-block');
7309 const selfHostedEditors = el('vaults-self-hosted-editors');
7310 const yamlOnly = el('vaults-hub-yaml-only');
7311 const hostedCreate = el('vaults-hosted-create');
7312 const workspacePanel = el('vaults-hosted-workspace');
7313 const workspaceInput = el('workspace-owner-input');
7314 const workspaceMsg = el('workspace-save-msg');
7315 if (listContainer) listContainer.textContent = 'Loading…';
7316 if (serverView) serverView.textContent = 'Loading…';
7317 try {
7318 const settingsRes = await api('/api/v1/settings');
7319 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
7320 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
7321 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
7322 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
7323 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
7324 const ownerFromSettings =
7325 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
7326 ? String(settingsRes.workspace_owner_id).trim()
7327 : '';
7328 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
7329 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
7330 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
7331 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
7332 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
7333 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
7334 const hostedCreateMsg = el('vaults-hosted-create-msg');
7335 if (hostedCreateMsg && isHosted) {
7336 hostedCreateMsg.textContent = '';
7337 hostedCreateMsg.className = 'settings-msg';
7338 }
7339 if (workspaceMsg) {
7340 workspaceMsg.textContent = '';
7341 workspaceMsg.className = 'settings-msg';
7342 }
7343
7344 /** @type {{ vaults?: unknown[] }} */
7345 let vRes = { vaults: [] };
7346 try {
7347 vRes = await api('/api/v1/vaults');
7348 } catch (_) {
7349 vRes = { vaults: [] };
7350 }
7351 /** @type {{ access?: Record<string, unknown> }} */
7352 let aRes = { access: {} };
7353 try {
7354 aRes = await api('/api/v1/vault-access');
7355 } catch (_) {
7356 aRes = { access: {} };
7357 }
7358 /** @type {{ scope?: Record<string, unknown> }} */
7359 let sRes = { scope: {} };
7360 try {
7361 sRes = await api('/api/v1/scope');
7362 } catch (_) {
7363 sRes = { scope: {} };
7364 }
7365
7366 if (isHosted && workspaceInput) {
7367 try {
7368 const w = await api('/api/v1/workspace');
7369 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
7370 } catch (e) {
7371 workspaceInput.value = '';
7372 if (workspaceMsg) {
7373 workspaceMsg.textContent =
7374 (e && e.message) ||
7375 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
7376 workspaceMsg.className = 'settings-msg err';
7377 }
7378 }
7379 } else if (workspaceInput && !isHosted) {
7380 workspaceInput.value = '';
7381 }
7382 const vaults = vRes.vaults || [];
7383 if (serverView) {
7384 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
7385 const allowed = settingsRes.allowed_vault_ids;
7386 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
7387 if (isHosted) {
7388 serverView.innerHTML =
7389 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
7390 escapeHtml(uid) +
7391 '</code> · <strong>Vaults:</strong> <code>' +
7392 escapeHtml(allowedStr) +
7393 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
7394 } else {
7395 const dataDir =
7396 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
7397 serverView.innerHTML =
7398 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
7399 escapeHtml(uid) +
7400 '</code> · <strong>Allowed vaults:</strong> <code>' +
7401 escapeHtml(allowedStr) +
7402 '</code> · <strong>Data:</strong> <code>' +
7403 dataDir +
7404 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
7405 }
7406 }
7407 if (listContainer) {
7408 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
7409 }
7410 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
7411 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
7412 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
7413
7414 const vaultListJsonDetails = el('vault-list-json-details');
7415 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
7416 const vaultAccessDetails = el('vault-access-json-details');
7417 if (vaultAccessDetails) vaultAccessDetails.open = false;
7418 const scopeJsonDetails = el('scope-json-details');
7419 if (scopeJsonDetails) scopeJsonDetails.open = false;
7420
7421 let roleIds = [];
7422 try {
7423 const ro = await api('/api/v1/roles');
7424 roleIds = Object.keys(ro.roles || {});
7425 } catch (_) {
7426 roleIds = [];
7427 }
7428 populateHostedTeamUserSelect(
7429 el('access-form-user-select'),
7430 roleIds,
7431 settingsRes.user_id,
7432 '— Choose a person —',
7433 );
7434 populateHostedTeamUserSelect(
7435 el('scope-form-user-select'),
7436 roleIds,
7437 settingsRes.user_id,
7438 '— Choose or type User ID below —',
7439 );
7440 const asel = el('access-form-user-select');
7441 if (asel) asel.value = '';
7442 const ssel = el('scope-form-user-select');
7443 if (ssel) ssel.value = '';
7444 accessFormToggleOtherInput();
7445 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
7446 renderAccessVaultCheckboxes(vaultIdsForForm);
7447 accessFormSyncCheckboxesFromAccessJson();
7448 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
7449
7450 const scopeVaultSelect = el('scope-form-vault-id');
7451 if (scopeVaultSelect) {
7452 scopeVaultSelect.innerHTML =
7453 vaults.length === 0
7454 ? '<option value="default">default</option>'
7455 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
7456 }
7457
7458 if (!isHosted) {
7459 populateVaultListExistingSelect(vaults);
7460 const vSel = el('vault-list-form-existing');
7461 if (vSel) vSel.value = '';
7462 fillVaultListFormFromExisting();
7463 }
7464 } catch (e) {
7465 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
7466 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
7467 }
7468 }
7469
7470 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
7471 function sanitizeNewHostedVaultId(raw) {
7472 const t = String(raw || '').trim();
7473 if (!t) return { error: 'Enter a vault id.' };
7474 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
7475 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
7476 s = s.slice(0, 64);
7477 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
7478 if (s === 'default') {
7479 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
7480 }
7481 return { id: s };
7482 }
7483
7484 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
7485 if (btnHostedVaultCreate) {
7486 btnHostedVaultCreate.onclick = async () => {
7487 const msgEl = el('vaults-hosted-create-msg');
7488 const inp = el('vaults-hosted-new-id');
7489 const setCreateVaultMsg = (text, isErr) => {
7490 if (!msgEl) return;
7491 msgEl.textContent = text;
7492 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
7493 };
7494 if (!isHostedHubFromSettings()) {
7495 setCreateVaultMsg('This action is only available on hosted Hub.', true);
7496 return;
7497 }
7498 if (!hubUserCanWriteNotes()) {
7499 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
7500 return;
7501 }
7502 const ws = lastBackupSettingsPayload;
7503 const ownerId =
7504 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
7505 ? String(ws.workspace_owner_id).trim()
7506 : '';
7507 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
7508 if (ownerId && me && me !== ownerId) {
7509 setCreateVaultMsg(
7510 'Only the workspace owner can create new cloud vaults. Ask them to create the vault id here, then an admin can grant access under Vault access.',
7511 true,
7512 );
7513 return;
7514 }
7515 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
7516 if (parsed.error) {
7517 setCreateVaultMsg(parsed.error, true);
7518 return;
7519 }
7520 const { id } = parsed;
7521 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
7522 setCreateVaultMsg('');
7523 try {
7524 const fresh = await api('/api/v1/settings');
7525 const allowed = fresh.allowed_vault_ids || [];
7526 if (Array.isArray(allowed) && allowed.includes(id)) {
7527 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the left rail to switch to it.', true);
7528 return;
7529 }
7530 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
7531 await api('/api/v1/notes', {
7532 method: 'POST',
7533 headers: { 'X-Vault-Id': id },
7534 body: JSON.stringify({
7535 path,
7536 body:
7537 'This note was created when you added the "' +
7538 id +
7539 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
7540 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
7541 }),
7542 });
7543 hubMarkSemanticIndexStaleForVault(id);
7544 const s = await api('/api/v1/settings');
7545 lastBackupSettingsPayload = s;
7546 if (s.role) window.__hubUserRole = String(s.role);
7547 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7548 applyHostedUiFromSettings(s);
7549 setCurrentVaultId(id);
7550 const sel = el('vault-switcher');
7551 if (sel) sel.value = id;
7552 loadFacets();
7553 loadNotes();
7554 loadProposals();
7555 await loadVaultsPanel();
7556 if (inp) inp.value = '';
7557 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the left rail to switch.', false);
7558 } catch (e) {
7559 setCreateVaultMsg(e.message || 'Could not create vault', true);
7560 }
7561 });
7562 };
7563 }
7564
7565 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
7566 if (btnSettingsDeleteVault) {
7567 btnSettingsDeleteVault.onclick = async () => {
7568 const msgEl = el('settings-delete-vault-msg');
7569 const setVaultDelMsg = (text, isErr) => {
7570 if (!msgEl) return;
7571 msgEl.textContent = text;
7572 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
7573 };
7574 if (!hubUserMayDeleteVault()) {
7575 setVaultDelMsg('You are not allowed to delete vaults.', true);
7576 return;
7577 }
7578 const sel = el('settings-delete-vault-select');
7579 const vaultId = (sel && sel.value) || '';
7580 const vaultIdTrim = String(vaultId).trim();
7581 if (!vaultIdTrim) {
7582 setVaultDelMsg('Choose a vault to delete.', true);
7583 return;
7584 }
7585 if (vaultIdTrim === 'default') {
7586 setVaultDelMsg('The default vault cannot be deleted.', true);
7587 return;
7588 }
7589 const confirmEl = el('settings-delete-vault-confirm');
7590 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
7591 if (confirmVal !== 'DELETE VAULT') {
7592 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
7593 return;
7594 }
7595 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
7596 setVaultDelMsg('', false);
7597 try {
7598 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
7599 method: 'DELETE',
7600 headers: { 'X-Vault-Id': vaultIdTrim },
7601 });
7602 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
7603 if (wasCurrent) {
7604 setCurrentVaultId('default');
7605 const vSel = el('vault-switcher');
7606 if (vSel) vSel.value = 'default';
7607 }
7608 const s = await api('/api/v1/settings');
7609 lastBackupSettingsPayload = s;
7610 if (s.role) window.__hubUserRole = String(s.role);
7611 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7612 applyHostedUiFromSettings(s);
7613 refreshDeleteProjectPanelVisibility();
7614 loadFacets();
7615 loadNotes();
7616 loadProposals();
7617 await loadVaultsPanel();
7618 if (confirmEl) confirmEl.value = '';
7619 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
7620 } catch (e) {
7621 setVaultDelMsg(e.message || 'Could not delete vault', true);
7622 }
7623 });
7624 };
7625 }
7626
7627 const btnScopeFormApply = el('btn-scope-form-apply');
7628 if (btnScopeFormApply) {
7629 btnScopeFormApply.onclick = () => {
7630 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
7631 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
7632 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
7633 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
7634 const msg = el('scope-form-msg');
7635 if (!userId) {
7636 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
7637 return;
7638 }
7639 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
7640 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
7641 const scopeText = el('scope-json');
7642 let scope = {};
7643 if (scopeText && scopeText.value) {
7644 try {
7645 scope = JSON.parse(scopeText.value);
7646 if (typeof scope !== 'object' || scope === null) scope = {};
7647 } catch (_) { scope = {}; }
7648 }
7649 if (!scope[userId]) scope[userId] = {};
7650 scope[userId][vaultId] = { projects, folders };
7651 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
7652 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
7653 };
7654 }
7655
7656 function isHostedHubFromSettings() {
7657 const s = lastBackupSettingsPayload;
7658 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
7659 }
7660
7661 const BULK_PRESET_EMPTY = '';
7662 const BULK_PRESET_CUSTOM = '__custom__';
7663
7664 function fillBulkPresetSelect(sel, items, includeCustom) {
7665 if (!sel) return;
7666 const preserve = sel.value;
7667 sel.innerHTML = '';
7668 const head = document.createElement('option');
7669 head.value = BULK_PRESET_EMPTY;
7670 head.textContent = '— Select or type below —';
7671 sel.appendChild(head);
7672 for (const item of items) {
7673 if (item == null || item === '') continue;
7674 const o = document.createElement('option');
7675 o.value = item;
7676 o.textContent = item;
7677 sel.appendChild(o);
7678 }
7679 if (includeCustom) {
7680 const c = document.createElement('option');
7681 c.value = BULK_PRESET_CUSTOM;
7682 c.textContent = 'Custom (type below)';
7683 sel.appendChild(c);
7684 }
7685 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7686 else sel.value = BULK_PRESET_EMPTY;
7687 }
7688
7689 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
7690 if (!selectEl || !inputEl) return;
7691 const p = (inputEl.value || '').trim();
7692 if (!p) {
7693 selectEl.value = BULK_PRESET_EMPTY;
7694 return;
7695 }
7696 let best = BULK_PRESET_CUSTOM;
7697 let bestLen = -1;
7698 for (const opt of selectEl.options) {
7699 const v = opt.value;
7700 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
7701 if (p === v || p.startsWith(v + '/')) {
7702 if (v.length > bestLen) {
7703 best = v;
7704 bestLen = v.length;
7705 }
7706 }
7707 }
7708 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
7709 }
7710
7711 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
7712 if (!selectEl || !inputEl) return;
7713 const p = (inputEl.value || '').trim();
7714 if (!p) {
7715 selectEl.value = BULK_PRESET_EMPTY;
7716 return;
7717 }
7718 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
7719 else selectEl.value = BULK_PRESET_CUSTOM;
7720 }
7721
7722 function wireBulkPathPresetPair(selectEl, inputEl) {
7723 if (!selectEl || !inputEl) return;
7724 selectEl.addEventListener('change', () => {
7725 const v = selectEl.value;
7726 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7727 });
7728 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
7729 }
7730
7731 function wireBulkSlugPresetPair(selectEl, inputEl) {
7732 if (!selectEl || !inputEl) return;
7733 selectEl.addEventListener('change', () => {
7734 const v = selectEl.value;
7735 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7736 });
7737 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
7738 }
7739
7740 let bulkPresetDropdownsToken = 0;
7741 async function refreshBulkDeletePresetDropdowns() {
7742 if (!token) return;
7743 const pathSelect = el('settings-bulk-path-prefix-preset');
7744 const delProjSelect = el('settings-bulk-delete-project-preset');
7745 const renameFromSelect = el('settings-bulk-rename-from-preset');
7746 const pathInput = el('settings-delete-prefix');
7747 const delProjInput = el('settings-delete-project-slug');
7748 const renameFromInput = el('settings-rename-project-from');
7749 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
7750 const my = ++bulkPresetDropdownsToken;
7751 let diskFolders = [];
7752 let facets = { projects: [], folders: [] };
7753 try {
7754 const [vf, fc] = await Promise.all([
7755 api('/api/v1/vault/folders'),
7756 api('/api/v1/notes/facets'),
7757 ]);
7758 if (my !== bulkPresetDropdownsToken) return;
7759 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
7760 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
7761 } catch (_) {
7762 if (my !== bulkPresetDropdownsToken) return;
7763 }
7764 const pathSet = new Set();
7765 for (const f of diskFolders) {
7766 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7767 }
7768 for (const f of facets.folders || []) {
7769 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7770 }
7771 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
7772 const pathPrefixes = ['inbox', ...rest];
7773 const projects = [
7774 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
7775 ].sort((a, b) => a.localeCompare(b));
7776
7777 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
7778 fillBulkPresetSelect(delProjSelect, projects, true);
7779 fillBulkPresetSelect(renameFromSelect, projects, true);
7780
7781 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
7782 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
7783 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
7784 }
7785
7786 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
7787 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
7788 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
7789
7790 const btnDeletePrefix = el('btn-settings-delete-prefix');
7791 if (btnDeletePrefix) {
7792 btnDeletePrefix.onclick = async () => {
7793 const msg = el('settings-delete-prefix-msg');
7794 const prefixEl = el('settings-delete-prefix');
7795 const confirmEl = el('settings-delete-confirm');
7796 if (!hubUserCanWriteNotes()) {
7797 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7798 return;
7799 }
7800 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
7801 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7802 if (!raw) {
7803 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
7804 return;
7805 }
7806 if (conf !== 'DELETE') {
7807 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7808 return;
7809 }
7810 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
7811 try {
7812 const out = await api('/api/v1/notes/delete-by-prefix', {
7813 method: 'POST',
7814 headers: { 'Content-Type': 'application/json' },
7815 body: JSON.stringify({ path_prefix: raw }),
7816 });
7817 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7818 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7819 if (confirmEl) confirmEl.value = '';
7820 if (msg) {
7821 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7822 msg.className = 'settings-msg ok';
7823 }
7824 if (typeof showToast === 'function') {
7825 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
7826 }
7827 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7828 loadNotes();
7829 loadFacets();
7830 if (typeof loadProposals === 'function') loadProposals();
7831 void refreshBulkDeletePresetDropdowns();
7832 } catch (e) {
7833 const m = e && e.message ? String(e.message) : String(e);
7834 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7835 }
7836 });
7837 };
7838 }
7839
7840 const btnDeleteByProject = el('btn-settings-delete-by-project');
7841 if (btnDeleteByProject) {
7842 btnDeleteByProject.onclick = async () => {
7843 const msg = el('settings-delete-by-project-msg');
7844 const slugEl = el('settings-delete-project-slug');
7845 const confirmEl = el('settings-delete-project-confirm');
7846 if (!hubUserCanWriteNotes()) {
7847 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7848 return;
7849 }
7850 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
7851 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7852 if (!slug) {
7853 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
7854 return;
7855 }
7856 if (conf !== 'DELETE') {
7857 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7858 return;
7859 }
7860 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
7861 try {
7862 const out = await api('/api/v1/notes/delete-by-project', {
7863 method: 'POST',
7864 headers: { 'Content-Type': 'application/json' },
7865 body: JSON.stringify({ project: slug }),
7866 });
7867 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7868 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7869 if (confirmEl) confirmEl.value = '';
7870 if (msg) {
7871 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7872 msg.className = 'settings-msg ok';
7873 }
7874 if (typeof showToast === 'function') {
7875 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
7876 }
7877 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7878 loadNotes();
7879 loadFacets();
7880 if (typeof loadProposals === 'function') loadProposals();
7881 void refreshBulkDeletePresetDropdowns();
7882 } catch (e) {
7883 const m = e && e.message ? String(e.message) : String(e);
7884 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7885 }
7886 });
7887 };
7888 }
7889
7890 const btnRenameProject = el('btn-settings-rename-project');
7891 if (btnRenameProject) {
7892 btnRenameProject.onclick = async () => {
7893 const msg = el('settings-rename-project-msg');
7894 const fromEl = el('settings-rename-project-from');
7895 const toEl = el('settings-rename-project-to');
7896 const confirmEl = el('settings-rename-project-confirm');
7897 if (!hubUserCanWriteNotes()) {
7898 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
7899 return;
7900 }
7901 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
7902 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
7903 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7904 if (!from || !to) {
7905 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
7906 return;
7907 }
7908 if (conf !== 'RENAME') {
7909 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
7910 return;
7911 }
7912 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
7913 try {
7914 const out = await api('/api/v1/notes/rename-project', {
7915 method: 'POST',
7916 headers: { 'Content-Type': 'application/json' },
7917 body: JSON.stringify({ from, to }),
7918 });
7919 const n = out && typeof out.updated === 'number' ? out.updated : 0;
7920 if (confirmEl) confirmEl.value = '';
7921 if (msg) {
7922 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
7923 msg.className = 'settings-msg ok';
7924 }
7925 if (typeof showToast === 'function') {
7926 showToast('Renamed project on ' + n + ' note(s).', false);
7927 }
7928 if (n > 0) hubMarkSemanticIndexStale();
7929 loadNotes();
7930 loadFacets();
7931 void refreshBulkDeletePresetDropdowns();
7932 } catch (e) {
7933 const m = e && e.message ? String(e.message) : String(e);
7934 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7935 }
7936 });
7937 };
7938 }
7939
7940 const btnVaultsSave = el('btn-vaults-save');
7941 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
7942 const msg = el('vaults-save-msg');
7943 if (isHostedHubFromSettings()) {
7944 if (msg) {
7945 msg.textContent =
7946 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
7947 msg.className = 'settings-msg err';
7948 }
7949 return;
7950 }
7951 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
7952 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
7953 try {
7954 const vaults = JSON.parse(raw);
7955 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
7956 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
7957 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7958 try {
7959 const s = await api('/api/v1/settings');
7960 applySettingsPayloadToHubChrome(s);
7961 } catch (_) {}
7962 loadVaultsPanel();
7963 } catch (e) {
7964 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7965 }
7966 });
7967 };
7968 function validateVaultAccess(access) {
7969 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
7970 for (const [uid, arr] of Object.entries(access)) {
7971 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
7972 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
7973 }
7974 return null;
7975 }
7976 function validateScope(scope) {
7977 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
7978 for (const [userId, perVault] of Object.entries(scope)) {
7979 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
7980 for (const [vaultId, entry] of Object.entries(perVault)) {
7981 if (typeof entry !== 'object' || entry === null) continue;
7982 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
7983 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
7984 }
7985 }
7986 return null;
7987 }
7988 const btnVaultAccessSave = el('btn-vault-access-save');
7989 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
7990 const msg = el('vault-access-save-msg');
7991 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
7992 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
7993 try {
7994 const access = JSON.parse(raw);
7995 const err = validateVaultAccess(access);
7996 if (err) throw new Error(err);
7997 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
7998 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7999 try {
8000 const s = await api('/api/v1/settings');
8001 applySettingsPayloadToHubChrome(s);
8002 } catch (_) {}
8003 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
8004 } catch (e) {
8005 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
8006 }
8007 });
8008 };
8009
8010 const btnAccessFormApply = el('btn-access-form-apply');
8011 if (btnAccessFormApply) {
8012 btnAccessFormApply.onclick = () => {
8013 const msg = el('access-form-msg');
8014 const uid = getAccessFormResolvedUserId();
8015 if (!uid) {
8016 if (msg) {
8017 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
8018 msg.className = 'settings-msg err';
8019 }
8020 return;
8021 }
8022 const checked = Array.from(
8023 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
8024 ).map((c) => c.value);
8025 if (checked.length === 0) {
8026 if (msg) {
8027 msg.textContent = 'Tick at least one vault.';
8028 msg.className = 'settings-msg err';
8029 }
8030 return;
8031 }
8032 const access = parseVaultAccessFromTextarea();
8033 access[uid] = checked;
8034 const ta = el('vault-access-json');
8035 if (ta) ta.value = JSON.stringify(access, null, 2);
8036 refreshAccessRulesSummary(access);
8037 if (msg) {
8038 msg.textContent =
8039 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
8040 msg.className = 'settings-msg ok';
8041 }
8042 };
8043 }
8044
8045 const btnAccessFormRemove = el('btn-access-form-remove-user');
8046 if (btnAccessFormRemove) {
8047 btnAccessFormRemove.onclick = () => {
8048 const msg = el('access-form-msg');
8049 const uid = getAccessFormResolvedUserId();
8050 if (!uid) {
8051 if (msg) {
8052 msg.textContent = 'Choose a person to remove.';
8053 msg.className = 'settings-msg err';
8054 }
8055 return;
8056 }
8057 const access = parseVaultAccessFromTextarea();
8058 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
8059 if (msg) {
8060 msg.textContent = 'No rule for that user.';
8061 msg.className = 'settings-msg err';
8062 }
8063 return;
8064 }
8065 delete access[uid];
8066 const ta = el('vault-access-json');
8067 if (ta) ta.value = JSON.stringify(access, null, 2);
8068 refreshAccessRulesSummary(access);
8069 accessFormSyncCheckboxesFromAccessJson();
8070 if (msg) {
8071 msg.textContent =
8072 'Removed from draft rules only. Click Save vault access below to persist (required).';
8073 msg.className = 'settings-msg ok';
8074 }
8075 };
8076 }
8077
8078 const btnScopeSave = el('btn-scope-save');
8079 if (btnScopeSave) btnScopeSave.onclick = async () => {
8080 const msg = el('scope-save-msg');
8081 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
8082 const raw = (el('scope-json') && el('scope-json').value) || '{}';
8083 try {
8084 const scope = JSON.parse(raw);
8085 const err = validateScope(scope);
8086 if (err) throw new Error(err);
8087 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
8088 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
8089 } catch (e) {
8090 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
8091 }
8092 });
8093 };
8094
8095 const btnWorkspaceUseMe = el('btn-workspace-use-me');
8096 if (btnWorkspaceUseMe) {
8097 btnWorkspaceUseMe.onclick = async () => {
8098 const input = el('workspace-owner-input');
8099 const msg = el('workspace-save-msg');
8100 let uid =
8101 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
8102 ? String(lastBackupSettingsPayload.user_id)
8103 : '';
8104 if (!uid) {
8105 try {
8106 const s = await api('/api/v1/settings');
8107 lastBackupSettingsPayload = s;
8108 uid = s.user_id != null ? String(s.user_id) : '';
8109 } catch (e) {
8110 if (msg) {
8111 msg.textContent = e.message || 'Could not load your User ID.';
8112 msg.className = 'settings-msg err';
8113 }
8114 return;
8115 }
8116 }
8117 if (input) input.value = uid;
8118 if (msg) {
8119 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
8120 msg.className = 'settings-msg ok';
8121 }
8122 };
8123 }
8124
8125 const btnWorkspaceSave = el('btn-workspace-save');
8126 if (btnWorkspaceSave) {
8127 btnWorkspaceSave.onclick = async () => {
8128 const msg = el('workspace-save-msg');
8129 const input = el('workspace-owner-input');
8130 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
8131 try {
8132 const raw = (input && input.value) || '';
8133 const trimmed = raw.trim();
8134 const owner_user_id = trimmed === '' ? null : trimmed;
8135 await api('/api/v1/workspace', {
8136 method: 'POST',
8137 body: JSON.stringify({ owner_user_id }),
8138 });
8139 if (msg) {
8140 msg.textContent = 'Saved.';
8141 msg.className = 'settings-msg ok';
8142 }
8143 } catch (e) {
8144 if (msg) {
8145 msg.textContent = e.message || 'Save failed';
8146 msg.className = 'settings-msg err';
8147 }
8148 }
8149 });
8150 };
8151 }
8152
8153 const btnWorkspaceClear = el('btn-workspace-clear');
8154 if (btnWorkspaceClear) {
8155 btnWorkspaceClear.onclick = async () => {
8156 const msg = el('workspace-save-msg');
8157 const input = el('workspace-owner-input');
8158 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
8159 try {
8160 await api('/api/v1/workspace', {
8161 method: 'POST',
8162 body: JSON.stringify({ owner_user_id: null }),
8163 });
8164 if (input) input.value = '';
8165 if (msg) {
8166 msg.textContent = 'Cleared — each person uses their own cloud space.';
8167 msg.className = 'settings-msg ok';
8168 }
8169 } catch (e) {
8170 if (msg) {
8171 msg.textContent = e.message || 'Clear failed';
8172 msg.className = 'settings-msg err';
8173 }
8174 }
8175 });
8176 };
8177 }
8178
8179 async function loadInvitesList() {
8180 const listEl = el('invites-pending-list');
8181 if (!listEl) return;
8182 listEl.textContent = 'Loading…';
8183 try {
8184 const out = await api('/api/v1/invites');
8185 const invites = out.invites || [];
8186 if (invites.length === 0) {
8187 listEl.textContent = 'No pending invites. Create a link above.';
8188 } else {
8189 listEl.innerHTML = invites.map((inv) => {
8190 const tokenShort = inv.token.slice(0, 12) + '…';
8191 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
8192 return '<div class="team-role-row invite-row">' +
8193 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
8194 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
8195 '</div>';
8196 }).join('');
8197 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
8198 btn.onclick = async () => {
8199 const t = btn.dataset.token;
8200 if (!t) return;
8201 try {
8202 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
8203 loadInvitesList();
8204 } catch (e) {
8205 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
8206 }
8207 };
8208 });
8209 }
8210 } catch (e) {
8211 listEl.textContent = 'Could not load: ' + (e.message || '');
8212 }
8213 }
8214
8215 const btnInviteCreate = el('btn-invite-create');
8216 const inviteLinkBlock = el('invite-link-block');
8217 const inviteLinkUrl = el('invite-link-url');
8218 const inviteCreateMsg = el('invite-create-msg');
8219 if (btnInviteCreate) {
8220 btnInviteCreate.onclick = async () => {
8221 const roleSelect = el('invite-role');
8222 const role = (roleSelect && roleSelect.value) || 'editor';
8223 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
8224 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
8225 try {
8226 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
8227 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
8228 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
8229 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
8230 loadInvitesList();
8231 } catch (e) {
8232 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
8233 }
8234 });
8235 };
8236 }
8237 const btnInviteCopy = el('btn-invite-copy');
8238 if (btnInviteCopy && inviteLinkUrl) {
8239 btnInviteCopy.onclick = () => {
8240 inviteLinkUrl.select();
8241 if (navigator.clipboard && navigator.clipboard.writeText) {
8242 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
8243 if (typeof showToast === 'function') showToast('Link copied.');
8244 }).catch(() => {});
8245 }
8246 };
8247 }
8248
8249 function syncTeamAddEvaluatorMayApproveVisibility() {
8250 const wrap = el('team-add-evaluator-may-approve-wrap');
8251 const sel = el('team-role');
8252 if (!wrap || !sel) return;
8253 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
8254 }
8255 const teamRoleSelect = el('team-role');
8256 if (teamRoleSelect) {
8257 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
8258 syncTeamAddEvaluatorMayApproveVisibility();
8259 }
8260
8261 async function loadTeamRolesList() {
8262 const listEl = el('team-roles-list');
8263 if (!listEl) return;
8264 listEl.textContent = 'Loading…';
8265 try {
8266 const out = await api('/api/v1/roles');
8267 const roles = out.roles || {};
8268 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
8269 const entries = Object.entries(roles);
8270 listEl.innerHTML = '';
8271 if (entries.length === 0) {
8272 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
8273 return;
8274 }
8275 for (const [uid, role] of entries) {
8276 const row = document.createElement('div');
8277 row.className = 'team-role-row team-role-row-flex';
8278 const label = document.createElement('span');
8279 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
8280 row.appendChild(label);
8281 if (role === 'evaluator') {
8282 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
8283 const chk = document.createElement('input');
8284 chk.type = 'checkbox';
8285 chk.title = 'May approve proposals';
8286 chk.checked = Boolean(mayMap[uid]);
8287 chk.addEventListener('change', async () => {
8288 chk.disabled = true;
8289 try {
8290 await api('/api/v1/roles/evaluator-may-approve', {
8291 method: 'POST',
8292 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
8293 });
8294 } catch (err) {
8295 chk.checked = !chk.checked;
8296 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
8297 } finally {
8298 chk.disabled = false;
8299 }
8300 });
8301 const lab = document.createElement('label');
8302 lab.className = 'team-evaluator-approve-inline';
8303 lab.appendChild(chk);
8304 const sp = document.createElement('span');
8305 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
8306 lab.appendChild(sp);
8307 row.appendChild(lab);
8308 }
8309 listEl.appendChild(row);
8310 }
8311 } catch (e) {
8312 listEl.textContent = 'Could not load: ' + (e.message || '');
8313 }
8314 }
8315
8316 const btnTeamUserUseMe = el('btn-team-user-use-me');
8317 if (btnTeamUserUseMe) {
8318 btnTeamUserUseMe.onclick = async () => {
8319 const userIdInput = el('team-user-id');
8320 const msgEl = el('team-save-msg');
8321 let uid =
8322 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
8323 ? String(lastBackupSettingsPayload.user_id)
8324 : '';
8325 if (!uid) {
8326 try {
8327 const s = await api('/api/v1/settings');
8328 lastBackupSettingsPayload = s;
8329 uid = s.user_id != null ? String(s.user_id) : '';
8330 } catch (e) {
8331 if (msgEl) {
8332 msgEl.textContent = e.message || 'Could not load your User ID.';
8333 msgEl.className = 'settings-msg err';
8334 }
8335 return;
8336 }
8337 }
8338 if (userIdInput) userIdInput.value = uid;
8339 if (msgEl) {
8340 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
8341 msgEl.className = 'settings-msg';
8342 }
8343 };
8344 }
8345
8346 const btnTeamSave = el('btn-team-save');
8347 if (btnTeamSave) {
8348 btnTeamSave.onclick = async () => {
8349 const userIdInput = el('team-user-id');
8350 const roleSelect = el('team-role');
8351 const msgEl = el('team-save-msg');
8352 const userId = (userIdInput && userIdInput.value || '').trim();
8353 const role = (roleSelect && roleSelect.value) || 'editor';
8354 if (!userId) {
8355 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
8356 return;
8357 }
8358 if (msgEl) msgEl.textContent = '';
8359 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
8360 try {
8361 const body = { user_id: userId, role };
8362 if (role === 'evaluator') {
8363 const cb = el('team-add-evaluator-may-approve');
8364 body.evaluator_may_approve = Boolean(cb && cb.checked);
8365 }
8366 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
8367 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
8368 userIdInput.value = '';
8369 loadTeamRolesList();
8370 } catch (e) {
8371 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
8372 }
8373 });
8374 };
8375 }
8376
8377 const currentAccent = () => {
8378 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
8379 if (inline) return inline;
8380 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
8381 return fromCss || DEFAULT_ACCENT;
8382 };
8383 function accentStringToHex6(str) {
8384 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
8385 const t = str.trim();
8386 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
8387 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
8388 const a = t.slice(1);
8389 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
8390 }
8391 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
8392 if (m) {
8393 return (
8394 '#' +
8395 [1, 2, 3]
8396 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
8397 .join('')
8398 ).toLowerCase();
8399 }
8400 return DEFAULT_ACCENT;
8401 }
8402 function updateAccentCustomHexLabel(hex6) {
8403 const out = el('accent-custom-hex');
8404 if (out && hex6) out.textContent = String(hex6).toUpperCase();
8405 }
8406 function setAccentRuntimeOnly(hex) {
8407 if (!hex) return;
8408 document.documentElement.style.setProperty('--accent', hex);
8409 updateAccentCustomHexLabel(accentStringToHex6(hex));
8410 }
8411 let accentIroPicker = null;
8412 let accentIroSuppressChange = false;
8413 function ensureAccentIroPicker() {
8414 if (accentIroPicker) return accentIroPicker;
8415 const mount = el('accent-iro-root');
8416 const Iro = typeof window !== 'undefined' && window.iro;
8417 if (!mount || !Iro || !Iro.ColorPicker) return null;
8418 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
8419 const br = (brRaw && brRaw.trim()) || '';
8420 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
8421 accentIroPicker = new Iro.ColorPicker(mount, {
8422 width: 280,
8423 color: accentStringToHex6(currentAccent()),
8424 borderWidth: 1,
8425 borderColor,
8426 layout: [
8427 { component: Iro.ui.Box, options: {} },
8428 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
8429 ],
8430 });
8431 accentIroPicker.on('color:change', (color) => {
8432 if (accentIroSuppressChange) return;
8433 setAccentRuntimeOnly(color.hexString);
8434 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
8435 });
8436 accentIroPicker.on('input:end', () => {
8437 if (accentIroSuppressChange) return;
8438 const h = accentIroPicker.color.hexString;
8439 if (h) applyAccent(h);
8440 });
8441 return accentIroPicker;
8442 }
8443 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
8444 function setAccentPickerColor(picker, hexNorm) {
8445 if (!picker || !hexNorm) return;
8446 const col = picker.color;
8447 if (col && typeof col.set === 'function') {
8448 col.set(hexNorm);
8449 return;
8450 }
8451 if (typeof picker.setColor === 'function') {
8452 try {
8453 picker.setColor(hexNorm, { silent: true });
8454 } catch (_) {
8455 picker.setColor(hexNorm);
8456 }
8457 }
8458 }
8459 function paintAccentSwatches() {
8460 document.querySelectorAll('.accent-swatch').forEach((btn) => {
8461 const hex = btn.dataset.accent;
8462 if (hex) btn.style.backgroundColor = hex;
8463 });
8464 }
8465 paintAccentSwatches();
8466 document.querySelectorAll('.accent-swatch').forEach((btn) => {
8467 btn.addEventListener('click', () => {
8468 const hex = btn.dataset.accent;
8469 if (hex) {
8470 applyAccent(hex);
8471 const norm = accentStringToHex6(hex);
8472 document.querySelectorAll('.accent-swatch').forEach((b) => {
8473 const bh = b.dataset.accent;
8474 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
8475 });
8476 ensureAccentIroPicker();
8477 if (accentIroPicker) {
8478 accentIroSuppressChange = true;
8479 try {
8480 setAccentPickerColor(accentIroPicker, norm);
8481 } finally {
8482 accentIroSuppressChange = false;
8483 }
8484 }
8485 updateAccentCustomHexLabel(norm);
8486 }
8487 });
8488 });
8489 ensureAccentIroPicker();
8490 function syncAccentUI() {
8491 const norm = accentStringToHex6(currentAccent());
8492 document.querySelectorAll('.accent-swatch').forEach((b) => {
8493 const bh = b.dataset.accent;
8494 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
8495 });
8496 ensureAccentIroPicker();
8497 if (accentIroPicker) {
8498 accentIroSuppressChange = true;
8499 try {
8500 setAccentPickerColor(accentIroPicker, norm);
8501 } finally {
8502 accentIroSuppressChange = false;
8503 }
8504 }
8505 updateAccentCustomHexLabel(norm);
8506 }
8507 function currentTheme() {
8508 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
8509 }
8510 function syncThemeUI() {
8511 const theme = currentTheme();
8512 document.querySelectorAll('.theme-btn').forEach((btn) => {
8513 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
8514 });
8515 }
8516 function syncColorPaletteUI() {
8517 const p = currentColorPalette();
8518 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
8519 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
8520 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
8521 });
8522 }
8523 const dashboardThemeGrid = el('dashboard-theme-grid');
8524 if (dashboardThemeGrid) {
8525 dashboardThemeGrid.addEventListener('click', (ev) => {
8526 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
8527 if (!card || !dashboardThemeGrid.contains(card)) return;
8528 const pid = card.dataset.palette;
8529 if (pid == null) return;
8530 applyColorPalette(pid);
8531 syncColorPaletteUI();
8532 });
8533 }
8534 document.querySelectorAll('.theme-btn').forEach((btn) => {
8535 btn.addEventListener('click', () => {
8536 const theme = btn.dataset.theme;
8537 if (theme) {
8538 applyTheme(theme);
8539 syncThemeUI();
8540 }
8541 });
8542 });
8543 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
8544 if (scrollDashColorsBtn) {
8545 scrollDashColorsBtn.addEventListener('click', () => {
8546 const target = el('settings-dashboard-color-theme');
8547 if (target && target.scrollIntoView) {
8548 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
8549 }
8550 });
8551 }
8552
8553 el('btn-settings-sync').onclick = async () => {
8554 const syncBtn = el('btn-settings-sync');
8555 const msg = el('settings-sync-msg');
8556 msg.textContent = 'Syncing…';
8557 msg.className = 'settings-msg';
8558 const s = lastBackupSettingsPayload;
8559 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
8560 const hostedPath = isHosted && s.github_connect_available;
8561 let opts = { method: 'POST' };
8562 if (hostedPath) {
8563 const slug =
8564 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
8565 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
8566 normalizeGithubRepoSlug(s.repo);
8567 if (!slug) {
8568 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
8569 msg.className = 'settings-msg err';
8570 return;
8571 }
8572 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
8573 opts.body = JSON.stringify({ repo: slug });
8574 }
8575 setButtonBusy(syncBtn, true, 'Backing up…');
8576 try {
8577 const result = await api('/api/v1/vault/sync', opts);
8578 msg.textContent = result.message || 'Done.';
8579 const initBtnOk = el('btn-vault-git-init');
8580 if (initBtnOk) initBtnOk.classList.add('hidden');
8581 if (hostedPath && s) {
8582 const refreshed = await api('/api/v1/settings');
8583 lastBackupSettingsPayload = refreshed;
8584 const vg = refreshed.vault_git || {};
8585 let gitText = 'Not configured';
8586 if (vg.enabled && vg.has_remote) {
8587 gitText = 'Configured';
8588 if (vg.auto_commit) gitText += ' (auto-commit on)';
8589 if (vg.auto_push) gitText += ', auto-push on';
8590 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8591 el('settings-git-status').textContent = gitText;
8592 const step4 = document.getElementById('setup-step-4');
8593 if (step4) {
8594 const done = !!(vg.enabled && vg.has_remote);
8595 step4.classList.toggle('setup-step-done', done);
8596 const icon = step4.querySelector('.setup-step-icon');
8597 if (icon) icon.textContent = done ? '✓' : '';
8598 }
8599 }
8600 } catch (e) {
8601 msg.textContent = e.message || 'Sync failed';
8602 msg.className = 'settings-msg err';
8603 const initBtn = el('btn-vault-git-init');
8604 if (initBtn) {
8605 const st = lastBackupSettingsPayload;
8606 const hosted =
8607 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
8608 const needInit =
8609 e.code === 'GIT_NOT_INITIALIZED' ||
8610 /not a Git repository/i.test(e.message || '');
8611 initBtn.classList.toggle('hidden', hosted || !needInit);
8612 }
8613 } finally {
8614 setButtonBusy(syncBtn, false);
8615 const st = lastBackupSettingsPayload;
8616 if (syncBtn && st) {
8617 const vg = st.vault_git || {};
8618 const vd = st.vault_path_display || '';
8619 const ih = (vd + '').toLowerCase() === 'canister';
8620 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
8621 }
8622 }
8623 };
8624 const btnVaultGitInit = el('btn-vault-git-init');
8625 if (btnVaultGitInit) {
8626 btnVaultGitInit.onclick = async () => {
8627 const msg = el('settings-sync-msg');
8628 msg.textContent = 'Initializing Git…';
8629 msg.className = 'settings-msg';
8630 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
8631 try {
8632 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
8633 msg.textContent = out.message || 'Git initialized. Try Back up now.';
8634 msg.className = 'settings-msg ok';
8635 btnVaultGitInit.classList.add('hidden');
8636 } catch (e) {
8637 msg.textContent = e.message || 'Git init failed';
8638 msg.className = 'settings-msg err';
8639 }
8640 });
8641 };
8642 }
8643 const saveSetupBtn = el('btn-settings-save');
8644 if (saveSetupBtn) {
8645 saveSetupBtn.onclick = async () => {
8646 const msg = el('settings-save-msg');
8647 if (msg) {
8648 msg.textContent = 'Saving…';
8649 msg.className = 'settings-msg';
8650 }
8651 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
8652 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
8653 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
8654 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
8655 try {
8656 await api('/api/v1/setup', {
8657 method: 'POST',
8658 body: JSON.stringify({
8659 vault_path: vault_path || undefined,
8660 vault_git: { enabled, remote: remote || undefined },
8661 }),
8662 });
8663 const successText = 'Saved. Config applied.' + (vault_path !== undefined ? ' If you changed the vault path, run Re-index or restart the Hub so search uses the new path.' : '');
8664 if (msg) {
8665 msg.textContent = successText;
8666 msg.className = 'settings-msg ok';
8667 }
8668 if (typeof showToast === 'function') showToast('Setup saved.');
8669 api('/api/v1/settings').then((s) => {
8670 const vd = s.vault_path_display || '—';
8671 const isHostedNow = (vd + '').toLowerCase() === 'canister';
8672 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
8673 el('settings-vault-display').textContent = vd;
8674 const configureSection = el('settings-configure-backup-section');
8675 const configureHr = el('settings-hr-configure');
8676 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
8677 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
8678 const vg = s.vault_git || {};
8679 let gitText = 'Not configured';
8680 if (vg.enabled && vg.has_remote) {
8681 gitText = 'Configured';
8682 if (vg.auto_commit) gitText += ' (auto-commit on)';
8683 if (vg.auto_push) gitText += ', auto-push on';
8684 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8685 el('settings-git-status').textContent = gitText;
8686 const syncBtn = el('btn-settings-sync');
8687 const isAdmin = s.role === 'admin';
8688 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
8689 if (msg) {
8690 msg.textContent = successText;
8691 msg.className = 'settings-msg ok';
8692 }
8693 }).catch(() => {});
8694 } catch (e) {
8695 const errMsg = e.message || 'Save failed';
8696 if (msg) {
8697 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
8698 ? 'Only admins can save setup. Your role is shown under Status above.'
8699 : errMsg;
8700 msg.className = 'settings-msg err';
8701 }
8702 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
8703 }
8704 });
8705 };
8706 }
8707
8708 function defaultFullPath() {
8709 const sel = el('full-path-folder');
8710 const folder =
8711 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8712 return folder + '/note-' + Date.now() + '.md';
8713 }
8714
8715 let fullPathFolderLoadToken = 0;
8716 async function refreshFullPathFolderSelect() {
8717 const sel = el('full-path-folder');
8718 if (!sel || !token) return;
8719 const my = ++fullPathFolderLoadToken;
8720 let folders = ['inbox'];
8721 try {
8722 const data = await api('/api/v1/vault/folders');
8723 if (my !== fullPathFolderLoadToken) return;
8724 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8725 } catch (_) {
8726 if (my !== fullPathFolderLoadToken) return;
8727 }
8728 lastVaultFoldersForCreate = folders.slice();
8729 const preserve = sel.value;
8730 sel.innerHTML = '';
8731 for (const f of folders) {
8732 const o = document.createElement('option');
8733 o.value = f;
8734 o.textContent = f;
8735 sel.appendChild(o);
8736 }
8737 const custom = document.createElement('option');
8738 custom.value = '__custom__';
8739 custom.textContent = 'Custom (type path below)';
8740 sel.appendChild(custom);
8741 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8742 else sel.value = folders[0] || 'inbox';
8743 refreshFullCreateSubrootSelect();
8744 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
8745 }
8746
8747 let importVaultFolderLoadToken = 0;
8748 async function refreshImportVaultFolderSelect() {
8749 const sel = el('import-vault-folder');
8750 if (!sel || !token) return;
8751 const my = ++importVaultFolderLoadToken;
8752 let folders = ['inbox'];
8753 try {
8754 const data = await api('/api/v1/vault/folders');
8755 if (my !== importVaultFolderLoadToken) return;
8756 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8757 } catch (_) {
8758 if (my !== importVaultFolderLoadToken) return;
8759 }
8760 lastVaultFoldersForCreate = folders.slice();
8761 const preserve = sel.value;
8762 sel.innerHTML = '';
8763 for (const f of folders) {
8764 const o = document.createElement('option');
8765 o.value = f;
8766 o.textContent = f;
8767 sel.appendChild(o);
8768 }
8769 const custom = document.createElement('option');
8770 custom.value = '__custom__';
8771 custom.textContent = 'Custom (type path below)';
8772 sel.appendChild(custom);
8773 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8774 else sel.value = folders[0] || 'inbox';
8775 refreshImportCreateSubrootSelect();
8776 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
8777 }
8778
8779 function syncFolderSelectToPathInput() {
8780 const pathInput = el('full-path');
8781 const sel = el('full-path-folder');
8782 if (!pathInput || !sel) return;
8783 const p = pathInput.value.trim();
8784 if (!p) return;
8785 let best = '__custom__';
8786 let bestLen = -1;
8787 for (const opt of sel.options) {
8788 const v = opt.value;
8789 if (v === '__custom__') continue;
8790 if (p === v || p.startsWith(v + '/')) {
8791 if (v.length > bestLen) {
8792 best = v;
8793 bestLen = v.length;
8794 }
8795 }
8796 }
8797 sel.value = bestLen >= 0 ? best : '__custom__';
8798 }
8799
8800 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
8801 function syncFullProjectFromPath() {
8802 const pi = el('full-path');
8803 const fp = el('full-project');
8804 if (!pi || !fp) return;
8805 const slug = projectSlugFromProjectsPath(pi.value.trim());
8806 if (slug) {
8807 fp.value = slug;
8808 fp.readOnly = true;
8809 fp.title = 'Derived from vault path projects/' + slug + '/';
8810 } else {
8811 fp.readOnly = false;
8812 fp.removeAttribute('title');
8813 }
8814 updateFullPathProjectTypoHint();
8815 }
8816
8817 function updateFullPathProjectTypoHint() {
8818 const pi = el('full-path');
8819 const hint = el('full-path-project-typo-hint');
8820 const fixBtn = el('btn-full-path-fix-typo');
8821 if (!pi || !hint) return;
8822 const raw = pi.value.trim();
8823 const sug = projectsPathTypoSuggestion(raw);
8824 if (sug) {
8825 hint.textContent =
8826 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
8827 hint.className = 'muted small detail-project-hint warn';
8828 hint.classList.remove('hidden');
8829 if (fixBtn) {
8830 fixBtn.classList.remove('hidden');
8831 fixBtn.onclick = () => {
8832 pi.value = sug;
8833 syncFolderSelectToPathInput();
8834 syncFullCreatePickersFromPath();
8835 syncFullProjectFromPath();
8836 scheduleFullCreateSimilarHint();
8837 };
8838 }
8839 } else {
8840 hint.textContent = '';
8841 hint.className = 'muted small detail-project-hint hidden';
8842 hint.classList.add('hidden');
8843 if (fixBtn) {
8844 fixBtn.classList.add('hidden');
8845 fixBtn.onclick = null;
8846 }
8847 }
8848 }
8849
8850 const fullPathFolderEl = () => el('full-path-folder');
8851 const fullPathInputEl = () => el('full-path');
8852 if (fullPathFolderEl() && fullPathInputEl()) {
8853 fullPathFolderEl().addEventListener('change', () => {
8854 const sel = fullPathFolderEl();
8855 if (!sel || sel.value === '__custom__') return;
8856 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
8857 syncFullCreatePickersFromPath();
8858 syncFullProjectFromPath();
8859 updateFullPathProjectTypoHint();
8860 scheduleFullCreateSimilarHint();
8861 });
8862 fullPathInputEl().addEventListener('input', () => {
8863 syncFolderSelectToPathInput();
8864 syncFullCreatePickersFromPath();
8865 syncFullProjectFromPath();
8866 updateFullPathProjectTypoHint();
8867 scheduleFullCreateSimilarHint();
8868 });
8869 fullPathInputEl().addEventListener('change', () => {
8870 syncFullCreatePickersFromPath();
8871 updateFullCreateSimilarInlineHint();
8872 });
8873 }
8874
8875 const fullCreateProjectSlugEl = el('full-create-project-slug');
8876 const fullCreateProjectSubEl = el('full-create-project-subroot');
8877 if (fullCreateProjectSlugEl) {
8878 fullCreateProjectSlugEl.addEventListener('change', () => {
8879 refreshFullCreateSubrootSelect();
8880 updateFullCreatePathLayoutVisibility();
8881 const v = fullCreateProjectSlugEl.value;
8882 const pi = el('full-path');
8883 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
8884 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
8885 syncFolderSelectToPathInput();
8886 syncFullProjectFromPath();
8887 updateFullPathProjectTypoHint();
8888 scheduleFullCreateSimilarHint();
8889 });
8890 }
8891 if (fullCreateProjectSubEl) {
8892 fullCreateProjectSubEl.addEventListener('change', () => {
8893 composeFullPathFromCreatePickers();
8894 syncFolderSelectToPathInput();
8895 syncFullProjectFromPath();
8896 updateFullPathProjectTypoHint();
8897 scheduleFullCreateSimilarHint();
8898 });
8899 }
8900
8901 const importCreateProjectSlugEl = el('import-create-project-slug');
8902 const importCreateProjectSubEl = el('import-create-project-subroot');
8903 const importVaultFolderEl = el('import-vault-folder');
8904 const importOutputDirEl = el('import-output-dir');
8905 if (importVaultFolderEl) {
8906 importVaultFolderEl.addEventListener('change', () => {
8907 const sel = importVaultFolderEl;
8908 const out = el('import-output-dir');
8909 if (!sel || !out || sel.value === '__custom__') return;
8910 out.value = sel.value;
8911 syncImportPickersFromOutputDir();
8912 });
8913 }
8914 if (importOutputDirEl) {
8915 importOutputDirEl.addEventListener('input', () => {
8916 syncImportFolderSelectToOutputDir();
8917 syncImportPickersFromOutputDir();
8918 });
8919 }
8920 if (importCreateProjectSlugEl) {
8921 importCreateProjectSlugEl.addEventListener('change', () => {
8922 refreshImportCreateSubrootSelect();
8923 updateImportPathLayoutVisibility();
8924 const v = importCreateProjectSlugEl.value;
8925 const out = el('import-output-dir');
8926 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
8927 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
8928 const sel = el('import-vault-folder');
8929 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8930 }
8931 syncImportFolderSelectToOutputDir();
8932 syncImportPickersFromOutputDir();
8933 });
8934 }
8935 if (importCreateProjectSubEl) {
8936 importCreateProjectSubEl.addEventListener('change', () => {
8937 composeImportOutputDirFromPickers();
8938 syncImportFolderSelectToOutputDir();
8939 syncImportPickersFromOutputDir();
8940 });
8941 }
8942
8943 document.querySelectorAll('.modal-tab').forEach((t) => {
8944 t.onclick = () => {
8945 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
8946 t.classList.add('active');
8947 const tab = t.dataset.createTab;
8948 el('create-quick').classList.toggle('hidden', tab !== 'quick');
8949 el('create-full').classList.toggle('hidden', tab !== 'full');
8950 if (tab === 'full') {
8951 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
8952 void (async () => {
8953 await refreshFullPathFolderSelect();
8954 if (!lastHubFacets) {
8955 try {
8956 lastHubFacets = await fetchFacetsResolved();
8957 } catch (_) {}
8958 }
8959 hydrateFullCreateProjectSlugSelect(lastHubFacets);
8960 const pi = el('full-path');
8961 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
8962 else syncFolderSelectToPathInput();
8963 syncFullCreatePickersFromPath();
8964 syncFullProjectFromPath();
8965 updateFullPathProjectTypoHint();
8966 updateFullCreateSimilarInlineHint();
8967 })();
8968 }
8969 };
8970 });
8971
8972 el('btn-quick-save').onclick = async () => {
8973 const quickBtn = el('btn-quick-save');
8974 const body = el('quick-body').value.trim();
8975 const msg = el('create-msg-quick');
8976 if (!body) {
8977 msg.textContent = 'Enter some text.';
8978 msg.className = 'create-msg err';
8979 return;
8980 }
8981 const projectRaw = el('quick-project').value.trim();
8982 const pslug = normSlug(projectRaw);
8983 const today = ymd(new Date());
8984 const slug = 'hub_' + Date.now();
8985 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
8986 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
8987 await withButtonBusy(quickBtn, 'Saving…', async () => {
8988 try {
8989 await api('/api/v1/notes', {
8990 method: 'POST',
8991 body: stringifyNotePostPayload(path, body, {
8992 source: 'hub',
8993 date: today,
8994 title,
8995 ...(pslug && { project: pslug }),
8996 }),
8997 });
8998 hubMarkSemanticIndexStale();
8999 msg.textContent = 'Saved: ' + path;
9000 msg.className = 'create-msg ok';
9001 el('quick-body').value = '';
9002 loadFacets();
9003 loadNotes();
9004 closeCreateModal();
9005 } catch (e) {
9006 msg.textContent = e.message;
9007 msg.className = 'create-msg err';
9008 }
9009 });
9010 };
9011
9012 async function submitFullCreateNote() {
9013 const fullBtn = el('btn-full-save');
9014 const notePath = el('full-path').value.trim();
9015 const pathProjFull = projectSlugFromProjectsPath(notePath);
9016 const msg = el('create-msg-full');
9017 if (!notePath) {
9018 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
9019 msg.className = 'create-msg err';
9020 return;
9021 }
9022 const pathTypoSug = projectsPathTypoSuggestion(notePath);
9023 if (pathTypoSug) {
9024 msg.textContent =
9025 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
9026 pathTypoSug;
9027 msg.className = 'create-msg err';
9028 return;
9029 }
9030 if (!notePath.endsWith('.md')) {
9031 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
9032 msg.className = 'create-msg err';
9033 return;
9034 }
9035 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
9036 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
9037 const dest = notePath.replace(/\\/g, '/');
9038 if (src === dest) {
9039 msg.textContent =
9040 'When duplicating, pick a different path than the original (same path would overwrite the original).';
9041 msg.className = 'create-msg err';
9042 return;
9043 }
9044 }
9045 const slugFromPath = projectSlugFromProjectsPath(notePath);
9046 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
9047 const similarGuess =
9048 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
9049 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
9050 : null;
9051 if (similarGuess) {
9052 openFullCreateSimilarModal(notePath, similarGuess);
9053 return;
9054 }
9055 fullCreateSimilarOverrideOnce = false;
9056 const title = el('full-title').value.trim();
9057 const body = el('full-body').value;
9058 const project = pathProjFull || el('full-project').value.trim();
9059 const tags = el('full-tags').value.trim();
9060 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
9061 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
9062 const entityRaw = el('full-entity') && el('full-entity').value.trim();
9063 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
9064 const episode = el('full-episode') && el('full-episode').value.trim();
9065 const followsRaw = el('full-follows') && el('full-follows').value.trim();
9066 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
9067 const fm = {
9068 date: dateVal,
9069 ...(title && { title }),
9070 ...(project && { project }),
9071 ...(tags && { tags }),
9072 ...(causalChain && { causal_chain_id: causalChain }),
9073 ...(entity && entity.length && { entity }),
9074 ...(episode && { episode_id: episode }),
9075 ...(follows && { follows }),
9076 };
9077 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
9078 await withButtonBusy(fullBtn, savingLabel, async () => {
9079 try {
9080 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
9081 hubMarkSemanticIndexStale();
9082 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
9083 msg.className = 'create-msg ok';
9084 const dupSrc = pendingDuplicateDeleteSource;
9085 const delChk = el('duplicate-delete-after-save');
9086 const shouldDeleteOriginal =
9087 dupSrc &&
9088 dupSrc.path &&
9089 delChk &&
9090 delChk.checked &&
9091 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
9092 if (shouldDeleteOriginal) {
9093 try {
9094 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
9095 if (typeof showToast === 'function') showToast('Original note deleted');
9096 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
9097 const bcb = el('btn-detail-copy-body');
9098 if (bcb) bcb.classList.add('hidden');
9099 } catch (delErr) {
9100 if (typeof showToast === 'function') {
9101 showToast(
9102 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
9103 true,
9104 );
9105 }
9106 }
9107 }
9108 void refreshFullPathFolderSelect().then(() => {
9109 el('full-path').value = defaultFullPath();
9110 syncFolderSelectToPathInput();
9111 syncFullCreatePickersFromPath();
9112 syncFullProjectFromPath();
9113 updateFullCreateSimilarInlineHint();
9114 });
9115 el('full-title').value = '';
9116 el('full-body').value = '';
9117 el('full-project').value = '';
9118 el('full-tags').value = '';
9119 if (el('full-date')) el('full-date').value = '';
9120 if (el('full-causal-chain')) el('full-causal-chain').value = '';
9121 if (el('full-entity')) el('full-entity').value = '';
9122 if (el('full-episode')) el('full-episode').value = '';
9123 if (el('full-follows')) el('full-follows').value = '';
9124 loadFacets();
9125 loadNotes();
9126 closeCreateModal();
9127 } catch (e) {
9128 msg.textContent = e.message;
9129 msg.className = 'create-msg err';
9130 }
9131 });
9132 }
9133
9134 el('btn-full-save').onclick = () => {
9135 void submitFullCreateNote();
9136 };
9137
9138 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
9139 const modalSimilarClose = el('modal-create-similar-project-close');
9140 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
9141 const btnSimilarKeep = el('btn-modal-create-similar-keep');
9142 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
9143 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
9144 if (btnSimilarUseExisting) {
9145 btnSimilarUseExisting.onclick = () => {
9146 const path = fullCreateSimilarModalPendingPath;
9147 const slug = fullCreateSimilarModalSuggestedSlug;
9148 closeFullCreateSimilarModal();
9149 if (path && slug) {
9150 const pi = el('full-path');
9151 if (pi) {
9152 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
9153 syncFolderSelectToPathInput();
9154 syncFullCreatePickersFromPath();
9155 syncFullProjectFromPath();
9156 updateFullPathProjectTypoHint();
9157 updateFullCreateSimilarInlineHint();
9158 }
9159 }
9160 fullCreateSimilarOverrideOnce = false;
9161 void submitFullCreateNote();
9162 };
9163 }
9164 if (btnSimilarKeep) {
9165 btnSimilarKeep.onclick = () => {
9166 closeFullCreateSimilarModal();
9167 fullCreateSimilarOverrideOnce = true;
9168 void submitFullCreateNote();
9169 };
9170 }
9171
9172 function formatDetailReadBody(body, fm) {
9173 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
9174 const keys = Object.keys(o);
9175 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
9176 if (keys.length === 0 && hubUserCanWriteNotes()) {
9177 text +=
9178 '\n\n—\nNo metadata is stored for this file on the server yet (common for older hosted notes). Hosted Hub uses the same read view as self-hosted: after you Edit → Save once, the JSON block here fills with keys like title, tags, date, and provenance—same idea as on localhost. Overview and Quick tags then pick that up. To fix many notes at once from a computer, use `npm run resave:hosted-empty-fm` or `node scripts/resave-hosted-empty-frontmatter.mjs` (set `KNOWTATION_HUB_TOKEN` per `scripts/resave-hosted-empty-frontmatter.mjs` header).';
9179 }
9180 return text;
9181 }
9182
9183 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
9184 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
9185
9186 function videoExtToMime(url) {
9187 try {
9188 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
9189 return VIDEO_MIME_MAP[ext] || 'video/mp4';
9190 } catch (_) {
9191 var clean = url.split('?')[0].split('#')[0];
9192 var ext2 = clean.split('.').pop().toLowerCase();
9193 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
9194 }
9195 }
9196
9197 /**
9198 * Ensure standalone video URL lines are surrounded by blank lines in the raw
9199 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
9200 * mode joins adjacent lines (e.g. a video URL followed immediately by image
9201 * markdown) into a single <p>, which prevents the video-URL regex from matching.
9202 */
9203 function isolateVideoUrlLines(md) {
9204 // Match any line whose entire content is a bare https video URL.
9205 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
9206 // and after so marked always puts the URL in its own paragraph.
9207 return md.replace(
9208 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
9209 '\n$1$2\n'
9210 );
9211 }
9212
9213 /**
9214 * Replace bare video URLs (on their own line) with <video> elements.
9215 * Handles two forms that marked produces for a bare URL on its own paragraph:
9216 * 1. GFM autolink: <p><a href="URL">URL</a></p>
9217 * 2. Plain text: <p>URL</p>
9218 * Runs before DOMPurify so the sanitiser validates the output.
9219 */
9220 function expandVideoUrls(html) {
9221 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
9222
9223 // GFM autolink form: <p><a href="URL">...</a></p>
9224 var result = html.replace(
9225 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
9226 function (match, url) {
9227 if (!VIDEO_EXT_PAT.test(url)) return match;
9228 var mime = videoExtToMime(url);
9229 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
9230 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
9231 'Your browser does not support embedded video.</video>';
9232 }
9233 );
9234
9235 // Plain text form: <p>URL</p>
9236 result = result.replace(
9237 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
9238 function (match, url) {
9239 if (!VIDEO_EXT_PAT.test(url)) return match;
9240 var mime = videoExtToMime(url);
9241 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
9242 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
9243 'Your browser does not support embedded video.</video>';
9244 }
9245 );
9246
9247 return result;
9248 }
9249
9250 var SANITIZE_OPTS_NOTE = {
9251 ADD_TAGS: ['details', 'summary', 'video', 'source'],
9252 ADD_ATTR: ['controls', 'preload', 'type'],
9253 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
9254 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
9255 };
9256
9257 /**
9258 * Render markdown text as sanitised HTML.
9259 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
9260 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
9261 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
9262 */
9263 var _imageProxyToken = null;
9264 var _imageProxyTokenExp = 0;
9265
9266 async function getImageProxyToken() {
9267 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
9268 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9269 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
9270 if (!jwt) return '';
9271 try {
9272 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
9273 headers: { authorization: 'Bearer ' + jwt },
9274 });
9275 if (!res.ok) return '';
9276 var data = await res.json();
9277 _imageProxyToken = data.token || '';
9278 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
9279 return _imageProxyToken;
9280 } catch (_) { return ''; }
9281 }
9282
9283 /**
9284 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
9285 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
9286 * Falls back to no rewrite if no cached image token is available yet.
9287 */
9288 function rewriteGitHubImageUrls(html) {
9289 var tok = _imageProxyToken || '';
9290 if (!tok) {
9291 // Fallback: use session JWT — gateway accepts it via backward-compat path.
9292 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
9293 }
9294 if (!tok) return html;
9295 var encodedTok = encodeURIComponent(tok);
9296 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9297 return html.replace(
9298 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
9299 function (match, pre, rest) {
9300 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
9301 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
9302 }
9303 );
9304 }
9305
9306 function renderNoteMarkdownHtml(md) {
9307 try {
9308 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9309 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9310 var withVideo = expandVideoUrls(raw);
9311 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9312 return rewriteGitHubImageUrls(sanitised);
9313 }
9314 } catch (_) { /* fall through */ }
9315 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
9316 }
9317
9318 /**
9319 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
9320 */
9321 function buildNoteReadHtml(body, fm) {
9322 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
9323 const keys = Object.keys(o);
9324 const bodyHtml = renderNoteMarkdownHtml(body || '');
9325 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
9326 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
9327 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
9328 : '';
9329 return (
9330 bodyHtml +
9331 '<details class="note-meta-block">' +
9332 '<summary>Metadata</summary>' +
9333 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
9334 emptyNote +
9335 '</details>'
9336 );
9337 }
9338
9339 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
9340 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
9341 'absolute_path',
9342 'body',
9343 'body_length',
9344 'byte_offset',
9345 'byte_offsets',
9346 'frontmatter',
9347 'line_range',
9348 'line_ranges',
9349 'mcp_resource_uri',
9350 'provider_payload',
9351 'raw_canister_payload',
9352 'resource_uri',
9353 'section_body',
9354 'section_body_length',
9355 'snippet',
9356 'snippets',
9357 ]);
9358
9359 function normalizeSectionSourcePathForUi(path) {
9360 const value = String(path || '').trim();
9361 if (!value) return '';
9362 if (value.includes('\\') || value.includes('\0')) return '';
9363 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
9364 if (value.split('/').some((part) => part === '..')) return '';
9365 return value;
9366 }
9367
9368 function sectionSourceEndpointForPath(path) {
9369 return '/api/v1/section-source?path=' + encodeURIComponent(path);
9370 }
9371
9372 function sectionSourcePayloadHasForbiddenKeys(value) {
9373 if (!value || typeof value !== 'object') return false;
9374 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
9375 for (const [key, child] of Object.entries(value)) {
9376 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
9377 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
9378 }
9379 return false;
9380 }
9381
9382 function normalizeSectionSourceForRender(data) {
9383 if (!data || typeof data !== 'object' || Array.isArray(data)) {
9384 throw new Error('INVALID_SECTION_SOURCE');
9385 }
9386 if (sectionSourcePayloadHasForbiddenKeys(data)) {
9387 throw new Error('INVALID_SECTION_SOURCE');
9388 }
9389 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
9390 throw new Error('INVALID_SECTION_SOURCE');
9391 }
9392 return {
9393 schema: SECTION_SOURCE_SCHEMA,
9394 path: String(data.path || ''),
9395 title: String(data.title || ''),
9396 truncated: data.truncated === true,
9397 sections: data.sections.map((section) => {
9398 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
9399 const normalized = {
9400 section_id: String(item.section_id || ''),
9401 heading_id: String(item.heading_id || ''),
9402 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
9403 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
9404 heading_text: String(item.heading_text || ''),
9405 child_section_ids: Array.isArray(item.child_section_ids)
9406 ? item.child_section_ids.map((childId) => String(childId))
9407 : [],
9408 body_available: item.body_available === true,
9409 body_returned: item.body_returned === true,
9410 snippet_returned: item.snippet_returned === true,
9411 };
9412 if (normalized.body_returned || normalized.snippet_returned) {
9413 throw new Error('INVALID_SECTION_SOURCE');
9414 }
9415 return normalized;
9416 }),
9417 };
9418 }
9419
9420 function resetDetailSectionSourceState() {
9421 hubSectionSourceSeq += 1;
9422 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
9423 }
9424
9425 function setSectionSourcePanelState(panel, state, message) {
9426 panel.className = 'section-source-panel section-source-panel-' + state;
9427 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
9428 panel.setAttribute('aria-label', 'Body-free section list');
9429 panel.setAttribute('aria-live', 'polite');
9430 panel.replaceChildren();
9431 const text = document.createElement('p');
9432 text.className = 'section-source-state';
9433 text.textContent = message;
9434 panel.appendChild(text);
9435 }
9436
9437 function sectionSourceErrorMessage(error) {
9438 const code = error && error.code ? String(error.code) : '';
9439 const message = error && error.message ? String(error.message) : '';
9440 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
9441 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
9442 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
9443 if (message === 'Unauthorized') return 'Sign in to view sections.';
9444 return 'Sections are unavailable right now.';
9445 }
9446
9447 function appendSectionSourceDebugRow(list, labelText, valueText) {
9448 const label = document.createElement('dt');
9449 label.textContent = labelText;
9450 const value = document.createElement('dd');
9451 value.textContent = valueText;
9452 list.append(label, value);
9453 }
9454
9455 function renderSectionSourceData(panel, source) {
9456 panel.className = 'section-source-panel';
9457 panel.setAttribute('role', 'region');
9458 panel.setAttribute('aria-label', 'Body-free section list');
9459 panel.setAttribute('aria-live', 'polite');
9460 panel.replaceChildren();
9461
9462 const header = document.createElement('div');
9463 header.className = 'section-source-header';
9464 const title = document.createElement('h3');
9465 title.textContent = 'Sections';
9466 const meta = document.createElement('p');
9467 meta.className = 'muted small';
9468 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
9469 header.append(title, meta);
9470 panel.appendChild(header);
9471
9472 if (source.truncated) {
9473 const truncated = document.createElement('p');
9474 truncated.className = 'section-source-state section-source-truncated';
9475 truncated.textContent = 'Section list is capped for display.';
9476 panel.appendChild(truncated);
9477 }
9478
9479 if (source.sections.length === 0) {
9480 const empty = document.createElement('p');
9481 empty.className = 'section-source-state';
9482 empty.textContent = 'No headings are available for this note.';
9483 panel.appendChild(empty);
9484 return;
9485 }
9486
9487 const list = document.createElement('ol');
9488 list.className = 'section-source-list';
9489 for (const section of source.sections) {
9490 const item = document.createElement('li');
9491 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
9492
9493 const heading = document.createElement('p');
9494 heading.className = 'section-source-heading';
9495 const levelBadge = document.createElement('span');
9496 levelBadge.className = 'section-source-level-label';
9497 levelBadge.textContent = 'H' + section.level;
9498 const headingText = document.createElement('span');
9499 headingText.className = 'section-source-heading-text';
9500 headingText.textContent = section.heading_text || '(Untitled section)';
9501 heading.append(levelBadge, headingText);
9502 item.appendChild(heading);
9503
9504 const detail = document.createElement('p');
9505 detail.className = 'section-source-detail muted small';
9506 detail.textContent = 'Heading level: H' + section.level;
9507 item.appendChild(detail);
9508
9509 const pathLine = document.createElement('p');
9510 pathLine.className = 'section-source-path muted small';
9511 pathLine.textContent =
9512 'Heading path: ' +
9513 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
9514 item.appendChild(pathLine);
9515
9516 const childLine = document.createElement('p');
9517 childLine.className = 'section-source-children muted small';
9518 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
9519 item.appendChild(childLine);
9520
9521 const debugDetails = document.createElement('details');
9522 debugDetails.className = 'section-source-debug muted small';
9523 const debugSummary = document.createElement('summary');
9524 debugSummary.textContent = 'IDs';
9525 const debugList = document.createElement('dl');
9526 debugList.className = 'section-source-debug-list';
9527 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
9528 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
9529 appendSectionSourceDebugRow(
9530 debugList,
9531 'Child IDs',
9532 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
9533 );
9534 debugDetails.append(debugSummary, debugList);
9535 item.appendChild(debugDetails);
9536
9537 list.appendChild(item);
9538 }
9539 panel.appendChild(list);
9540 }
9541
9542 async function loadSectionSourceForCurrentNote(actionsEl, button) {
9543 let panel = actionsEl.querySelector('[data-section-source-panel]');
9544 if (!panel) {
9545 panel = document.createElement('div');
9546 panel.dataset.sectionSourcePanel = 'true';
9547 actionsEl.appendChild(panel);
9548 }
9549 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
9550 if (!path) {
9551 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
9552 return;
9553 }
9554 const seq = ++hubSectionSourceSeq;
9555 const openPath = currentOpenNote.path;
9556 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
9557 if (button) {
9558 button.disabled = true;
9559 button.setAttribute('aria-expanded', 'true');
9560 }
9561 try {
9562 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
9563 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9564 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
9565 } catch (error) {
9566 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9567 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
9568 } finally {
9569 if (button && currentOpenNote && currentOpenNote.path === openPath) {
9570 button.disabled = false;
9571 }
9572 }
9573 }
9574
9575 function toggleSectionSourcePanel(actionsEl, button) {
9576 const panel = actionsEl.querySelector('[data-section-source-panel]');
9577 if (panel) {
9578 hubSectionSourceSeq += 1;
9579 panel.remove();
9580 if (button) button.setAttribute('aria-expanded', 'false');
9581 return;
9582 }
9583 void loadSectionSourceForCurrentNote(actionsEl, button);
9584 }
9585
9586 function createSectionSourceButton(actionsEl) {
9587 const sectionBtn = document.createElement('button');
9588 sectionBtn.type = 'button';
9589 sectionBtn.textContent = 'Sections';
9590 sectionBtn.className = 'btn-section-source';
9591 sectionBtn.setAttribute('aria-expanded', 'false');
9592 sectionBtn.setAttribute('aria-controls', 'detail-actions');
9593 sectionBtn.title = 'Show body-free section headings for this note';
9594 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
9595 return sectionBtn;
9596 }
9597
9598 function switchNoteToReadMode() {
9599 if (!currentOpenNote) return;
9600 resetDetailSectionSourceState();
9601 teardownDetailEditBodyLayout();
9602 const bodyEl = el('detail-body');
9603 const actionsEl = el('detail-actions');
9604 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
9605 bodyEl.className = 'note-rendered-body';
9606 actionsEl.innerHTML = '';
9607 attachNoteDetailReadActions(actionsEl);
9608 const bcbRead = el('btn-detail-copy-body');
9609 if (bcbRead) bcbRead.classList.remove('hidden');
9610 }
9611
9612 async function deleteOpenNote() {
9613 if (!currentOpenNote) return;
9614 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
9615 const p = currentOpenNote.path;
9616 try {
9617 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
9618 if (typeof showToast === 'function') showToast('Note deleted');
9619 hubMarkSemanticIndexStale();
9620 currentOpenNote = null;
9621 currentNotePathForCopy = '';
9622 resetDetailSectionSourceState();
9623 teardownDetailEditBodyLayout();
9624 hideDetailPanelChrome();
9625 el('btn-copy-path').classList.add('hidden');
9626 const bcbDel = el('btn-detail-copy-body');
9627 if (bcbDel) bcbDel.classList.add('hidden');
9628 loadNotes();
9629 loadFacets();
9630 } catch (e) {
9631 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
9632 }
9633 }
9634
9635 function attachNoteDetailReadActions(actionsEl) {
9636 const exportBtn = document.createElement('button');
9637 exportBtn.type = 'button';
9638 exportBtn.textContent = 'Export';
9639 exportBtn.onclick = () => exportCurrentNote('md');
9640 const sectionBtn = createSectionSourceButton(actionsEl);
9641
9642 if (hubUserCanWriteNotes()) {
9643 const editBtn = document.createElement('button');
9644 editBtn.type = 'button';
9645 editBtn.textContent = 'Edit';
9646 editBtn.onclick = () => switchNoteToEditMode();
9647 const dupBtn = document.createElement('button');
9648 dupBtn.type = 'button';
9649 dupBtn.textContent = 'Duplicate…';
9650 dupBtn.title =
9651 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
9652 dupBtn.onclick = () => {
9653 void openDuplicateNoteModal();
9654 };
9655 const proposeBtn = document.createElement('button');
9656 proposeBtn.type = 'button';
9657 proposeBtn.textContent = 'Propose change';
9658 proposeBtn.onclick = () => {
9659 if (!currentOpenNote) return;
9660 openCreateProposalModal({
9661 path: currentOpenNote.path,
9662 body: currentOpenNote.body || '',
9663 fromNote: true,
9664 });
9665 };
9666 const delBtn = document.createElement('button');
9667 delBtn.type = 'button';
9668 delBtn.textContent = 'Delete';
9669 delBtn.onclick = () => deleteOpenNote();
9670 if (hubHasMultipleVaultsForCopy()) {
9671 const copyVaultBtn = document.createElement('button');
9672 copyVaultBtn.type = 'button';
9673 copyVaultBtn.textContent = 'Copy to vault…';
9674 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
9675 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
9676 } else {
9677 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
9678 }
9679 return;
9680 }
9681
9682 if (hubUserMayProposeFromNote()) {
9683 const proposeBtn = document.createElement('button');
9684 proposeBtn.type = 'button';
9685 proposeBtn.textContent = 'Propose change';
9686 proposeBtn.onclick = () => {
9687 if (!currentOpenNote) return;
9688 openCreateProposalModal({
9689 path: currentOpenNote.path,
9690 body: currentOpenNote.body || '',
9691 fromNote: true,
9692 });
9693 };
9694 actionsEl.appendChild(proposeBtn);
9695 }
9696 actionsEl.appendChild(sectionBtn);
9697 if (hubUserCanExportNote()) {
9698 actionsEl.appendChild(exportBtn);
9699 }
9700 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
9701 const hint = document.createElement('p');
9702 hint.className = 'muted small';
9703 hint.style.marginTop = '0.5rem';
9704 hint.textContent =
9705 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
9706 actionsEl.appendChild(hint);
9707 }
9708 }
9709
9710 function openCopyNoteToVaultModal() {
9711 if (!currentOpenNote || !token) return;
9712 if (!hubHasMultipleVaultsForCopy()) {
9713 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
9714 return;
9715 }
9716 const existing = document.getElementById('modal-copy-note-vault');
9717 if (existing) existing.remove();
9718 const s = lastBackupSettingsPayload;
9719 const allowed = new Set((s.allowed_vault_ids || []).map(String));
9720 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
9721 const fromId = String(getCurrentVaultId() || 'default');
9722 const targets = vaultList.filter((v) => String(v.id) !== fromId);
9723 if (targets.length === 0) {
9724 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
9725 return;
9726 }
9727 const wrap = document.createElement('div');
9728 wrap.id = 'modal-copy-note-vault';
9729 wrap.className = 'modal';
9730 wrap.setAttribute('role', 'dialog');
9731 wrap.setAttribute('aria-modal', 'true');
9732 wrap.setAttribute('aria-label', 'Copy note to another vault');
9733 const backdrop = document.createElement('div');
9734 backdrop.className = 'modal-backdrop';
9735 const card = document.createElement('div');
9736 card.className = 'modal-card';
9737 card.style.maxWidth = '480px';
9738 const header = document.createElement('div');
9739 header.className = 'modal-header';
9740 const h2 = document.createElement('h2');
9741 h2.textContent = 'Copy to vault';
9742 const btnClose = document.createElement('button');
9743 btnClose.type = 'button';
9744 btnClose.className = 'modal-close';
9745 btnClose.textContent = '×';
9746 btnClose.setAttribute('aria-label', 'Close');
9747 header.appendChild(h2);
9748 header.appendChild(btnClose);
9749 const body = document.createElement('div');
9750 body.style.padding = '1rem 1.25rem';
9751 const lbl = document.createElement('label');
9752 lbl.className = 'detail-field-label';
9753 lbl.textContent = 'Target vault';
9754 lbl.setAttribute('for', 'copy-note-vault-select');
9755 const sel = document.createElement('select');
9756 sel.id = 'copy-note-vault-select';
9757 sel.className = 'vault-switcher-select';
9758 sel.style.width = '100%';
9759 sel.style.marginTop = '0.35rem';
9760 for (const v of targets) {
9761 const id = String(v.id);
9762 const opt = document.createElement('option');
9763 opt.value = id;
9764 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
9765 sel.appendChild(opt);
9766 }
9767 const moveRow = document.createElement('label');
9768 moveRow.style.display = 'flex';
9769 moveRow.style.alignItems = 'center';
9770 moveRow.style.gap = '0.5rem';
9771 moveRow.style.marginTop = '1rem';
9772 moveRow.style.cursor = 'pointer';
9773 const moveChk = document.createElement('input');
9774 moveChk.type = 'checkbox';
9775 moveChk.id = 'copy-note-delete-source';
9776 const moveSpan = document.createElement('span');
9777 moveSpan.textContent = 'Delete from this vault (move)';
9778 moveRow.appendChild(moveChk);
9779 moveRow.appendChild(moveSpan);
9780 const hint = document.createElement('p');
9781 hint.className = 'muted small';
9782 hint.style.marginTop = '0.75rem';
9783 hint.style.fontSize = '0.85rem';
9784 hint.textContent =
9785 'If a note with the same path exists in the target vault, it will be overwritten. On hosted, semantic search catches up after re-index (started automatically).';
9786 const actions = document.createElement('div');
9787 actions.style.display = 'flex';
9788 actions.style.justifyContent = 'flex-end';
9789 actions.style.gap = '0.5rem';
9790 actions.style.marginTop = '1.25rem';
9791 const btnCancel = document.createElement('button');
9792 btnCancel.type = 'button';
9793 btnCancel.className = 'btn-secondary';
9794 btnCancel.textContent = 'Cancel';
9795 const btnGo = document.createElement('button');
9796 btnGo.type = 'button';
9797 btnGo.className = 'btn-primary';
9798 btnGo.textContent = 'Copy';
9799 actions.appendChild(btnCancel);
9800 actions.appendChild(btnGo);
9801 body.appendChild(lbl);
9802 body.appendChild(sel);
9803 body.appendChild(moveRow);
9804 body.appendChild(hint);
9805 body.appendChild(actions);
9806 card.appendChild(header);
9807 card.appendChild(body);
9808 wrap.appendChild(backdrop);
9809 wrap.appendChild(card);
9810 function close() {
9811 wrap.remove();
9812 }
9813 backdrop.onclick = close;
9814 btnClose.onclick = close;
9815 btnCancel.onclick = close;
9816 btnGo.onclick = async () => {
9817 const toId = sel.value;
9818 if (!toId || !currentOpenNote) return;
9819 await withButtonBusy(btnGo, 'Copying…', async () => {
9820 try {
9821 const res = await api('/api/v1/notes/copy', {
9822 method: 'POST',
9823 body: JSON.stringify({
9824 from_vault_id: fromId,
9825 to_vault_id: toId,
9826 path: currentOpenNote.path,
9827 delete_source: moveChk.checked,
9828 }),
9829 });
9830 hubMarkSemanticIndexStaleForVault(toId);
9831 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
9832 close();
9833 if (typeof showToast === 'function') {
9834 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
9835 }
9836 if (res.moved) {
9837 currentOpenNote = null;
9838 currentNotePathForCopy = '';
9839 resetDetailSectionSourceState();
9840 hideDetailPanelChrome();
9841 const bcp = el('btn-copy-path');
9842 if (bcp) bcp.classList.add('hidden');
9843 loadNotes();
9844 loadFacets();
9845 }
9846 } catch (e) {
9847 if (typeof showToast === 'function') showToast(e.message || String(e), true);
9848 }
9849 });
9850 };
9851 document.body.appendChild(wrap);
9852 }
9853
9854 async function exportCurrentNote(format) {
9855 if (!currentOpenNote) return;
9856 try {
9857 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
9858 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
9859 const a = document.createElement('a');
9860 a.href = URL.createObjectURL(blob);
9861 a.download = res.filename || 'export.md';
9862 a.click();
9863 URL.revokeObjectURL(a.href);
9864 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
9865 } catch (e) {
9866 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
9867 }
9868 }
9869
9870 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
9871 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
9872 var MEDIA_URL_SAFE = /^https?:\/\//i;
9873
9874 function teardownDetailEditBodyLayout() {
9875 if (detailEditBodyLayoutAbort) {
9876 detailEditBodyLayoutAbort.abort();
9877 detailEditBodyLayoutAbort = null;
9878 }
9879 }
9880
9881 function detailEditBodyMaxTextareaPx() {
9882 var wrap = el('detail-edit-body-wrap');
9883 var ta = el('detail-edit-body');
9884 if (!wrap || !ta) return 400;
9885 var toolbar = el('media-toolbar');
9886 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9887 var tb = toolbar ? toolbar.offsetHeight : 0;
9888 var gh = grip ? grip.offsetHeight : 0;
9889 var slack = 10;
9890 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
9891 var fallback = Math.round(window.innerHeight * 0.28);
9892 var wr = wrap.getBoundingClientRect();
9893 var next = wrap.nextElementSibling;
9894 var slice = 0;
9895 if (next && next.nodeType === 1) {
9896 var nr = next.getBoundingClientRect();
9897 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
9898 } else {
9899 var body = el('detail-body');
9900 if (body) {
9901 var br = body.getBoundingClientRect();
9902 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
9903 }
9904 }
9905 if (!Number.isFinite(slice) || slice < 120) {
9906 slice = fallback;
9907 }
9908 return Math.max(160, Math.min(hard, slice));
9909 }
9910
9911 function sizeDetailEditBodyToFill() {
9912 var ta = el('detail-edit-body');
9913 if (!ta) return;
9914 ta.style.removeProperty('height');
9915 }
9916
9917 function wireDetailEditBodyLayout() {
9918 teardownDetailEditBodyLayout();
9919 var ta = el('detail-edit-body');
9920 var wrap = el('detail-edit-body-wrap');
9921 if (!ta || !wrap) return;
9922 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9923 if (!grip) {
9924 grip = document.createElement('div');
9925 grip.className = 'detail-edit-body-resize-handle';
9926 grip.setAttribute('role', 'separator');
9927 grip.setAttribute('aria-orientation', 'horizontal');
9928 grip.setAttribute('aria-label', 'Resize editor height');
9929 var next = ta.nextSibling;
9930 if (next && next.id === 'media-toolbar') {
9931 wrap.insertBefore(grip, next);
9932 } else {
9933 wrap.appendChild(grip);
9934 }
9935 }
9936 if (grip.dataset.wired !== '1') {
9937 grip.dataset.wired = '1';
9938 function startDrag(clientY) {
9939 var startY = clientY;
9940 var startH = ta.offsetHeight;
9941 document.body.style.userSelect = 'none';
9942 function onMove(e2) {
9943 if (e2.touches && e2.cancelable) e2.preventDefault();
9944 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
9945 var dy = y - startY;
9946 var cap = detailEditBodyMaxTextareaPx();
9947 var nh = Math.max(160, Math.min(cap, startH + dy));
9948 ta.style.height = nh + 'px';
9949 }
9950 function onUp() {
9951 document.body.style.userSelect = '';
9952 document.removeEventListener('mousemove', onMove);
9953 document.removeEventListener('mouseup', onUp);
9954 document.removeEventListener('touchmove', onMove);
9955 document.removeEventListener('touchend', onUp);
9956 }
9957 document.addEventListener('mousemove', onMove);
9958 document.addEventListener('mouseup', onUp);
9959 document.addEventListener('touchmove', onMove, { passive: false });
9960 document.addEventListener('touchend', onUp);
9961 }
9962 grip.addEventListener('mousedown', function (e) {
9963 e.preventDefault();
9964 startDrag(e.clientY);
9965 });
9966 grip.addEventListener('touchstart', function (e) {
9967 if (!e.touches || !e.touches[0]) return;
9968 e.preventDefault();
9969 startDrag(e.touches[0].clientY);
9970 }, { passive: false });
9971 }
9972 window.requestAnimationFrame(function () {
9973 sizeDetailEditBodyToFill();
9974 });
9975 detailEditBodyLayoutAbort = new AbortController();
9976 window.addEventListener(
9977 'resize',
9978 function () {
9979 if (!el('detail-edit-body-wrap')) return;
9980 sizeDetailEditBodyToFill();
9981 },
9982 { signal: detailEditBodyLayoutAbort.signal }
9983 );
9984 }
9985
9986 function attachMediaToolbar() {
9987 var textarea = el('detail-edit-body');
9988 if (!textarea) return;
9989 var existing = document.getElementById('media-toolbar');
9990 if (existing) existing.remove();
9991
9992 var toolbar = document.createElement('div');
9993 toolbar.id = 'media-toolbar';
9994 toolbar.className = 'media-toolbar';
9995
9996 var insertBtn = document.createElement('button');
9997 insertBtn.type = 'button';
9998 insertBtn.textContent = 'Insert Media URL';
9999 insertBtn.className = 'btn-small';
10000 insertBtn.title = 'Paste a public image or video URL (.mp4 / .webm / .mov) to preview and insert it. Direct video file URLs render as inline players; YouTube/Vimeo links appear as clickable links.';
10001 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
10002 toolbar.appendChild(insertBtn);
10003
10004 var s = lastBackupSettingsPayload;
10005 if (s && s.github_connected && hubUserCanWriteNotes()) {
10006 var uploadBtn = document.createElement('button');
10007 uploadBtn.type = 'button';
10008 uploadBtn.textContent = 'Upload Image';
10009 uploadBtn.className = 'btn-small';
10010 uploadBtn.title = 'Upload an image (JPEG, PNG, GIF, WebP) and commit it to your connected GitHub repo. The image embeds inline in the note. Requires a public GitHub repo for the image to display.';
10011 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
10012 toolbar.appendChild(uploadBtn);
10013 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
10014 var connectHint = document.createElement('span');
10015 connectHint.className = 'media-toolbar-hint';
10016 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
10017 connectHint.textContent = 'Connect GitHub to upload images';
10018 toolbar.appendChild(connectHint);
10019 }
10020
10021 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
10022 }
10023
10024 function toggleMediaUrlDialog(toolbar, textarea) {
10025 var existing = document.getElementById('media-url-dialog');
10026 if (existing) { existing.remove(); return; }
10027
10028 var dialog = document.createElement('div');
10029 dialog.id = 'media-url-dialog';
10030 dialog.className = 'media-url-dialog';
10031
10032 var input = document.createElement('input');
10033 input.type = 'text';
10034 input.placeholder = 'Paste image or video URL (https://...)';
10035 input.className = 'media-url-input';
10036
10037 var preview = document.createElement('div');
10038 preview.className = 'media-preview';
10039
10040 var actions = document.createElement('div');
10041 actions.className = 'media-url-actions';
10042
10043 var doInsert = document.createElement('button');
10044 doInsert.type = 'button';
10045 doInsert.textContent = 'Insert';
10046 doInsert.className = 'btn-primary btn-small';
10047 doInsert.disabled = true;
10048
10049 var doCancel = document.createElement('button');
10050 doCancel.type = 'button';
10051 doCancel.textContent = 'Cancel';
10052 doCancel.className = 'btn-small';
10053 doCancel.onclick = function () { dialog.remove(); };
10054
10055 actions.appendChild(doInsert);
10056 actions.appendChild(doCancel);
10057
10058 var detectedType = null;
10059
10060 function onUrlChange() {
10061 var url = input.value.trim();
10062 preview.innerHTML = '';
10063 doInsert.disabled = true;
10064 detectedType = null;
10065 if (!url || !MEDIA_URL_SAFE.test(url)) return;
10066 if (MEDIA_IMAGE_EXTS.test(url)) {
10067 detectedType = 'image';
10068 var img = document.createElement('img');
10069 img.src = url;
10070 img.style.maxHeight = '200px';
10071 img.style.maxWidth = '100%';
10072 img.crossOrigin = 'anonymous';
10073 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
10074 preview.appendChild(img);
10075 doInsert.disabled = false;
10076 } else if (MEDIA_VIDEO_EXTS.test(url)) {
10077 detectedType = 'video';
10078 var vid = document.createElement('video');
10079 vid.controls = true;
10080 vid.preload = 'metadata';
10081 vid.style.maxHeight = '200px';
10082 vid.style.maxWidth = '100%';
10083 vid.src = url;
10084 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
10085 preview.appendChild(vid);
10086 doInsert.disabled = false;
10087 } else {
10088 preview.innerHTML = '<span class="muted small">Not a recognised image or video URL. Paste a URL ending in .jpg, .png, .gif, .webp, .mp4, .webm, or .mov.</span>';
10089 }
10090 }
10091
10092 input.addEventListener('input', onUrlChange);
10093 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
10094
10095 doInsert.onclick = function () {
10096 var url = input.value.trim();
10097 if (!url) return;
10098 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
10099 insertAtCursor(textarea, insertion);
10100 dialog.remove();
10101 };
10102
10103 dialog.appendChild(input);
10104 dialog.appendChild(preview);
10105 dialog.appendChild(actions);
10106 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
10107 input.focus();
10108 }
10109
10110 function insertAtCursor(textarea, text) {
10111 var start = textarea.selectionStart;
10112 var end = textarea.selectionEnd;
10113 var val = textarea.value;
10114 var before = val.substring(0, start);
10115 var needsNewline = before.length > 0 && !before.endsWith('\n');
10116 var insertion = (needsNewline ? '\n' : '') + text + '\n';
10117 textarea.value = before + insertion + val.substring(end);
10118 var newPos = start + insertion.length;
10119 textarea.setSelectionRange(newPos, newPos);
10120 textarea.focus();
10121 }
10122
10123 /**
10124 * Compress an image File/Blob using the Canvas API so it fits within the
10125 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
10126 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
10127 * Falls back to the original file if Canvas is unavailable or the image is
10128 * already small enough.
10129 */
10130 function compressImageIfNeeded(file) {
10131 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
10132 var MAX_DIM = 2048;
10133 return new Promise(function (resolve) {
10134 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
10135 return resolve(file);
10136 }
10137 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
10138 return resolve(file);
10139 }
10140 var img = new window.Image();
10141 var objectUrl = URL.createObjectURL(file);
10142 img.onload = function () {
10143 URL.revokeObjectURL(objectUrl);
10144 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
10145 var w = Math.round(img.width * ratio);
10146 var h = Math.round(img.height * ratio);
10147 var canvas = document.createElement('canvas');
10148 canvas.width = w;
10149 canvas.height = h;
10150 var ctx = canvas.getContext('2d');
10151 ctx.drawImage(img, 0, 0, w, h);
10152 var tryQuality = function (quality, attempt) {
10153 canvas.toBlob(function (blob) {
10154 if (!blob) return resolve(file); // Canvas failed — upload original
10155 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
10156 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
10157 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
10158 } else {
10159 tryQuality(quality - 0.2, attempt + 1);
10160 }
10161 }, 'image/jpeg', quality);
10162 };
10163 tryQuality(0.82, 0);
10164 };
10165 img.onerror = function () {
10166 URL.revokeObjectURL(objectUrl);
10167 resolve(file);
10168 };
10169 img.src = objectUrl;
10170 });
10171 }
10172
10173 function triggerImageUpload(textarea) {
10174 var fileInput = document.createElement('input');
10175 fileInput.type = 'file';
10176 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
10177 fileInput.onchange = async function () {
10178 var file = fileInput.files && fileInput.files[0];
10179 if (!file || !currentOpenNote) return;
10180 try {
10181 if (typeof showToast === 'function') showToast('Uploading image…');
10182 // Compress before uploading to stay within the Netlify Lambda 6 MB
10183 // payload limit (~4.5 MB binary after base64 overhead).
10184 var uploadFile = await compressImageIfNeeded(file);
10185 var form = new FormData();
10186 form.append('image', uploadFile);
10187 var notePath = encodeURIComponent(currentOpenNote.path);
10188 var vaultIdParam = '';
10189 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
10190 // Build auth headers from the shared helper (omit Content-Type so the
10191 // browser sets the correct multipart/form-data boundary automatically).
10192 var uploadHeaders = headers();
10193 delete uploadHeaders['Content-Type'];
10194 // Use apiBase so this request reaches the gateway when the frontend is served
10195 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
10196 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
10197 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
10198 method: 'POST',
10199 headers: uploadHeaders,
10200 body: form,
10201 });
10202 if (!res.ok) {
10203 var errData = await res.json().catch(function () { return {}; });
10204 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
10205 }
10206 var data = await res.json();
10207 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
10208 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
10209 } catch (e) {
10210 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
10211 }
10212 };
10213 fileInput.click();
10214 }
10215
10216 function switchNoteToEditMode() {
10217 if (!currentOpenNote) return;
10218 closeCreateModal();
10219 resetDetailSectionSourceState();
10220 const bcbEdit = el('btn-detail-copy-body');
10221 if (bcbEdit) bcbEdit.classList.add('hidden');
10222 const bodyEl = el('detail-body');
10223 const actionsEl = el('detail-actions');
10224 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
10225 bodyEl.className = 'detail-edit-container create-panel';
10226 bodyEl.innerHTML =
10227 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
10228 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
10229 '<label for="detail-edit-title">Title</label>' +
10230 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
10231 '<label for="detail-edit-body">Body (Markdown)</label>' +
10232 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
10233 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
10234 '</div>' +
10235 '<label for="detail-edit-date">Date</label>' +
10236 '<input type="date" id="detail-edit-date" />' +
10237 '<label for="detail-edit-project">Project (slug)</label>' +
10238 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
10239 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
10240 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
10241 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
10242 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
10243 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
10244 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
10245 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
10246 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
10247 '<label for="detail-edit-episode">Episode ID</label>' +
10248 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
10249 '<label for="detail-edit-follows">Follows (vault path)</label>' +
10250 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
10251 const pathDisp = el('detail-edit-path-display');
10252 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
10253 fillDetailEditFieldsFromFrontmatter(fm);
10254 attachMediaToolbar();
10255 wireDetailEditBodyLayout();
10256 actionsEl.innerHTML = '';
10257 const saveBtn = document.createElement('button');
10258 saveBtn.textContent = 'Save';
10259 saveBtn.className = 'btn-primary';
10260 saveBtn.onclick = async () => {
10261 closeCreateModal();
10262 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
10263 const frontmatter = mergedFrontmatterForDetailSave();
10264 await withButtonBusy(saveBtn, 'Saving…', async () => {
10265 try {
10266 await api('/api/v1/notes', {
10267 method: 'POST',
10268 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
10269 });
10270 hubMarkSemanticIndexStale();
10271 if (typeof showToast === 'function') showToast('Note saved');
10272 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
10273 const nfm = materializeFrontmatter(refreshed.frontmatter);
10274 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
10275 switchNoteToReadMode();
10276 if (typeof loadNotes === 'function') loadNotes();
10277 if (typeof loadFacets === 'function') loadFacets();
10278 } catch (e) {
10279 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
10280 }
10281 });
10282 };
10283 const cancelBtn = document.createElement('button');
10284 cancelBtn.textContent = 'Cancel';
10285 cancelBtn.onclick = () => switchNoteToReadMode();
10286 const delBtn = document.createElement('button');
10287 delBtn.type = 'button';
10288 delBtn.textContent = 'Delete';
10289 delBtn.onclick = () => deleteOpenNote();
10290 actionsEl.append(saveBtn, delBtn, cancelBtn);
10291 }
10292
10293 function openNote(path) {
10294 const seq = ++hubOpenNoteSeq;
10295 resetDetailSectionSourceState();
10296 teardownDetailEditBodyLayout();
10297 closeCreateModal();
10298 clearReviewSplitPosition();
10299 currentNotePathForCopy = path;
10300 currentOpenNote = null;
10301 const panel = el('detail-panel');
10302 panel.classList.remove('detail-panel-proposal-wide');
10303 // Reset any prior resize so notes open at the CSS half-page default.
10304 panel.style.width = '';
10305 const title = el('detail-title');
10306 const bodyEl = el('detail-body');
10307 const actionsEl = el('detail-actions');
10308 const btnCopy = el('btn-copy-path');
10309 const btnCopyBody = el('btn-detail-copy-body');
10310 if (btnCopyBody) btnCopyBody.classList.add('hidden');
10311 title.textContent = path;
10312 bodyEl.textContent = 'Loading…';
10313 bodyEl.className = '';
10314 actionsEl.innerHTML = '';
10315 btnCopy.classList.remove('hidden');
10316 panel.classList.remove('hidden');
10317 api('/api/v1/notes/' + encodeURIComponent(path))
10318 .then((note) => {
10319 if (seq !== hubOpenNoteSeq) return;
10320 const fm = materializeFrontmatter(note.frontmatter);
10321 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
10322 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
10323 bodyEl.className = 'note-rendered-body';
10324 actionsEl.innerHTML = '';
10325 attachNoteDetailReadActions(actionsEl);
10326 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
10327 })
10328 .catch((e) => {
10329 if (seq !== hubOpenNoteSeq) return;
10330 bodyEl.textContent = 'Error: ' + e.message;
10331 bodyEl.className = '';
10332 if (btnCopyBody) btnCopyBody.classList.add('hidden');
10333 });
10334 }
10335
10336 el('btn-copy-path').onclick = () => {
10337 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
10338 };
10339
10340 const btnDetailCopyBody = el('btn-detail-copy-body');
10341 if (btnDetailCopyBody) {
10342 btnDetailCopyBody.onclick = () => {
10343 if (!currentOpenNote) {
10344 if (typeof showToast === 'function') showToast('Open a note first.', true);
10345 return;
10346 }
10347 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
10348 if (navigator.clipboard && navigator.clipboard.writeText) {
10349 navigator.clipboard.writeText(text).then(
10350 () => {
10351 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
10352 },
10353 () => {
10354 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
10355 },
10356 );
10357 } else if (typeof showToast === 'function') {
10358 showToast('Clipboard not available in this browser.', true);
10359 }
10360 };
10361 }
10362
10363 const btnCopyUserId = el('btn-copy-user-id');
10364 if (btnCopyUserId) {
10365 btnCopyUserId.onclick = () => {
10366 const idEl = el('settings-user-id');
10367 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
10368 if (text && navigator.clipboard && navigator.clipboard.writeText) {
10369 navigator.clipboard.writeText(text).then(() => {
10370 if (typeof showToast === 'function') showToast('User ID copied.');
10371 }).catch(() => {});
10372 }
10373 };
10374 }
10375 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
10376 if (btnCopyAgentceptionEnv) {
10377 btnCopyAgentceptionEnv.onclick = () => {
10378 const envEl = el('integrations-agentception-env');
10379 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
10380 if (text && navigator.clipboard && navigator.clipboard.writeText) {
10381 navigator.clipboard.writeText(text).then(() => {
10382 if (typeof showToast === 'function') showToast('Env snippet copied.');
10383 }).catch(() => {});
10384 }
10385 };
10386 }
10387 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
10388 if (btnIntegrationsHowToAgentception) {
10389 btnIntegrationsHowToAgentception.onclick = () => {
10390 closeSettings();
10391 openHowToUse('setup');
10392 };
10393 }
10394 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
10395 if (btnHowToFlexibleNetwork) {
10396 btnHowToFlexibleNetwork.onclick = () => {
10397 closeSettings();
10398 openHowToUse('setup', 'how-to-flexible-network');
10399 };
10400 }
10401
10402 function renderProposalMarkdownHtml(md) {
10403 try {
10404 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
10405 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
10406 var withVideo = expandVideoUrls(raw);
10407 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
10408 return rewriteGitHubImageUrls(sanitised);
10409 }
10410 } catch (_) {
10411 /* fall through */
10412 }
10413 return escapeHtml(md || '');
10414 }
10415
10416 /** Canister stores checklist as JSON text; Node may return an array. */
10417 function parseProposalEvaluationChecklist(raw) {
10418 if (Array.isArray(raw)) return raw;
10419 if (raw == null || raw === '') return [];
10420 const s = String(raw).trim();
10421 if (!s) return [];
10422 try {
10423 const j = JSON.parse(s);
10424 return Array.isArray(j) ? j : [];
10425 } catch (_) {
10426 return [];
10427 }
10428 }
10429
10430 /**
10431 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
10432 */
10433 function buildProposalEvaluationRecordHtml(p, rubricItems) {
10434 const st = p.status;
10435 if (st !== 'approved' && st !== 'discarded') return '';
10436 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
10437 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
10438 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
10439 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
10440 const meaningfulStatus = es && es !== 'none';
10441 let waiverText = '';
10442 const w = p.evaluation_waiver;
10443 if (w != null && w !== '') {
10444 try {
10445 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
10446 if (o && typeof o === 'object') {
10447 const r1 = o.reason != null ? String(o.reason).trim() : '';
10448 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
10449 waiverText = r1 || r2;
10450 }
10451 } catch (_) {
10452 /* ignore */
10453 }
10454 }
10455 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
10456 const rubricById = new Map(
10457 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
10458 String(it.id || '').trim(),
10459 String(it.label || it.id || '').trim(),
10460 ]),
10461 );
10462 const rows = checklist
10463 .map((c) => {
10464 const rid = c && c.id != null ? String(c.id) : '';
10465 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
10466 const pass = c && c.passed === true;
10467 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
10468 })
10469 .join('');
10470 return (
10471 '<div class="proposal-eval proposal-eval-readonly">' +
10472 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
10473 '<p class="small">' +
10474 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
10475 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
10476 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
10477 (p.evaluated_at
10478 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
10479 : '') +
10480 '</p>' +
10481 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
10482 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
10483 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
10484 '</div>'
10485 );
10486 }
10487
10488 function openProposal(id) {
10489 resetDetailSectionSourceState();
10490 currentNotePathForCopy = '';
10491 currentOpenNote = null;
10492 el('btn-copy-path').classList.add('hidden');
10493 const bcbProp = el('btn-detail-copy-body');
10494 if (bcbProp) bcbProp.classList.add('hidden');
10495 const panel = el('detail-panel');
10496 panel.classList.add('detail-panel-proposal-wide');
10497 const title = el('detail-title');
10498 const body = el('detail-body');
10499 const actions = el('detail-actions');
10500 body.className = 'detail-body-proposal';
10501 panel.classList.remove('hidden');
10502 body.innerHTML = '<p class="muted">Loading…</p>';
10503 actions.innerHTML = '';
10504 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
10505 api('/api/v1/proposals/' + encodeURIComponent(id))
10506 .then((p) =>
10507 api('/api/v1/notes/' + pathEnc(p.path)).then(
10508 (note) => ({ p, note }),
10509 () => ({ p, note: null }),
10510 ),
10511 )
10512 .then(({ p, note }) => {
10513 title.textContent = p.path + ' (' + p.status + ')';
10514 const pFm = materializeFrontmatter(p.frontmatter);
10515 const currentBlock = note
10516 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
10517 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
10518 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
10519 const mdHtml = renderProposalMarkdownHtml(p.body || '');
10520 const chips = [];
10521 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
10522 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
10523 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
10524 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
10525 });
10526 if (p.external_ref) {
10527 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
10528 }
10529 const role = window.__hubUserRole || 'member';
10530 const isAdmin = role === 'admin';
10531 const isEvaluator = role === 'evaluator';
10532 const canEvaluate = isAdmin || isEvaluator;
10533 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
10534 const canDiscard = isAdmin;
10535 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
10536 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
10537 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
10538 function prevEvalPassed(rid) {
10539 const row = prevChecklist.find((c) => c && c.id === rid);
10540 return Boolean(row && row.passed === true);
10541 }
10542 let evalHtml = '';
10543 let waiverHtml = '';
10544 if (canEvaluate && p.status === 'proposed') {
10545 const es = p.evaluation_status || 'none';
10546 let evalIntro = '';
10547 if (es && es !== 'none' && es !== 'pending') {
10548 evalIntro =
10549 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
10550 escapeHtml(es) +
10551 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
10552 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
10553 (p.evaluation_comment
10554 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
10555 : '') +
10556 '</div>';
10557 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
10558 evalIntro =
10559 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
10560 }
10561 const checks = rubricItems.length
10562 ? rubricItems
10563 .map((it) => {
10564 const rid = String(it.id || '').trim();
10565 if (!rid) return '';
10566 const lab = String(it.label || rid);
10567 const ck = prevEvalPassed(rid) ? ' checked' : '';
10568 return (
10569 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
10570 escapeHtml(rid) +
10571 '"' +
10572 ck +
10573 ' /> ' +
10574 escapeHtml(lab) +
10575 '</label>'
10576 );
10577 })
10578 .join('')
10579 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
10580 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
10581 evalHtml =
10582 '<div class="proposal-eval">' +
10583 '<h4 class="proposal-md-heading">Evaluation</h4>' +
10584 evalIntro +
10585 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
10586 '<option value="pass">Pass</option>' +
10587 '<option value="fail">Fail</option>' +
10588 '<option value="needs_changes">Needs changes</option>' +
10589 '</select></label>' +
10590 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
10591 gradeVal +
10592 '" placeholder="e.g. A or 4" /></label>' +
10593 '<div class="proposal-eval-checklist">' +
10594 checks +
10595 '</div>' +
10596 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
10597 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
10598 '</textarea></label>' +
10599 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
10600 '</div>';
10601 }
10602 if (canApprove && p.status === 'proposed') {
10603 waiverHtml =
10604 '<div class="proposal-eval-waiver">' +
10605 '<label class="proposal-eval-field">Approve waiver reason <textarea id="proposal-waiver-reason" rows="2" placeholder="If approving without a passed evaluation, enter at least 3 characters."></textarea></label>' +
10606 '</div>';
10607 }
10608 let autoFlagHtml = '';
10609 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
10610 autoFlagHtml =
10611 '<p class="small muted">Auto-flagged: ' +
10612 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
10613 '</p>';
10614 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
10615 try {
10616 const ar = JSON.parse(String(p.auto_flag_reasons_json));
10617 if (Array.isArray(ar) && ar.length) {
10618 autoFlagHtml =
10619 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
10620 }
10621 } catch (_) {
10622 /* ignore */
10623 }
10624 }
10625 let hintsHtml = '';
10626 if (p.review_hints) {
10627 hintsHtml =
10628 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
10629 (p.review_hints_model
10630 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
10631 : '') +
10632 (p.review_hints_at
10633 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
10634 : '') +
10635 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">Use as a review checklist; copy into your comment if helpful — you still decide pass or fail.</p>' +
10636 '<pre class="proposal-pre">' +
10637 escapeHtml(String(p.review_hints)) +
10638 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
10639 }
10640 let assistantHtml = '';
10641 if (p.assistant_notes) {
10642 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
10643 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
10644 .join('');
10645 assistantHtml =
10646 '<div class="proposal-assistant"><strong>Assistant</strong>' +
10647 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
10648 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
10649 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">Quick summary and label ideas from the model; verify before trusting or reusing (e.g. paste into your comment or frontmatter after approve).</p>' +
10650 '<p>' +
10651 escapeHtml(String(p.assistant_notes)) +
10652 '</p>' +
10653 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
10654 '</div>';
10655 }
10656 let suggestedFmHtml = '';
10657 {
10658 let fm = p.assistant_suggested_frontmatter;
10659 if (typeof fm === 'string') {
10660 try {
10661 fm = JSON.parse(fm);
10662 } catch {
10663 fm = null;
10664 }
10665 }
10666 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
10667 const keys = Object.keys(fm).filter((k) => {
10668 const v = fm[k];
10669 return v !== undefined && v !== null && v !== '';
10670 });
10671 if (keys.length) {
10672 const rows = keys
10673 .map((k) => {
10674 const v = fm[k];
10675 let cell;
10676 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
10677 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
10678 else cell = String(v);
10679 return (
10680 '<tr><th scope="row">' +
10681 escapeHtml(k) +
10682 '</th><td>' +
10683 escapeHtml(cell) +
10684 '</td></tr>'
10685 );
10686 })
10687 .join('');
10688 suggestedFmHtml =
10689 '<div class="proposal-suggested-fm">' +
10690 '<strong>Suggested frontmatter</strong> ' +
10691 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
10692 '<p class="small muted" style="margin: 0.35rem 0 0.5rem;">From the assistant run; not applied on approve — verify before reusing in a note.</p>' +
10693 '<table class="proposal-suggested-fm-table"><tbody>' +
10694 rows +
10695 '</tbody></table></div>';
10696 }
10697 }
10698 }
10699 const openVaultNoteLine = note
10700 ? '<p class="small proposal-open-note-wrap"><button type="button" class="btn-link btn-link-small" id="proposal-open-note-btn">Open vault note to edit</button> <span class="muted">— tags, episode, entity, causal chain (frontmatter); use Activity again to return to this proposal.</span></p>'
10701 : '<p class="small muted">No note file at this path yet — approving creates or overwrites the file from the proposal body; then you can edit frontmatter.</p>';
10702 const primaryEvalBlock =
10703 evalHtml || waiverHtml
10704 ? '<div class="proposal-primary-eval">' + evalHtml + waiverHtml + '</div>'
10705 : '';
10706 body.innerHTML =
10707 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
10708 autoFlagHtml +
10709 '<p class="small muted">Intent: ' +
10710 escapeHtml(p.intent || '—') +
10711 ' · base_state_id: ' +
10712 escapeHtml(p.base_state_id || '—') +
10713 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
10714 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
10715 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
10716 '</p>' +
10717 openVaultNoteLine +
10718 primaryEvalBlock +
10719 '<div class="proposal-diff-grid">' +
10720 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
10721 escapeHtml(currentBlock) +
10722 '</pre></div>' +
10723 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
10724 escapeHtml(proposedBlock) +
10725 '</pre></div>' +
10726 '</div>' +
10727 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
10728 '<div class="proposal-md">' +
10729 mdHtml +
10730 '</div>' +
10731 evalRecordHtml +
10732 assistantHtml +
10733 suggestedFmHtml +
10734 hintsHtml;
10735 actions.innerHTML = '';
10736 {
10737 const idx = proposalListIds.indexOf(String(id));
10738 if (idx >= 0 && proposalListIds.length > 0) {
10739 proposalListSelectedIndex = idx;
10740 setReviewSplitPosition(idx + 1, proposalListIds.length);
10741 const c = getActiveProposalListContainer();
10742 if (c) updateProposalListSelection(c);
10743 } else {
10744 clearReviewSplitPosition();
10745 }
10746 }
10747 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
10748 if (openNoteBtn && note && p.path) {
10749 openNoteBtn.onclick = () => openNote(String(p.path));
10750 }
10751 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
10752 if (copyFmBtn) {
10753 let fmForCopy = p.assistant_suggested_frontmatter;
10754 if (typeof fmForCopy === 'string') {
10755 try {
10756 fmForCopy = JSON.parse(fmForCopy);
10757 } catch {
10758 fmForCopy = null;
10759 }
10760 }
10761 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
10762 copyFmBtn.onclick = async () => {
10763 try {
10764 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
10765 showToast('Copied suggested frontmatter JSON.');
10766 } catch (err) {
10767 showToast(err.message || 'Copy failed', true);
10768 }
10769 };
10770 }
10771 }
10772 const saveEvalBtn = body.querySelector('#proposal-eval-save');
10773 if (saveEvalBtn) {
10774 saveEvalBtn.onclick = async () => {
10775 const outcomeEl = body.querySelector('#proposal-eval-outcome');
10776 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
10777 const gradeEl = body.querySelector('#proposal-eval-grade');
10778 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
10779 const commentEl = body.querySelector('#proposal-eval-comment');
10780 const comment = commentEl ? String(commentEl.value || '').trim() : '';
10781 const checklist = [];
10782 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
10783 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
10784 });
10785 try {
10786 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
10787 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
10788 method: 'POST',
10789 body: JSON.stringify({
10790 outcome,
10791 grade: grade || undefined,
10792 comment: comment || undefined,
10793 checklist,
10794 }),
10795 });
10796 });
10797 showToast('Evaluation saved.');
10798 openProposal(id);
10799 loadProposals();
10800 } catch (err) {
10801 showToast(err.message || 'Evaluation failed', true);
10802 }
10803 };
10804 }
10805 if (p.status === 'proposed') {
10806 if (canApprove) {
10807 const approveBtn = document.createElement('button');
10808 approveBtn.textContent = 'Approve';
10809 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
10810 actions.append(approveBtn);
10811 }
10812 if (canDiscard) {
10813 const discardBtn = document.createElement('button');
10814 discardBtn.textContent = 'Discard';
10815 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
10816 actions.append(discardBtn);
10817 }
10818 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
10819 const enrichBtn = document.createElement('button');
10820 enrichBtn.type = 'button';
10821 enrichBtn.className = 'btn-secondary';
10822 enrichBtn.textContent = 'Enrich (AI)';
10823 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
10824 actions.append(enrichBtn);
10825 }
10826 if (isEvaluator && !canApprove) {
10827 const hintEv = document.createElement('p');
10828 hintEv.className = 'muted small';
10829 hintEv.textContent =
10830 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
10831 actions.append(hintEv);
10832 } else if (!canEvaluate) {
10833 const hint = document.createElement('p');
10834 hint.className = 'muted small';
10835 hint.textContent =
10836 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
10837 actions.append(hint);
10838 }
10839 }
10840 })
10841 .catch((e) => {
10842 body.className = 'detail-body-proposal';
10843 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
10844 });
10845 }
10846
10847 async function enrichProposal(id, panel, btn) {
10848 try {
10849 await withButtonBusy(btn, 'Enriching…', async () => {
10850 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
10851 });
10852 showToast('Proposal enriched.');
10853 openProposal(id);
10854 loadProposals();
10855 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
10856 // is visible instead of the browser staying at whatever scroll position it was at.
10857 const scrollHost = el('detail-body');
10858 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
10859 // Also highlight the matching row in the Review/Activity list so the user can see which
10860 // proposal was enriched.
10861 requestAnimationFrame(() => {
10862 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
10863 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
10864 });
10865 } catch (e) {
10866 showToast(e.message || 'Enrich failed', true);
10867 }
10868 }
10869
10870 async function approveProposal(id, panel, btn) {
10871 try {
10872 const db = el('detail-body');
10873 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
10874 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
10875 const approveBody = {};
10876 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
10877 let approveOut = null;
10878 await withButtonBusy(btn, 'Approving…', async () => {
10879 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
10880 method: 'POST',
10881 body: JSON.stringify(approveBody),
10882 });
10883 });
10884 if (approveOut && approveOut.approval_log_written === false) {
10885 showToast(
10886 approveOut.approval_log_error
10887 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
10888 : 'Approved, but approval log was not written. Check server logs and re-index.',
10889 true,
10890 );
10891 }
10892 hideDetailPanelChrome();
10893 hubMarkSemanticIndexStale();
10894 loadProposals();
10895 loadNotes();
10896 loadActivity();
10897 } catch (e) {
10898 const msg = e.message || String(e);
10899 showToast('Approve failed: ' + msg, true);
10900 }
10901 }
10902
10903 async function discardProposal(id, panel, btn) {
10904 try {
10905 await withButtonBusy(btn, 'Discarding…', async () => {
10906 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
10907 });
10908 hideDetailPanelChrome();
10909 loadProposals();
10910 loadActivity();
10911 } catch (e) {
10912 const msg = e.message || String(e);
10913 showToast('Discard failed: ' + msg, true);
10914 }
10915 }
10916
10917 el('detail-close').onclick = () => closeDetailPanel();
10918 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
10919 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
10920 (function wireDetailPanelFooterClose() {
10921 const panel = el('detail-panel');
10922 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
10923 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
10924 })();
10925
10926 (function initHubHeaderOffsetSync() {
10927 syncHubHeaderOffset();
10928 window.addEventListener('resize', () => syncHubHeaderOffset());
10929 if (typeof ResizeObserver !== 'undefined') {
10930 const header = document.querySelector('.hub-header');
10931 if (header) {
10932 const ro = new ResizeObserver(() => syncHubHeaderOffset());
10933 ro.observe(header);
10934 }
10935 }
10936 })();
10937
10938 // Resizable detail panel — drag the left edge to widen/narrow.
10939 (function initDetailPanelResize() {
10940 const panel = el('detail-panel');
10941 if (!panel) return;
10942 const handle = document.createElement('div');
10943 handle.className = 'detail-resize-handle';
10944 handle.title = 'Drag to resize panel';
10945 panel.prepend(handle);
10946 const MIN_W = 280;
10947 const MAX_W = Math.round(window.innerWidth * 0.92);
10948 let startX = 0, startW = 0, dragging = false;
10949 const onMove = (e) => {
10950 if (!dragging) return;
10951 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
10952 const delta = startX - clientX;
10953 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
10954 panel.style.width = newW + 'px';
10955 };
10956 const onUp = () => {
10957 if (!dragging) return;
10958 dragging = false;
10959 handle.classList.remove('dragging');
10960 document.removeEventListener('mousemove', onMove);
10961 document.removeEventListener('mouseup', onUp);
10962 document.removeEventListener('touchmove', onMove);
10963 document.removeEventListener('touchend', onUp);
10964 document.body.style.userSelect = '';
10965 };
10966 handle.addEventListener('mousedown', (e) => {
10967 e.preventDefault();
10968 dragging = true;
10969 startX = e.clientX;
10970 startW = panel.offsetWidth;
10971 handle.classList.add('dragging');
10972 document.body.style.userSelect = 'none';
10973 document.addEventListener('mousemove', onMove);
10974 document.addEventListener('mouseup', onUp);
10975 });
10976 handle.addEventListener('touchstart', (e) => {
10977 dragging = true;
10978 startX = e.touches[0].clientX;
10979 startW = panel.offsetWidth;
10980 handle.classList.add('dragging');
10981 document.addEventListener('touchmove', onMove, { passive: true });
10982 document.addEventListener('touchend', onUp);
10983 });
10984 })();
10985
10986 document.addEventListener('keydown', (e) => {
10987 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
10988 if (e.key === 'Escape') {
10989 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
10990 closeDetailPanel();
10991 e.preventDefault();
10992 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
10993 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
10994 closeHowToUse();
10995 e.preventDefault();
10996 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
10997 closeIntegGuideModal();
10998 e.preventDefault();
10999 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
11000 closeSettings();
11001 e.preventDefault();
11002 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
11003 closeProjectsHelpModal();
11004 e.preventDefault();
11005 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
11006 closeOnboardingWizardResume();
11007 e.preventDefault();
11008 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
11009 closeImportModal();
11010 e.preventDefault();
11011 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
11012 closeFullCreateSimilarModal();
11013 e.preventDefault();
11014 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
11015 closeCreateProposalModal();
11016 e.preventDefault();
11017 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
11018 closeCreateModal();
11019 e.preventDefault();
11020 } else if (el('search-key-help')?.open) {
11021 el('search-key-help').open = false;
11022 e.preventDefault();
11023 }
11024 return;
11025 }
11026 if (inInput && e.key !== 'Escape') return;
11027 const searchSec = el('hub-search-section') || document.querySelector('.search-section');
11028 const noteSearchVisible = searchSec && !searchSec.classList.contains('hidden');
11029 if (e.key === '/' && noteSearchVisible) {
11030 searchQuery.focus();
11031 e.preventDefault();
11032 return;
11033 }
11034 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
11035 // run semantic search instead of opening the selected row (avoids "second search does nothing").
11036 if (e.key === 'Enter' && noteSearchVisible) {
11037 const q = (searchQuery.value || '').trim();
11038 if (q) {
11039 e.preventDefault();
11040 void runVaultSearch();
11041 return;
11042 }
11043 }
11044 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
11045 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
11046 const items = notesList.querySelectorAll('.list-item');
11047 if (notesTabActive && listViewVisible && items.length > 0) {
11048 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
11049 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
11050 updateListSelection();
11051 e.preventDefault();
11052 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
11053 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
11054 updateListSelection();
11055 e.preventDefault();
11056 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
11057 const node = items[listSelectedIndex];
11058 if (node.dataset.path) openNote(node.dataset.path);
11059 else if (node.dataset.id) openProposal(node.dataset.id);
11060 e.preventDefault();
11061 }
11062 return;
11063 }
11064 const propContainer = getActiveProposalListContainer();
11065 if (propContainer) {
11066 const propItems = propContainer.querySelectorAll('.list-item[data-id]');
11067 if (propItems.length > 0) {
11068 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
11069 proposalListSelectedIndex = Math.min(proposalListSelectedIndex + 1, propItems.length - 1);
11070 updateProposalListSelection(propContainer);
11071 e.preventDefault();
11072 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
11073 proposalListSelectedIndex = Math.max(proposalListSelectedIndex - 1, 0);
11074 updateProposalListSelection(propContainer);
11075 e.preventDefault();
11076 } else if (e.key === 'Enter' && propItems[proposalListSelectedIndex]) {
11077 const node = propItems[proposalListSelectedIndex];
11078 setReviewSplitPosition(proposalListSelectedIndex + 1, propItems.length);
11079 openProposal(node.dataset.id);
11080 e.preventDefault();
11081 }
11082 }
11083 }
11084 });
11085
11086 document.addEventListener('click', (e) => {
11087 const keyHelp = el('search-key-help');
11088 if (!keyHelp || !keyHelp.open) return;
11089 if (keyHelp.contains(e.target)) return;
11090 keyHelp.open = false;
11091 });
11092
11093 document.querySelectorAll('[data-tab].tab').forEach((tab) => {
11094 tab.onclick = () => {
11095 switchHubMainTab(tab.dataset.tab);
11096 };
11097 });
11098 const hubRailHistory = el('hub-rail-history');
11099 if (hubRailHistory) {
11100 hubRailHistory.addEventListener('click', () => openHistoryMode());
11101 }
11102 const hubBottomHistory = el('hub-bottom-history');
11103 if (hubBottomHistory) {
11104 hubBottomHistory.addEventListener('click', () => {
11105 closeHubMoreSheet();
11106 openHistoryMode();
11107 });
11108 }
11109 const hubBottomMore = el('hub-bottom-more');
11110 if (hubBottomMore) {
11111 hubBottomMore.addEventListener('click', () => {
11112 const sheet = el('hub-more-sheet');
11113 const open = sheet && !sheet.classList.contains('hidden');
11114 setHubMoreSheetOpen(!open);
11115 });
11116 }
11117 document.querySelectorAll('[data-hub-more-close]').forEach((node) => {
11118 node.addEventListener('click', () => closeHubMoreSheet());
11119 });
11120 document.querySelectorAll('[data-hub-more-action]').forEach((btn) => {
11121 btn.addEventListener('click', () => {
11122 const action = btn.getAttribute('data-hub-more-action');
11123 closeHubMoreSheet();
11124 runHubSecondaryAction(action);
11125 });
11126 });
11127 document.addEventListener('keydown', (e) => {
11128 if (e.key !== 'Escape') return;
11129 const sheet = el('hub-more-sheet');
11130 if (sheet && !sheet.classList.contains('hidden')) {
11131 closeHubMoreSheet();
11132 e.preventDefault();
11133 }
11134 });
11135 const hubRailInsights = el('hub-rail-insights');
11136 if (hubRailInsights) {
11137 hubRailInsights.addEventListener('click', () => runHubSecondaryAction('insights'));
11138 }
11139 const hubRailImport = el('hub-rail-import');
11140 if (hubRailImport) {
11141 hubRailImport.addEventListener('click', () => runHubSecondaryAction('import'));
11142 }
11143 const hubRailConnect = el('hub-rail-connect');
11144 if (hubRailConnect) {
11145 hubRailConnect.addEventListener('click', () => runHubSecondaryAction('connect'));
11146 }
11147 const hubRailSettings = el('hub-rail-settings');
11148 if (hubRailSettings) {
11149 hubRailSettings.addEventListener('click', () => runHubSecondaryAction('settings'));
11150 }
11151 const hubRailHelp = el('hub-rail-help');
11152 if (hubRailHelp) {
11153 hubRailHelp.addEventListener('click', () => runHubSecondaryAction('help'));
11154 }
11155 const needsYouOpen = el('hub-needs-you-open');
11156 if (needsYouOpen) {
11157 needsYouOpen.addEventListener('click', () => switchHubMainTab('suggested'));
11158 }
11159 const needsYouDismiss = el('hub-needs-you-dismiss');
11160 if (needsYouDismiss) {
11161 needsYouDismiss.addEventListener('click', () => {
11162 hubNeedsYouDismissed = true;
11163 try {
11164 sessionStorage.setItem('hub_needs_you_dismissed', '1');
11165 } catch (_) {}
11166 updateNeedsYouBanner(hubReviewBadgePrevCount);
11167 });
11168 }
11169 if (btnHeaderSuggested) {
11170 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
11171 }
11172
11173 function escapeHtml(s) {
11174 const div = document.createElement('div');
11175 div.textContent = s == null ? '' : String(s);
11176 return div.innerHTML;
11177 }
11178
11179 // ── Consolidation UI (Stream 2) ───────────────────────────────
11180
11181 function consolModeFromSettings(s) {
11182 if (!s || !s.daemon) return 'off';
11183 if (s.daemon.enabled) return 'daemon';
11184 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
11185 return 'off';
11186 }
11187
11188 function populateConsolSettingsForm(s) {
11189 if (!s || !s.daemon) return;
11190 const d = s.daemon;
11191 const mode = consolModeFromSettings(s);
11192 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
11193 applyConsolModeVisibility(mode);
11194 const iv = el('consol-interval');
11195 if (iv) iv.value = d.interval_minutes ?? 120;
11196 const idle = el('consol-idle-only');
11197 if (idle) idle.checked = d.idle_only !== false;
11198 const idleTh = el('consol-idle-threshold');
11199 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
11200 const ros = el('consol-run-on-start');
11201 if (ros) ros.checked = Boolean(d.run_on_start);
11202 const pc = el('pass-consolidate');
11203 if (pc) pc.checked = d.passes?.consolidate !== false;
11204 const pv = el('pass-verify');
11205 if (pv) pv.checked = d.passes?.verify !== false;
11206 const pd = el('pass-discover');
11207 if (pd) pd.checked = Boolean(d.passes?.discover);
11208 const lp = el('consol-llm-provider');
11209 if (lp) lp.value = d.llm?.provider || '';
11210 const lm = el('consol-llm-model');
11211 if (lm) lm.value = d.llm?.model || '';
11212 const lb = el('consol-llm-base-url');
11213 if (lb) lb.value = d.llm?.base_url || '';
11214 const lbh = el('consol-lookback-hours');
11215 if (lbh) lbh.value = d.lookback_hours ?? 24;
11216 const me = el('consol-max-events');
11217 if (me) me.value = d.max_events_per_pass ?? 200;
11218 const mt = el('consol-max-topics');
11219 if (mt) mt.value = d.max_topics_per_pass ?? 10;
11220 const lmt = el('consol-llm-max-tokens');
11221 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
11222 const cc = el('consol-cost-cap');
11223 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
11224 const chi = el('consol-hosted-interval');
11225 if (chi && d.interval_minutes != null) {
11226 const v = String(d.interval_minutes);
11227 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
11228 chi.value = allowed.includes(v) ? v : '120';
11229 }
11230 }
11231
11232 function buildConsolSettingsPayload() {
11233 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
11234 const mode = modeRadio ? modeRadio.value : 'off';
11235 const hostedSel = el('consol-hosted-interval');
11236 const intervalRaw =
11237 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
11238 const llm = {
11239 provider: el('consol-llm-provider')?.value || '',
11240 model: el('consol-llm-model')?.value || '',
11241 base_url: el('consol-llm-base-url')?.value || '',
11242 };
11243 if (mode === 'daemon') {
11244 llm.max_tokens = Math.max(
11245 64,
11246 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
11247 );
11248 }
11249 const payload = {
11250 mode,
11251 enabled: mode === 'daemon',
11252 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
11253 idle_only: Boolean(el('consol-idle-only')?.checked),
11254 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
11255 run_on_start: Boolean(el('consol-run-on-start')?.checked),
11256 passes: {
11257 consolidate: Boolean(el('pass-consolidate')?.checked),
11258 verify: Boolean(el('pass-verify')?.checked),
11259 discover: Boolean(el('pass-discover')?.checked),
11260 },
11261 llm,
11262 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
11263 };
11264 if (mode === 'daemon') {
11265 payload.lookback_hours = Math.max(
11266 1,
11267 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
11268 );
11269 payload.max_events_per_pass = Math.max(
11270 1,
11271 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
11272 );
11273 payload.max_topics_per_pass = Math.max(
11274 1,
11275 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
11276 );
11277 }
11278 return payload;
11279 }
11280
11281 function applyConsolModeVisibility(mode) {
11282 const daemonSection = el('consol-daemon-settings');
11283 const hostedSection = el('consol-hosted-settings');
11284 const llmSection = el('consol-llm-settings');
11285 const costGuard = el('consol-cost-guard');
11286 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
11287 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
11288 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
11289 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
11290 }
11291
11292 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
11293 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
11294 });
11295
11296 let lastChatKeyAvailable = {};
11297
11298 function chatProviderKeyHintText(provider, keyAvail) {
11299 const ka = keyAvail || {};
11300 switch (provider) {
11301 case '':
11302 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
11303 case 'ollama':
11304 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
11305 case 'openrouter':
11306 return ka.openrouter
11307 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
11308 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
11309 case 'openai':
11310 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
11311 case 'anthropic':
11312 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
11313 case 'deepinfra':
11314 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
11315 default:
11316 return '';
11317 }
11318 }
11319
11320 function applyChatProviderSettings(s) {
11321 const chat = (s && s.chat) || {};
11322 const sel = el('chat-provider-select');
11323 const keyHint = el('chat-provider-key-hint');
11324 const envHint = el('chat-provider-env-hint');
11325 const adminHint = el('chat-provider-admin-hint');
11326 const saveBtn = el('btn-chat-provider-save');
11327 const msg = el('chat-provider-msg');
11328 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
11329 if (!sel) return;
11330 lastChatKeyAvailable = chat.key_available || {};
11331 const isAdmin = String(s && s.role) === 'admin';
11332 const envLocked = Boolean(chat.env_locked);
11333 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
11334 sel.disabled = envLocked || !isAdmin;
11335 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
11336 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
11337 if (envHint) {
11338 if (envLocked) {
11339 envHint.textContent =
11340 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
11341 envHint.classList.remove('hidden');
11342 } else {
11343 envHint.classList.add('hidden');
11344 }
11345 }
11346 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
11347
11348 if (!sel.dataset.knowtationBound) {
11349 sel.dataset.knowtationBound = '1';
11350 sel.addEventListener('change', () => {
11351 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
11352 });
11353 }
11354 const btn = el('btn-chat-provider-save');
11355 if (btn && !btn.dataset.knowtationBound) {
11356 btn.dataset.knowtationBound = '1';
11357 btn.addEventListener('click', async () => {
11358 const m = el('chat-provider-msg');
11359 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
11360 try {
11361 const res = await api('/api/v1/settings/chat', {
11362 method: 'POST',
11363 body: JSON.stringify({ provider: sel.value }),
11364 });
11365 if (res && res.chat) sel.value = res.chat.provider || '';
11366 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
11367 } catch (e) {
11368 if (m) {
11369 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
11370 m.className = 'settings-msg err';
11371 }
11372 }
11373 });
11374 }
11375 }
11376
11377 async function loadConsolidationSettings() {
11378 const msg = el('consol-save-status');
11379 if (msg) msg.textContent = '';
11380 try {
11381 const s = await api('/api/v1/settings');
11382 populateConsolSettingsForm(s);
11383 } catch (e) {
11384 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
11385 }
11386 }
11387
11388 const btnConsolSave = el('btn-consol-save');
11389 if (btnConsolSave) {
11390 btnConsolSave.addEventListener('click', async () => {
11391 const msg = el('consol-save-status');
11392 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
11393 const payload = buildConsolSettingsPayload();
11394 if (payload.enabled && payload.interval_minutes < 30) {
11395 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
11396 return;
11397 }
11398 setButtonBusy(btnConsolSave, true, 'Saving…');
11399 try {
11400 await api('/api/v1/settings/consolidation', {
11401 method: 'POST',
11402 body: JSON.stringify(payload),
11403 });
11404 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
11405 } catch (e) {
11406 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
11407 }
11408 setButtonBusy(btnConsolSave, false);
11409 });
11410 }
11411
11412 const linkConsolHelp = el('link-consol-help');
11413 if (linkConsolHelp) {
11414 linkConsolHelp.addEventListener('click', (e) => {
11415 e.preventDefault();
11416 closeSettings();
11417 openHowToUse('consolidation');
11418 });
11419 }
11420
11421 // ── Consolidation Dashboard Card ──────────────────────────────
11422
11423 function formatCostMeter(costUsd, capUsd) {
11424 const cost = Math.max(0, Number(costUsd) || 0);
11425 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
11426 const display = '$' + cost.toFixed(3) + ' today';
11427 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
11428 const pct = Math.min(100, (cost / cap) * 100);
11429 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
11430 }
11431
11432 function renderConsolidationHistory(events, container) {
11433 if (!container) return;
11434 if (!events || events.length === 0) {
11435 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
11436 return;
11437 }
11438 let html = '<table class="consol-history-table"><thead><tr><th>Date</th><th>Topics</th><th>Events Merged</th><th>Status</th></tr></thead><tbody>';
11439 events.forEach((ev) => {
11440 const ts = ev.ts || ev.timestamp || ev.created_at;
11441 const date = ts ? new Date(ts).toLocaleString() : '—';
11442 const rawTopics = ev.data?.topics_count;
11443 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
11444 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
11445 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
11446 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
11447 });
11448 html += '</tbody></table>';
11449 container.innerHTML = html;
11450 }
11451
11452 async function refreshConsolidationCard() {
11453 const card = el('consolidation-card');
11454 const badge = el('consol-status-badge');
11455 const lastPass = el('consol-last-pass');
11456 const nextPass = el('consol-next-pass');
11457 const quotaMeter = el('consol-quota-meter');
11458 const quotaLabel = el('consol-quota-label');
11459 const quotaFill = el('consol-quota-fill');
11460 const btnNow = el('btn-consol-now');
11461 if (!card) return;
11462
11463 try {
11464 const s = await api('/api/v1/settings');
11465 const mode = consolModeFromSettings(s);
11466 if (mode === 'off') {
11467 card.style.display = 'none';
11468 return;
11469 }
11470 card.style.display = '';
11471
11472 if (mode === 'hosted') {
11473 try {
11474 const st = await api('/api/v1/memory/consolidate/status');
11475 if (badge) {
11476 badge.textContent = '● Active (hosted)';
11477 badge.className = 'consol-badge consol-badge-success';
11478 }
11479 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
11480 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
11481
11482 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
11483 const passUsed = st.pass_count_month ?? 0;
11484 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
11485 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
11486 if (quotaMeter) {
11487 if (passLimit === null) {
11488 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
11489 if (quotaFill) quotaFill.style.width = '0%';
11490 } else if (passLimit > 0) {
11491 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
11492 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
11493 if (quotaFill) quotaFill.style.width = pct + '%';
11494 }
11495 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
11496 }
11497
11498 // Disable "Consolidate Now" during cooldown; show time remaining.
11499 const cooldown = st.cooldown_minutes ?? 0;
11500 if (btnNow && cooldown > 0) {
11501 btnNow.disabled = true;
11502 btnNow.textContent = 'Available in ' + cooldown + ' min';
11503 } else if (btnNow) {
11504 btnNow.disabled = false;
11505 btnNow.textContent = 'Consolidate Now';
11506 }
11507 } catch (_) {
11508 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
11509 }
11510 } else {
11511 if (badge) {
11512 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
11513 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
11514 }
11515 if (lastPass) lastPass.textContent = 'Last pass: —';
11516 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
11517 if (quotaMeter) quotaMeter.style.display = 'none';
11518 }
11519 } catch (_) {
11520 card.style.display = 'none';
11521 }
11522 }
11523
11524 const btnConsolNow = el('btn-consol-now');
11525 if (btnConsolNow) {
11526 btnConsolNow.addEventListener('click', async () => {
11527 setButtonBusy(btnConsolNow, true, 'Previewing…');
11528 try {
11529 const preview = await api('/api/v1/memory/consolidate', {
11530 method: 'POST',
11531 body: JSON.stringify({ dry_run: true }),
11532 });
11533 setButtonBusy(btnConsolNow, false);
11534 const topicsRaw = preview.topics;
11535 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
11536 const events = preview.total_events ?? 0;
11537 // Fetch current quota to show remaining passes in the preview dialog.
11538 let quotaLine = '';
11539 try {
11540 const st = await api('/api/v1/memory/consolidate/status');
11541 const passUsed = st.pass_count_month ?? 0;
11542 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
11543 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
11544 if (passLimit === null) {
11545 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
11546 } else if (passLimit > 0) {
11547 const remaining = Math.max(0, passLimit - passUsed);
11548 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
11549 }
11550 } catch (_) {}
11551 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
11552 if (!ok) return;
11553 setButtonBusy(btnConsolNow, true, 'Consolidating…');
11554 await api('/api/v1/memory/consolidate', {
11555 method: 'POST',
11556 body: JSON.stringify({ dry_run: false }),
11557 });
11558 if (typeof showToast === 'function') showToast('Consolidation complete.');
11559 refreshConsolidationCard();
11560 } catch (e) {
11561 const msg = e?.message || 'Consolidation failed';
11562 if (typeof showToast === 'function') showToast(msg, true);
11563 // Re-check cooldown after a rate-limit response so the button state updates.
11564 refreshConsolidationCard();
11565 }
11566 setButtonBusy(btnConsolNow, false);
11567 });
11568 }
11569
11570 const btnConsolHistory = el('btn-consol-history');
11571 if (btnConsolHistory) {
11572 btnConsolHistory.addEventListener('click', async () => {
11573 setButtonBusy(btnConsolHistory, true, 'Loading…');
11574 try {
11575 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
11576 const events = res.events || res.history || [];
11577 setButtonBusy(btnConsolHistory, false);
11578 const modal = document.createElement('div');
11579 modal.className = 'modal';
11580 modal.setAttribute('aria-modal', 'true');
11581 modal.innerHTML =
11582 '<div class="modal-backdrop"></div>' +
11583 '<div class="modal-card consol-history-modal">' +
11584 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
11585 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
11586 document.body.appendChild(modal);
11587 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
11588 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
11589 modal.querySelector('.modal-close').onclick = () => modal.remove();
11590 } catch (e) {
11591 setButtonBusy(btnConsolHistory, false);
11592 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
11593 }
11594 });
11595 }
11596
11597 function openSettingsConsolidationTab() {
11598 openSettings();
11599 document.querySelectorAll('.settings-tab').forEach((t) => {
11600 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
11601 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
11602 });
11603 document.querySelectorAll('.settings-panel').forEach((p) => {
11604 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
11605 });
11606 loadConsolidationSettings();
11607 }
11608
11609 const btnConsolSettings = el('btn-consol-settings');
11610 if (btnConsolSettings) {
11611 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
11612 }
11613
11614 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
11615 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
11616 // Billing consolidation row is populated inline in loadBillingPanel's try block.
11617 // We add to the existing billing flow by hooking the billing API response.
11618
11619 // Refresh consolidation card when dashboard renders
11620 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
11621 })();
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 1 day ago