hub.js javascript
11,120 lines 447.3 KB
Raw
sha256:49f768e5fb72e8d17321410817422fc5cab8f0a1b66a1e1da8bdd4cd251c4f7e Merge 'feat/ourware-landing-rebrand' into 'main' — proposal… Human 18 days ago
1 /**
2 * Knowtation Hub UI — list, calendar, overview, quick add, presets. Phase 11C.
3 */
4
5 (function () {
6 const params = new URLSearchParams(location.search);
7 // Build-time or deployment config: set window.HUB_API_BASE_URL (e.g. from config.js). Empty string = same origin (when static host proxies /api to the gateway).
8 const apiBase = (function resolveApiBase() {
9 if (typeof window === 'undefined') return 'http://localhost:3333';
10 const paramApi = params.get('api');
11 if (paramApi != null && String(paramApi).trim()) {
12 return String(paramApi).trim().replace(/\/$/, '');
13 }
14 const hostname = location.hostname || '';
15 const isLocalDev =
16 hostname === 'localhost' ||
17 hostname === '127.0.0.1' ||
18 hostname === '[::1]' ||
19 hostname === '::1';
20 // Self-hosted dev: always call the same origin as the page (npm run hub). Stale localStorage
21 // hub_api_url often points at a hosted gateway and causes HTML 404 for Node-only routes.
22 if (isLocalDev) {
23 return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
24 }
25 if (Object.prototype.hasOwnProperty.call(window, 'HUB_API_BASE_URL')) {
26 const v = window.HUB_API_BASE_URL;
27 if (v == null) {
28 return (
29 localStorage.getItem('hub_api_url') ||
30 location.origin ||
31 'http://localhost:3333'
32 ).replace(/\/$/, '');
33 }
34 const s = String(v).trim();
35 if (s === '') return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
36 return s.replace(/\/$/, '');
37 }
38 return (localStorage.getItem('hub_api_url') || location.origin || 'http://localhost:3333').replace(/\/$/, '');
39 })();
40 /** Public MCP endpoint (https://…/mcp) when operator sets window.HUB_MCP_PUBLIC_URL in web/hub/config.js; else ''. */
41 const mcpPublicUrl = (function resolveMcpPublicUrl() {
42 if (typeof window === 'undefined') return '';
43 if (!Object.prototype.hasOwnProperty.call(window, 'HUB_MCP_PUBLIC_URL')) return '';
44 const v = window.HUB_MCP_PUBLIC_URL;
45 if (v == null) return '';
46 const s = String(v).trim();
47 if (s === '') return '';
48 return s.replace(/\/$/, '');
49 })();
50 /** Canonical doc: where Hub token, REST, remote MCP, and local CLI differ (copy blocks point here). */
51 const INTEGRATION_DOC_URL = 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md';
52 const hashParams = new URLSearchParams(location.hash.replace(/^#/, ''));
53 /** Used to defer onboarding until invite consume has run (see scheduleMaybeShowOnboardingWizard). */
54 const pageLoadHadInviteQuery = Boolean(params.get('invite'));
55 let token = hashParams.get('token') || params.get('token') || localStorage.getItem('hub_token');
56 if (token) {
57 localStorage.setItem('hub_token', token);
58 if (hashParams.has('token')) {
59 history.replaceState({}, '', location.pathname + location.search);
60 } else if (params.has('token')) {
61 const u = new URL(location.href);
62 u.searchParams.delete('token');
63 history.replaceState({}, '', u.toString());
64 }
65 }
66
67 /** Latest GET /api/v1/settings used for Backup tab (hosted repo field + sync body). */
68 let lastBackupSettingsPayload = null;
69
70 const PRESETS_KEY = 'hub_view_presets';
71 const el = (id) => document.getElementById(id);
72 const app = el('app');
73 const main = el('main');
74 const loginRequired = el('login-required');
75 const btnLoginGoogle = el('btn-login-google');
76 const btnLoginGithub = el('btn-login-github');
77 const btnLogout = el('btn-logout');
78 const btnNewNote = el('btn-new-note');
79 const btnImport = el('btn-import');
80 const btnHeaderSuggested = el('btn-header-suggested');
81 const btnHowToUse = el('btn-how-to-use');
82 const btnSettings = el('btn-settings');
83 const browseToolbar = el('browse-toolbar');
84 /** @type {number} last unfiltered proposed count for badge pulse */
85 let hubReviewBadgePrevCount = 0;
86 let hubNeedsYouDismissed = false;
87 /** Keyboard selection index for Review / History proposal lists */
88 let proposalListSelectedIndex = 0;
89 /** @type {string[]} proposal ids in the active Review/History list for N-of-M */
90 let proposalListIds = [];
91 try {
92 hubNeedsYouDismissed = sessionStorage.getItem('hub_needs_you_dismissed') === '1';
93 } catch (_) {
94 hubNeedsYouDismissed = false;
95 }
96
97 function hubShellIa() {
98 return globalThis.HubShellIa || null;
99 }
100
101 function getActiveHubMainTab() {
102 const t = document.querySelector('[data-tab].tab.active');
103 return (t && t.dataset.tab) || 'notes';
104 }
105
106 function getActiveNotesView() {
107 const graph = el('notes-view-graph');
108 if (graph && !graph.classList.contains('hidden')) return 'graph';
109 const cal = el('notes-view-calendar');
110 if (cal && !cal.classList.contains('hidden')) return 'calendar';
111 return 'list';
112 }
113
114 function syncVaultAdvancedFiltersOpen() {
115 const details = el('hub-search-advanced');
116 if (!details) return;
117 const SI = hubShellIa();
118 const active = typeof hasActiveNoteListFilters === 'function' ? hasActiveNoteListFilters() : false;
119 const expand =
120 SI && typeof SI.shouldExpandVaultAdvancedFilters === 'function'
121 ? SI.shouldExpandVaultAdvancedFilters(active, details.open)
122 : active || details.open;
123 if (expand) details.open = true;
124 }
125
126 function syncPendingEvalQuickChip() {
127 const chip = el('proposal-pending-eval-chip');
128 if (!chip) return;
129 const SI = hubShellIa();
130 const show =
131 SI && typeof SI.shouldShowPendingEvalQuickChip === 'function'
132 ? SI.shouldShowPendingEvalQuickChip(window.__hubProposalEvaluationRequired)
133 : Boolean(window.__hubProposalEvaluationRequired);
134 const onSuggested = getActiveHubMainTab() === 'suggested';
135 chip.classList.toggle('hidden', !(show && onSuggested));
136 const pe = el('proposal-filter-pending-eval');
137 const pressed = Boolean(pe && pe.checked);
138 chip.setAttribute('aria-pressed', pressed ? 'true' : 'false');
139 }
140
141 function syncModeToolbars(activeTab) {
142 const name = activeTab || getActiveHubMainTab();
143 const view = getActiveNotesView();
144 const SI = hubShellIa();
145 const chrome =
146 SI && typeof SI.hubChromeVisibility === 'function'
147 ? SI.hubChromeVisibility(name, view)
148 : {
149 noteSearch: name === 'notes' && view !== 'graph',
150 browseToolbar: name === 'notes' && view !== 'graph',
151 proposalFilters: name === 'suggested' || name === 'activity' || name === 'problem',
152 insights: name === 'notes' && view === 'graph',
153 };
154 const searchSec = el('hub-search-section') || document.querySelector('.search-section');
155 if (searchSec) searchSec.classList.toggle('hidden', !chrome.noteSearch);
156 if (browseToolbar) browseToolbar.classList.toggle('hidden', !chrome.browseToolbar);
157 setProposalFiltersBarVisible(chrome.proposalFilters);
158 if (chrome.noteSearch) syncVaultAdvancedFiltersOpen();
159 syncPendingEvalQuickChip();
160 }
161
162 function setReviewSplitPosition(index1Based, total) {
163 const posEl = el('detail-split-position');
164 const listPos = el('review-list-position');
165 const SI = hubShellIa();
166 const text =
167 SI && typeof SI.formatReviewSplitPosition === 'function'
168 ? SI.formatReviewSplitPosition(index1Based, total)
169 : index1Based > 0 && total > 0
170 ? index1Based + ' of ' + total
171 : '';
172 [posEl, listPos].forEach((node) => {
173 if (!node) return;
174 if (!text) {
175 node.textContent = '';
176 node.classList.add('hidden');
177 } else {
178 node.textContent = text;
179 node.classList.remove('hidden');
180 }
181 });
182 }
183
184 function clearReviewSplitPosition() {
185 setReviewSplitPosition(0, 0);
186 }
187
188 function updateProposalListSelection(container) {
189 if (!container) return;
190 const items = container.querySelectorAll('.list-item[data-id]');
191 if (items.length === 0) {
192 proposalListSelectedIndex = 0;
193 return;
194 }
195 const SI = hubShellIa();
196 proposalListSelectedIndex =
197 SI && typeof SI.clampListKeyboardIndex === 'function'
198 ? SI.clampListKeyboardIndex(proposalListSelectedIndex, items.length)
199 : Math.max(0, Math.min(proposalListSelectedIndex, items.length - 1));
200 items.forEach((item, i) => {
201 item.classList.toggle('selected', i === proposalListSelectedIndex);
202 if (i === proposalListSelectedIndex) item.setAttribute('tabindex', '0');
203 else item.removeAttribute('tabindex');
204 });
205 const sel = items[proposalListSelectedIndex];
206 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
207 }
208
209 function getActiveProposalListContainer() {
210 const tab = getActiveHubMainTab();
211 if (tab === 'suggested') return el('proposals-suggested');
212 if (tab === 'problem') return el('proposals-problem');
213 if (tab === 'activity') return el('proposals-activity');
214 return null;
215 }
216
217 function syncHubRailChrome(activeTab) {
218 const name = activeTab || getActiveHubMainTab();
219 const historyMode = name === 'activity' || name === 'problem';
220 const histBtn = el('hub-rail-history');
221 if (histBtn) histBtn.classList.toggle('active', historyMode);
222 const bottomHist = el('hub-bottom-history');
223 if (bottomHist) bottomHist.classList.toggle('active', historyMode);
224 const segments = el('history-segments');
225 if (segments) segments.classList.toggle('hidden', !historyMode);
226 document.querySelectorAll('.history-segment').forEach((btn) => {
227 btn.classList.toggle('active', btn.dataset.tab === name);
228 btn.setAttribute('aria-selected', btn.dataset.tab === name ? 'true' : 'false');
229 });
230 const insights = el('hub-rail-insights');
231 if (insights) {
232 const graphOn =
233 name === 'notes' && !el('notes-view-graph')?.classList.contains('hidden');
234 insights.classList.toggle('active', Boolean(graphOn));
235 }
236 const SI = hubShellIa();
237 if (historyMode && SI && typeof SI.writeHistorySegment === 'function') {
238 SI.writeHistorySegment(name === 'problem' ? 'problem' : 'activity', localStorage);
239 }
240 }
241
242 function setHubMoreSheetOpen(open) {
243 const sheet = el('hub-more-sheet');
244 const moreBtn = el('hub-bottom-more');
245 if (!sheet) return;
246 const show = Boolean(open);
247 sheet.classList.toggle('hidden', !show);
248 if (moreBtn) {
249 moreBtn.classList.toggle('active', show);
250 moreBtn.setAttribute('aria-expanded', show ? 'true' : 'false');
251 }
252 }
253
254 function closeHubMoreSheet() {
255 setHubMoreSheetOpen(false);
256 }
257
258 function openHubMoreSheet() {
259 setHubMoreSheetOpen(true);
260 }
261
262 function runHubSecondaryAction(action) {
263 const key = String(action || '');
264 if (key === 'insights') {
265 switchHubMainTab('notes');
266 switchNotesView('graph');
267 return;
268 }
269 if (key === 'import') {
270 if (typeof openImportModal === 'function') openImportModal();
271 else if (btnImport) btnImport.click();
272 return;
273 }
274 if (key === 'connect') {
275 openSettingsIntegrationsTab();
276 return;
277 }
278 if (key === 'settings') {
279 openSettings();
280 return;
281 }
282 if (key === 'help') {
283 if (typeof openHowToUse === 'function') openHowToUse();
284 else if (btnHowToUse) btnHowToUse.click();
285 }
286 }
287
288 function applyReviewBadgeCount(rawCount) {
289 const SI = hubShellIa();
290 const next = SI && typeof SI.clampProposedBadgeCount === 'function'
291 ? SI.clampProposedBadgeCount(rawCount)
292 : Math.max(0, Math.min(100, Math.floor(Number(rawCount) || 0)));
293 const text =
294 SI && typeof SI.formatProposedBadgeText === 'function'
295 ? SI.formatProposedBadgeText(next)
296 : next > 0
297 ? String(next)
298 : '';
299 const pulse =
300 SI && typeof SI.shouldPulseReviewBadge === 'function'
301 ? SI.shouldPulseReviewBadge(hubReviewBadgePrevCount, next)
302 : next > hubReviewBadgePrevCount;
303 ['hub-review-badge', 'hub-header-review-badge', 'hub-bottom-review-badge'].forEach((id) => {
304 const badge = el(id);
305 if (!badge) return;
306 if (!text) {
307 badge.textContent = '';
308 badge.classList.add('hidden');
309 badge.classList.remove('hub-rail-badge-pulse');
310 return;
311 }
312 badge.textContent = text;
313 badge.classList.remove('hidden');
314 if (pulse) {
315 badge.classList.remove('hub-rail-badge-pulse');
316 void badge.offsetWidth;
317 badge.classList.add('hub-rail-badge-pulse');
318 }
319 });
320 hubReviewBadgePrevCount = next;
321 updateNeedsYouBanner(next);
322 }
323
324 function updateNeedsYouBanner(proposedCount) {
325 const banner = el('hub-needs-you-banner');
326 const textEl = el('hub-needs-you-text');
327 if (!banner) return;
328 const SI = hubShellIa();
329 const show =
330 SI && typeof SI.shouldShowNeedsYouBanner === 'function'
331 ? SI.shouldShowNeedsYouBanner(proposedCount, hubNeedsYouDismissed)
332 : proposedCount > 0 && !hubNeedsYouDismissed;
333 const onVault = getActiveHubMainTab() === 'notes';
334 banner.classList.toggle('hidden', !(show && onVault));
335 if (textEl && SI && typeof SI.needsYouBannerCopy === 'function') {
336 textEl.textContent = SI.needsYouBannerCopy(proposedCount);
337 } else if (textEl) {
338 textEl.textContent =
339 proposedCount +
340 (proposedCount === 1 ? ' proposal' : ' proposals') +
341 ' waiting in Review';
342 }
343 }
344
345 async function refreshReviewBadge() {
346 if (!token) {
347 applyReviewBadgeCount(0);
348 return;
349 }
350 try {
351 const out = await api('/api/v1/proposals?status=proposed&limit=100');
352 applyReviewBadgeCount((out && out.proposals ? out.proposals.length : 0) || 0);
353 } catch (_) {
354 /* keep last badge; fail closed without wiping */
355 }
356 }
357
358 function openHistoryMode(preferredSegment) {
359 const SI = hubShellIa();
360 const seg =
361 preferredSegment ||
362 (SI && typeof SI.readHistorySegment === 'function'
363 ? SI.readHistorySegment(localStorage)
364 : 'activity');
365 switchHubMainTab(seg === 'problem' ? 'problem' : 'activity');
366 }
367 const userName = el('user-name');
368 const oauthNotConfigured = el('oauth-not-configured');
369 const loginIntro = el('login-intro');
370 const searchQuery = el('search-query');
371 const filterProject = el('filter-project');
372 const filterTag = el('filter-tag');
373 const filterFolder = el('filter-folder');
374 const filterSince = el('filter-since');
375 const filterUntil = el('filter-until');
376 const filterContentScope = el('filter-content-scope');
377 const filterNetwork = el('filter-network');
378 const filterWallet = el('filter-wallet');
379 const searchMode = el('search-mode');
380 const btnSearch = el('btn-search');
381 const btnClearSearch = el('btn-clear-search');
382 const btnApplyFilters = el('btn-apply-filters');
383 const btnReindex = el('btn-reindex');
384 const notesList = el('notes-list');
385 const notesTotal = el('notes-total');
386 /** True when the last unfiltered browse list (loadNotes, no list filters) returned zero notes. */
387 let hubBrowseListEmptyUnfiltered = false;
388 /** Last facets from {@link fetchFacetsResolved} (Hub create panel project pickers + similarity guard). */
389 let lastHubFacets = null;
390 /** Latest `/api/v1/vault/folders` list for subfolder derivation under `projects/<slug>/`. */
391 let lastVaultFoldersForCreate = [];
392 /** After “Keep my path” on similar-project modal, allow one create without re-prompting. */
393 let fullCreateSimilarOverrideOnce = false;
394 let fullCreateSimilarModalSuggestedSlug = '';
395 let fullCreateSimilarModalPendingPath = '';
396 let fullPathSimilarDebounceTimer = 0;
397 const filterChipsEl = el('filter-chips');
398 const presetsListEl = el('presets-list');
399 const presetNameInput = el('preset-name');
400 const hubBetaNote = el('hub-beta-note');
401 if (hubBetaNote && window.location.hostname !== 'knowtation.store' && window.location.hostname !== 'www.knowtation.store') hubBetaNote.classList.add('hidden');
402
403 let providers = null;
404 let calendarMonth = new Date();
405 let currentNotePathForCopy = '';
406 /** @type {{ path: string, body: string, frontmatter: Record<string, string> } | null} */
407 let currentOpenNote = null;
408 /** Increments when the SectionSource panel is reset so stale body-free reads do not render. */
409 let hubSectionSourceSeq = 0;
410 /** When set, full-create save may delete this path after posting the duplicate (optional checkbox). */
411 /** @type {{ path: string } | null} */
412 let pendingDuplicateDeleteSource = null;
413 /** AbortController for window resize while note edit body layout is active. */
414 let detailEditBodyLayoutAbort = null;
415
416 /** Hide the detail drawer (does not clear currentOpenNote). */
417 function hideDetailPanelChrome() {
418 const dp = el('detail-panel');
419 if (dp) {
420 dp.classList.add('hidden');
421 dp.classList.remove('detail-panel-proposal-wide');
422 }
423 clearReviewSplitPosition();
424 }
425
426 /** User dismisses the drawer (Escape, Close): clear open-note state. */
427 function closeDetailPanel() {
428 currentOpenNote = null;
429 currentNotePathForCopy = '';
430 resetDetailSectionSourceState();
431 teardownDetailEditBodyLayout();
432 hideDetailPanelChrome();
433 const bcbClose = el('btn-detail-copy-body');
434 if (bcbClose) bcbClose.classList.add('hidden');
435 const bcp = el('btn-copy-path');
436 if (bcp) bcp.classList.add('hidden');
437 }
438
439 let listSelectedIndex = 0;
440 /** Increments on each `openNote` call so stale fetch completions do not append duplicate actions or overwrite UI. */
441 let hubOpenNoteSeq = 0;
442 /** @type {import('chart.js').Chart[]} */
443 let chartInstances = [];
444
445 const FILTER_CHIPS_EXPANDED_KEY = 'hub_filter_chips_expanded';
446 let filterChipsExpanded = false;
447 try {
448 filterChipsExpanded = localStorage.getItem(FILTER_CHIPS_EXPANDED_KEY) === '1';
449 } catch (_) {
450 filterChipsExpanded = false;
451 }
452
453 const ACCENT_STORAGE_KEY = 'hub_accent_color';
454 const THEME_STORAGE_KEY = 'hub_theme';
455 const COLOR_PALETTE_STORAGE_KEY = 'hub_color_palette';
456 const DEFAULT_ACCENT = '#89cff0';
457 const DEFAULT_THEME = 'dark';
458 const DEFAULT_COLOR_PALETTE = 'default';
459 const VALID_COLOR_PALETTES = new Set([
460 'default',
461 'ocean',
462 'forest',
463 'sunset',
464 'lavender',
465 'ember',
466 'arctic',
467 'slate',
468 'midnight',
469 'sakura',
470 'sand',
471 'mint',
472 ]);
473 const loadingHtml = '<div class="loading-state" aria-live="polite">Loading…</div>';
474 function applyAccent(hex) {
475 if (hex) {
476 document.documentElement.style.setProperty('--accent', hex);
477 try {
478 localStorage.setItem(ACCENT_STORAGE_KEY, hex);
479 } catch (_) {}
480 }
481 }
482 function applyTheme(theme) {
483 const value = theme === 'light' ? 'light' : 'dark';
484 document.documentElement.setAttribute('data-theme', value === 'dark' ? '' : value);
485 try {
486 localStorage.setItem(THEME_STORAGE_KEY, value);
487 } catch (_) {}
488 }
489 function applyColorPalette(id) {
490 const p =
491 id && VALID_COLOR_PALETTES.has(String(id)) ? String(id) : DEFAULT_COLOR_PALETTE;
492 if (p === DEFAULT_COLOR_PALETTE) {
493 document.documentElement.removeAttribute('data-palette');
494 } else {
495 document.documentElement.setAttribute('data-palette', p);
496 }
497 try {
498 localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, p);
499 } catch (_) {}
500 }
501 function currentColorPalette() {
502 const a = document.documentElement.getAttribute('data-palette');
503 if (a && VALID_COLOR_PALETTES.has(a) && a !== DEFAULT_COLOR_PALETTE) return a;
504 return DEFAULT_COLOR_PALETTE;
505 }
506 (function initThemeAndAccent() {
507 try {
508 const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
509 if (savedTheme === 'light') applyTheme('light');
510 const savedAccent = localStorage.getItem(ACCENT_STORAGE_KEY);
511 if (savedAccent) applyAccent(savedAccent);
512 const savedPalette = localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
513 if (savedPalette) applyColorPalette(savedPalette);
514 } catch (_) {}
515 })();
516
517 function headers() {
518 const h = { 'Content-Type': 'application/json' };
519 if (token) h['Authorization'] = 'Bearer ' + token;
520 const vid = getCurrentVaultId();
521 if (vid) h['X-Vault-Id'] = vid;
522 return h;
523 }
524
525 // Persistent sessions: when the short-lived access token expires, silently exchange the
526 // HttpOnly refresh cookie for a new one instead of dropping the user to the login screen.
527 // Single-flight so a burst of 401s triggers exactly one refresh.
528 let refreshInFlight = null;
529 async function refreshAccessToken() {
530 if (refreshInFlight) return refreshInFlight;
531 refreshInFlight = (async () => {
532 try {
533 const res = await fetch(apiBase + '/api/v1/auth/refresh', {
534 method: 'POST',
535 credentials: 'include', // send the HttpOnly refresh cookie
536 cache: 'no-store',
537 headers: { 'Content-Type': 'application/json' },
538 });
539 if (!res.ok) return false;
540 const data = await res.json().catch(() => null);
541 if (data && typeof data.access_token === 'string' && data.access_token) {
542 token = data.access_token;
543 try { localStorage.setItem('hub_token', token); } catch (_) {}
544 return true;
545 }
546 return false;
547 } catch (_) {
548 return false;
549 }
550 })();
551 try {
552 return await refreshInFlight;
553 } finally {
554 refreshInFlight = null;
555 }
556 }
557
558 async function api(path, opts = {}) {
559 const method = (opts.method || 'GET').toUpperCase();
560 // GET/HEAD: retry up to 2×. POST/PATCH/DELETE: retry once only on pure network failures
561 // (before any HTTP response), which means the server never received the request so retrying
562 // is safe. Never retry on HTTP error responses (4xx/5xx) — those were received and processed.
563 //
564 // `opts.noRetry: true` opts out of retries entirely. Used by `POST /api/v1/index`: a 30s
565 // gateway timeout (Netlify Function cap) drops the client connection, which the browser
566 // surfaces as `Failed to fetch`. With retry on, the bridge then receives a SECOND index
567 // request while the first is still running, double-billing DeepInfra and worsening contention.
568 const maxNetworkRetries = opts.noRetry === true
569 ? 0
570 : (method === 'GET' || method === 'HEAD') ? 2 : 1;
571 // Strip non-fetch keys before forwarding to fetch() so they don't pollute the request init.
572 const { noRetry: _noRetry, ...fetchOpts } = opts;
573 // Internal one-shot control flag for the 401 silent-refresh retry; never forward to fetch().
574 delete fetchOpts._retriedAfterRefresh;
575 let res;
576 let networkRetries = maxNetworkRetries;
577 for (;;) {
578 try {
579 res = await fetch(apiBase + path, {
580 ...fetchOpts,
581 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
582 headers: { ...headers(), ...fetchOpts.headers },
583 });
584 break;
585 } catch (e) {
586 const m = e && e.message ? String(e.message) : String(e);
587 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
588 networkRetries--;
589 await new Promise(resolve => setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000));
590 continue;
591 }
592 if (m === 'Failed to fetch' || m.includes('NetworkError')) {
593 throw new Error(
594 'Could not reach the API (' +
595 apiBase +
596 '). Check gateway status, CORS (HUB_CORS_ORIGIN), ad blockers, and Netlify limits.',
597 );
598 }
599 throw e instanceof Error ? e : new Error(m);
600 }
601 }
602 if (res.status === 401) {
603 // Try a one-time silent refresh before forcing re-login. Never recurse on the auth
604 // endpoints themselves, and only retry once per original request.
605 if (
606 path !== '/api/v1/auth/refresh' &&
607 path !== '/api/v1/auth/logout' &&
608 !opts._retriedAfterRefresh
609 ) {
610 const refreshed = await refreshAccessToken();
611 if (refreshed) {
612 return api(path, { ...opts, _retriedAfterRefresh: true });
613 }
614 }
615 token = null;
616 localStorage.removeItem('hub_token');
617 if (app) app.classList.add('login-screen');
618 main.classList.add('hidden');
619 loginRequired.classList.remove('hidden');
620 browseToolbar.classList.add('hidden');
621 btnNewNote.classList.add('hidden');
622 if (btnImport) btnImport.classList.add('hidden');
623 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
624 if (btnHowToUse) btnHowToUse.classList.add('hidden');
625 if (btnSettings) btnSettings.classList.add('hidden');
626 showLoginChrome();
627 throw new Error('Unauthorized');
628 }
629 let text = await res.text();
630 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
631 let data;
632 try {
633 data = text ? JSON.parse(text) : null;
634 } catch (_) {
635 const t = text.trim();
636 if (/^<!DOCTYPE/i.test(t) || /<html/i.test(t)) {
637 throw new Error(
638 `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.`,
639 );
640 }
641 throw new Error(
642 'Response was not valid JSON (' +
643 res.status +
644 '). Start of body: ' +
645 t.slice(0, 120) +
646 (t.length > 120 ? '...' : ''),
647 );
648 }
649 if (!res.ok) {
650 const label = data?.error || res.statusText;
651 const detail = data?.message != null && String(data.message).trim() ? String(data.message).trim() : '';
652 const combined = detail ? `${label}: ${detail}` : label;
653 const err = new Error(combined);
654 if (data && data.code) err.code = data.code;
655 throw err;
656 }
657 return data;
658 }
659
660 /** Busy state for buttons during slow API calls (clear feedback on hosted). */
661 function setButtonBusy(btn, busy, labelWhenBusy) {
662 if (!btn || btn.nodeType !== 1) return;
663 const busyText = labelWhenBusy || 'Working…';
664 if (busy) {
665 if (btn.dataset.knowtationBtnRestLabel == null) {
666 btn.dataset.knowtationBtnRestLabel = btn.textContent;
667 }
668 btn.textContent = busyText;
669 btn.disabled = true;
670 btn.classList.add('btn-busy');
671 btn.setAttribute('aria-busy', 'true');
672 } else {
673 if (btn.dataset.knowtationBtnRestLabel != null) {
674 btn.textContent = btn.dataset.knowtationBtnRestLabel;
675 delete btn.dataset.knowtationBtnRestLabel;
676 }
677 btn.classList.remove('btn-busy');
678 btn.removeAttribute('aria-busy');
679 btn.disabled = false;
680 }
681 }
682
683 async function withButtonBusy(btn, labelWhenBusy, fn) {
684 if (!btn) return fn();
685 setButtonBusy(btn, true, labelWhenBusy);
686 try {
687 return await fn();
688 } finally {
689 setButtonBusy(btn, false);
690 }
691 }
692
693 const HOSTED_BACKUP_REPO_LS = 'knowtation_hosted_backup_repo';
694 /** If set, `resolveApiBase` uses this instead of `location.origin` — can point local Hub UI at Netlify by mistake. */
695 const HUB_API_URL_LS = 'hub_api_url';
696
697 const VAULT_ID_LS = 'hub_vault_id';
698 /** @see `web/hub/hub-client-import-zip.mjs` — 4B sequential import cap. */
699 const HUB_IMPORT_MAX_SEQUENTIAL = 200;
700 const importFileEl = el('import-file');
701 const importFileFolderEl = el('import-file-folder');
702 const importFolderHintEl = el('import-folder-hint');
703 const importBatchCancelBtn = el('import-batch-cancel');
704 const importBatchAriaEl = el('import-batch-aria');
705 /** Dropped files/folder (4C) — when set, submit uses this instead of the file inputs. */
706 /** @type {File[] | null} */
707 let importPendingDropFiles = null;
708 const importDropZoneEl = el('import-drop-zone');
709 const importDropStatusEl = el('import-drop-status');
710 /** @type {AbortController | null} */
711 let importBatchAbort = null;
712 const btnImportChooseFolder = el('btn-import-choose-folder');
713
714 function wrapFileWithWebkitRel(file, relPath) {
715 const w = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
716 const rel = String(relPath || file.name).replace(/^\//, '');
717 try {
718 Object.defineProperty(w, 'webkitRelativePath', { value: rel, enumerable: true, configurable: true });
719 } catch (_) {}
720 return w;
721 }
722
723 /**
724 * @param {FileSystemFileEntry} fe
725 * @param {string} pathPrefix
726 * @returns {Promise<File>}
727 */
728 function fileEntryToFileWithPath(fe, pathPrefix) {
729 return new Promise((resolve, reject) => {
730 fe.file(
731 (file) => {
732 const rel = (String(pathPrefix || '') + file.name).replace(/^\//, '');
733 resolve(wrapFileWithWebkitRel(file, rel));
734 },
735 reject,
736 );
737 });
738 }
739
740 /**
741 * @param {FileSystemDirectoryEntry} dirEntry
742 * @param {string} pathPrefix
743 * @returns {Promise<File[]>}
744 */
745 async function readAllFilesInDirectoryEntry(dirEntry, pathPrefix) {
746 const all = [];
747 const reader = dirEntry.createReader();
748 let batch;
749 do {
750 /** @type {FileSystemEntry[]} */
751 batch = await new Promise((res, rej) => reader.readEntries(res, rej));
752 for (const e of batch) {
753 if (e.isFile) {
754 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(e), pathPrefix));
755 } else if (e.isDirectory) {
756 all.push(
757 ...(await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(e), pathPrefix + e.name + '/')),
758 );
759 }
760 }
761 } while (batch.length > 0);
762 return all;
763 }
764
765 /**
766 * @param {DataTransfer} dataTransfer
767 * @returns {Promise<File[]>}
768 */
769 async function collectFilesFromDataTransfer(dataTransfer) {
770 if (!dataTransfer) return [];
771 const canEntry =
772 dataTransfer.items &&
773 dataTransfer.items.length > 0 &&
774 Array.from(dataTransfer.items).some((it) => it.kind === 'file' && 'webkitGetAsEntry' in it);
775 if (canEntry) {
776 const all = [];
777 for (const item of Array.from(dataTransfer.items)) {
778 if (item.kind !== 'file') continue;
779 if (item.webkitGetAsEntry) {
780 const entry = item.webkitGetAsEntry();
781 if (entry) {
782 if (entry.isFile) {
783 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(entry), ''));
784 } else if (entry.isDirectory) {
785 all.push(
786 ...(
787 await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(entry), entry.name + '/')
788 ),
789 );
790 }
791 } else {
792 const f = item.getAsFile();
793 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
794 }
795 } else {
796 const f = item.getAsFile();
797 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
798 }
799 }
800 return all;
801 }
802 if (dataTransfer.files && dataTransfer.files.length) {
803 return Array.from(dataTransfer.files).map((f) => wrapFileWithWebkitRel(f, f.name));
804 }
805 return [];
806 }
807
808 function updateImportDropStatusUi() {
809 if (!importDropStatusEl) return;
810 if (importPendingDropFiles && importPendingDropFiles.length > 0) {
811 importDropStatusEl.hidden = false;
812 importDropStatusEl.textContent =
813 importPendingDropFiles.length +
814 ' file(s) from drop. Click Import, or use the file picker above to replace.';
815 } else {
816 importDropStatusEl.hidden = true;
817 importDropStatusEl.textContent = '';
818 }
819 }
820
821 function clearImportDropPending() {
822 importPendingDropFiles = null;
823 if (importDropZoneEl) importDropZoneEl.classList.remove('import-drop-zone--over');
824 updateImportDropStatusUi();
825 }
826
827 function setImportBatchAria(s) {
828 if (importBatchAriaEl) importBatchAriaEl.textContent = s || '';
829 }
830
831 function normalizeUrlOrigin(base) {
832 try {
833 const s = String(base || '').trim().replace(/\/$/, '');
834 if (!s) return '';
835 const u = new URL(s.startsWith('http') ? s : 'https://' + s);
836 return u.origin;
837 } catch (_) {
838 return '';
839 }
840 }
841
842 function isLocalHubHostname() {
843 const h = location.hostname;
844 return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
845 }
846
847 /** Local Hub tab but `apiBase` targets another origin (e.g. Netlify) — causes “Could not reach the API … knowtation-gateway…”. */
848 function localApiBaseFootgunActive() {
849 if (!isLocalHubHostname()) return false;
850 const pageO = normalizeUrlOrigin(location.origin);
851 const apiO = normalizeUrlOrigin(apiBase);
852 if (!pageO || !apiO) return false;
853 return pageO !== apiO;
854 }
855
856 function refreshApiBaseFootgunBanner() {
857 const b = el('hub-api-base-footgun-banner');
858 if (!b) return;
859 if (!localApiBaseFootgunActive()) {
860 b.classList.add('hidden');
861 b.innerHTML = '';
862 return;
863 }
864 let lsHint = false;
865 try {
866 lsHint = Boolean(localStorage.getItem(HUB_API_URL_LS));
867 } catch (_) {}
868 const qsHint = Boolean(params.get('api'));
869 b.classList.remove('hidden');
870 const hint =
871 (lsHint ? ' <code>localStorage.' + HUB_API_URL_LS + '</code> is set.' : '') +
872 (qsHint ? ' This URL has an <code>?api=</code> override.' : '');
873 b.innerHTML =
874 '<p><strong>Wrong API for this tab.</strong> This page is on <code>' +
875 escapeHtml(location.origin) +
876 '</code> but the Hub calls <code>' +
877 escapeHtml(apiBase) +
878 '</code> for requests (settings, backup, notes).' +
879 hint +
880 ' For self-hosted <code>npm run hub</code>, clear the override so the API matches this origin, then reload.</p>' +
881 '<p><button type="button" class="btn-secondary" id="hub-api-footgun-clear">Clear API override &amp; reload</button></p>';
882 const clearBtn = el('hub-api-footgun-clear');
883 if (clearBtn) {
884 clearBtn.onclick = () => {
885 try {
886 localStorage.removeItem(HUB_API_URL_LS);
887 } catch (_) {}
888 const u = new URL(location.href);
889 u.searchParams.delete('api');
890 window.location.href = u.toString();
891 };
892 }
893 }
894
895 function getCurrentVaultId() {
896 try {
897 return localStorage.getItem(VAULT_ID_LS) || 'default';
898 } catch (_) {
899 return 'default';
900 }
901 }
902
903 function setCurrentVaultId(id) {
904 try {
905 localStorage.setItem(VAULT_ID_LS, id);
906 } catch (_) {}
907 }
908
909 /** Per-vault hint: Meaning (semantic) search may lag vault edits until Re-index runs successfully. */
910 const HUB_SEMANTIC_INDEX_STALE_PREFIX = 'hub_semantic_index_stale_v1:';
911
912 function hubSemanticIndexStaleLsKey(vaultId) {
913 const v = vaultId != null && String(vaultId).trim() !== '' ? String(vaultId).trim() : 'default';
914 return HUB_SEMANTIC_INDEX_STALE_PREFIX + v;
915 }
916
917 function hubRefreshIndexStaleBanner() {
918 const banner = el('hub-index-stale-banner');
919 if (!banner) return;
920 let flagged = false;
921 try {
922 flagged = Boolean(localStorage.getItem(hubSemanticIndexStaleLsKey(getCurrentVaultId())));
923 } catch (_) {
924 flagged = false;
925 }
926 if (!flagged) {
927 banner.classList.add('hidden');
928 return;
929 }
930 banner.classList.remove('hidden');
931 }
932
933 function hubMarkSemanticIndexStaleForVault(vaultId) {
934 try {
935 localStorage.setItem(hubSemanticIndexStaleLsKey(vaultId), String(Date.now()));
936 } catch (_) {}
937 hubRefreshIndexStaleBanner();
938 }
939
940 function hubMarkSemanticIndexStale() {
941 hubMarkSemanticIndexStaleForVault(getCurrentVaultId());
942 }
943
944 function hubClearSemanticIndexStaleForVault(vaultId) {
945 try {
946 localStorage.removeItem(hubSemanticIndexStaleLsKey(vaultId));
947 } catch (_) {}
948 hubRefreshIndexStaleBanner();
949 }
950
951 function hubClearSemanticIndexStale() {
952 hubClearSemanticIndexStaleForVault(getCurrentVaultId());
953 }
954
955 function updateVaultSwitcher(vaultList, allowedVaultIds) {
956 const wrap = el('vault-switcher-wrap');
957 const select = el('vault-switcher');
958 if (!wrap || !select) return;
959 const rows = Array.isArray(vaultList) ? vaultList : [];
960 const byId = new Map(rows.map((v) => [String(v.id), v]));
961 let allowed =
962 Array.isArray(allowedVaultIds) && allowedVaultIds.length
963 ? allowedVaultIds.map(String)
964 : rows.length
965 ? rows.map((v) => String(v.id))
966 : ['default'];
967 allowed = [...new Set(allowed)];
968 const options = allowed.map((id) => {
969 const v = byId.get(id);
970 return { id, label: v && (v.label || v.id) ? String(v.label || v.id) : id };
971 });
972 select.innerHTML = options
973 .map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label) + '</option>')
974 .join('');
975 select.value = getCurrentVaultId();
976 if (!allowed.includes(select.value)) select.value = allowed[0] || 'default';
977 setCurrentVaultId(select.value);
978 // Always surface the current vault once settings load (even with a single
979 // vault) so the control is discoverable under the left-rail Vault area.
980 wrap.classList.toggle('hidden', options.length < 1);
981 if (allowed.length >= 2 && options.length === 1) {
982 select.title =
983 '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.';
984 } else if (options.length === 1) {
985 select.title = 'Current vault. Add more under Settings → Vaults when your role allows.';
986 } else {
987 select.title = 'Switch the active vault for notes, search, and proposals.';
988 }
989 select.onchange = () => {
990 setCurrentVaultId(select.value);
991 loadFacets();
992 loadNotes();
993 loadProposals();
994 hubRefreshIndexStaleBanner();
995 };
996 }
997
998 function applyHostedUiFromSettings(s) {
999 if (!s || typeof s !== 'object') return;
1000 const hosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1001 window.__hubIsHosted = hosted;
1002 const btn = el('btn-projects-help');
1003 if (btn) btn.classList.toggle('hidden', !hosted);
1004 }
1005
1006 function normalizeGithubRepoSlug(raw) {
1007 let t = (raw || '').trim();
1008 if (!t) return '';
1009 t = t.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
1010 const parts = t.split('/').filter(Boolean);
1011 if (parts.length >= 2) return parts[0] + '/' + parts[1];
1012 return t;
1013 }
1014
1015 /** Hosted (canister): any logged-in user may sync to their own GitHub; self-hosted still requires admin. */
1016 function settingsSyncDisabled(s, vg, isHosted) {
1017 const isAdmin = s.role === 'admin';
1018 const hostedGitBackup = isHosted && s.github_connect_available;
1019 if (hostedGitBackup) {
1020 const inputEl = el('settings-hosted-repo');
1021 const inputRepo = normalizeGithubRepoSlug(inputEl && inputEl.value);
1022 const slug = inputRepo || normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) || normalizeGithubRepoSlug(s.repo);
1023 return !s.github_connected || !slug;
1024 }
1025 return !vg.enabled || !vg.has_remote || !isAdmin;
1026 }
1027
1028 /** After Connect GitHub, blob read-after-write can lag; retry settings until github_connected or timeout. */
1029 async function fetchSettingsForBackupModal() {
1030 const pendingRaw = sessionStorage.getItem('knowtation_github_connect_pending');
1031 const pendingTs = pendingRaw ? parseInt(pendingRaw, 10) : NaN;
1032 const pendingFresh = Number.isFinite(pendingTs) && Date.now() - pendingTs < 120000;
1033 if (!pendingFresh) {
1034 if (pendingRaw) sessionStorage.removeItem('knowtation_github_connect_pending');
1035 return api('/api/v1/settings');
1036 }
1037 let s;
1038 for (let attempt = 0; attempt < 8; attempt++) {
1039 s = await api('/api/v1/settings');
1040 if (s.github_connected || !s.github_connect_available) break;
1041 if (attempt < 7) await new Promise((r) => setTimeout(r, 600));
1042 }
1043 sessionStorage.removeItem('knowtation_github_connect_pending');
1044 return s;
1045 }
1046
1047 /** Align with hub/server effectiveRole: viewer read-only; member maps to editor for writes. */
1048 function hubUserCanWriteNotes() {
1049 const r = window.__hubUserRole;
1050 return r === 'editor' || r === 'admin' || r === 'member';
1051 }
1052
1053 /** Same roles as POST /api/v1/proposals on Hub (evaluators propose; viewers do not). */
1054 function hubUserMayProposeFromNote() {
1055 const r = window.__hubUserRole;
1056 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1057 }
1058
1059 /** Download current note (POST /api/v1/export); allowed for any vault reader including viewer. */
1060 function hubUserCanExportNote() {
1061 const r = window.__hubUserRole || 'member';
1062 return (
1063 r === 'editor' || r === 'admin' || r === 'member' || r === 'viewer' || r === 'evaluator'
1064 );
1065 }
1066
1067 /** Proposal Enrich (AI): evaluators may run it without note-write roles; editors/admins/members still qualify. */
1068 function hubUserMayEnrichProposal() {
1069 const r = window.__hubUserRole;
1070 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1071 }
1072
1073 /** Multi-vault copy/move in note detail (Settings must list ≥2 allowed vaults). */
1074 function hubHasMultipleVaultsForCopy() {
1075 const s = lastBackupSettingsPayload;
1076 if (!s || !Array.isArray(s.allowed_vault_ids)) return false;
1077 return s.allowed_vault_ids.filter(Boolean).length >= 2;
1078 }
1079
1080 function hubUserIsAdmin() {
1081 return window.__hubUserRole === 'admin';
1082 }
1083
1084 /** Delete vault: self-hosted admins only; hosted matches “create vault” (writer + workspace owner when set). */
1085 function hubUserMayDeleteVault() {
1086 if (!hubUserCanWriteNotes()) return false;
1087 if (isHostedHubFromSettings()) {
1088 const ws = lastBackupSettingsPayload;
1089 const ownerId =
1090 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
1091 ? String(ws.workspace_owner_id).trim()
1092 : '';
1093 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
1094 if (ownerId && me && me !== ownerId) return false;
1095 return true;
1096 }
1097 return hubUserIsAdmin();
1098 }
1099
1100 function populateSettingsDeleteVaultSelect(s) {
1101 const sel = el('settings-delete-vault-select');
1102 if (!sel) return;
1103 const vaultList = (s && Array.isArray(s.vault_list) && s.vault_list) || [];
1104 const allowedRaw = s && Array.isArray(s.allowed_vault_ids) ? s.allowed_vault_ids : null;
1105 const allowedSet = allowedRaw && allowedRaw.length > 0 ? new Set(allowedRaw.map(String)) : null;
1106 const opts = vaultList.filter((v) => {
1107 if (!v || v.id == null) return false;
1108 const id = String(v.id).trim();
1109 if (!id || id === 'default') return false;
1110 if (allowedSet && !allowedSet.has(id)) return false;
1111 return true;
1112 });
1113 sel.innerHTML =
1114 opts.length === 0
1115 ? '<option value="">(no extra vaults)</option>'
1116 : '<option value="">— Choose vault —</option>' +
1117 opts
1118 .map(
1119 (v) =>
1120 '<option value="' +
1121 escapeHtml(String(v.id)) +
1122 '">' +
1123 escapeHtml(String(v.label != null && v.label !== '' ? v.label : v.id)) +
1124 '</option>',
1125 )
1126 .join('');
1127 }
1128
1129 function refreshVaultDeleteSubsection() {
1130 const wrap = el('settings-danger-zone-vault');
1131 if (!wrap) return;
1132 const s = lastBackupSettingsPayload;
1133 if (!s || !hubUserMayDeleteVault()) {
1134 wrap.classList.add('hidden');
1135 return;
1136 }
1137 populateSettingsDeleteVaultSelect(s);
1138 const vaultList = (s.vault_list) || [];
1139 const extra = vaultList.filter((v) => v && String(v.id).trim() && String(v.id).trim() !== 'default');
1140 if (extra.length === 0) {
1141 wrap.classList.add('hidden');
1142 return;
1143 }
1144 wrap.classList.remove('hidden');
1145 }
1146
1147 function refreshDeleteProjectPanelVisibility() {
1148 const panel = el('settings-danger-zone-panel');
1149 if (panel) panel.classList.toggle('hidden', !hubUserCanWriteNotes());
1150 refreshVaultDeleteSubsection();
1151 }
1152
1153 /** Apply GET /api/v1/settings payload to header vault switcher, hosted flag, and cached backup modal state. */
1154 function applySettingsPayloadToHubChrome(s) {
1155 if (!s || typeof s !== 'object') return;
1156 lastBackupSettingsPayload = s;
1157 if (s.role) window.__hubUserRole = String(s.role);
1158 refreshDeleteProjectPanelVisibility();
1159 refreshNewProposalTabVisibility();
1160 const allowed = (s.allowed_vault_ids || []).map(String);
1161 const current = String(getCurrentVaultId());
1162 if (allowed.length && !allowed.includes(current)) {
1163 setCurrentVaultId(allowed[0] || 'default');
1164 }
1165 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
1166 if (typeof refreshAgentCredVaultSelect === 'function') refreshAgentCredVaultSelect();
1167 applyHostedUiFromSettings(s);
1168 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
1169 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
1170 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
1171 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
1172 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
1173 syncPendingEvalQuickChip();
1174 const metaSelf = el('settings-bulk-metadata-self-only');
1175 if (metaSelf) metaSelf.classList.remove('hidden');
1176 applyMuseBridgePanel(s);
1177 }
1178
1179 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
1180 function applyMuseBridgePanel(s) {
1181 if (!s || typeof s !== 'object') return;
1182 const mb = s.muse_bridge;
1183 const statusEl = el('settings-muse-status');
1184 const envHint = el('settings-muse-env-hint');
1185 const input = el('settings-muse-url');
1186 const saveBtn = el('btn-settings-muse-save');
1187 const msg = el('settings-muse-msg');
1188 if (msg) {
1189 msg.textContent = '';
1190 msg.className = 'settings-msg';
1191 }
1192 if (!mb) {
1193 if (statusEl) statusEl.textContent = '—';
1194 if (input) {
1195 input.value = '';
1196 input.disabled = true;
1197 }
1198 if (saveBtn) saveBtn.classList.add('hidden');
1199 return;
1200 }
1201 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1202 const isAdmin = s.role === 'admin';
1203 if (statusEl) {
1204 statusEl.textContent =
1205 mb.enabled && mb.origin
1206 ? 'Server status: linked — ' + mb.origin
1207 : 'Server status: Muse link not configured for this Hub.';
1208 }
1209 if (envHint) {
1210 envHint.classList.toggle('hidden', !mb.env_override_active);
1211 envHint.textContent = mb.env_override_active
1212 ? '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.'
1213 : '';
1214 }
1215 if (input) {
1216 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
1217 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
1218 input.disabled = !canEdit;
1219 input.title = canEdit
1220 ? ''
1221 : isHosted
1222 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
1223 : !isAdmin
1224 ? 'Only admins can save the Muse URL.'
1225 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
1226 }
1227 if (saveBtn) {
1228 const show = !isHosted && isAdmin && mb.url_editable === true;
1229 saveBtn.classList.toggle('hidden', !show);
1230 }
1231 }
1232
1233 function showLoginChrome() {
1234 btnLogout.classList.add('hidden');
1235 userName.textContent = '';
1236 if (!providers) return;
1237 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1238 if (providers.github) btnLoginGithub.classList.remove('hidden');
1239 if (!providers.google && !providers.github) {
1240 oauthNotConfigured.classList.remove('hidden');
1241 if (loginIntro) loginIntro.classList.add('hidden');
1242 }
1243 }
1244
1245 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
1246 let onboardingModulePromise = null;
1247 function loadOnboardingModule() {
1248 if (!onboardingModulePromise) {
1249 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
1250 }
1251 return onboardingModulePromise;
1252 }
1253
1254 function getOnboardingUserKey() {
1255 if (!token) return '';
1256 try {
1257 const payload = JSON.parse(atob(token.split('.')[1]));
1258 return String(payload.sub || payload.email || 'unknown');
1259 } catch (_) {
1260 return 'unknown';
1261 }
1262 }
1263
1264 /**
1265 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
1266 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
1267 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
1268 */
1269 function wizardHostedFromContext(settingsPayload) {
1270 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
1271 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
1272 if (vd === 'canister') return true;
1273 try {
1274 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
1275 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
1276 } catch (_) {}
1277 return false;
1278 }
1279
1280 function persistOnboardingProgress(mod, partial) {
1281 const userKey = getOnboardingUserKey();
1282 const isHosted = wizardHostedFromContext();
1283 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1284 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1285 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1286 st = mod.createFreshState(userKey, hostingPath);
1287 }
1288 Object.assign(st, partial);
1289 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1290 }
1291
1292 let onboardingWizardBindingsDone = false;
1293 let onboardingRenderStep = function () {};
1294
1295 function closeOnboardingWizardResume() {
1296 const modal = el('modal-onboarding');
1297 if (!modal || modal.classList.contains('hidden')) return;
1298 modal.classList.add('hidden');
1299 }
1300
1301 function closeOnboardingWizardDismiss() {
1302 loadOnboardingModule()
1303 .then((mod) => {
1304 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1305 updateEmptyVaultStripVisibility();
1306 })
1307 .catch(function () {});
1308 const modal = el('modal-onboarding');
1309 if (modal) modal.classList.add('hidden');
1310 }
1311
1312 function bindOnboardingWizardOnce(mod) {
1313 if (onboardingWizardBindingsDone) return;
1314 onboardingWizardBindingsDone = true;
1315 const modal = el('modal-onboarding');
1316 const closeBtn = el('modal-onboarding-close');
1317 const backdrop = el('modal-onboarding-backdrop');
1318 const btnSkip = el('btn-onboarding-skip');
1319 const btnBack = el('btn-onboarding-back');
1320 const btnNext = el('btn-onboarding-next');
1321 const body = el('onboarding-step-body');
1322 const progress = el('onboarding-progress');
1323 const live = el('onboarding-live');
1324 const secondary = el('onboarding-secondary-actions');
1325
1326 function handleSecondaryAction(id) {
1327 /* 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. */
1328 if (id === 'projectsHelp') {
1329 openProjectsHelpModal();
1330 return;
1331 }
1332 if (id === 'howToKnowledge') {
1333 openHowToUse('knowledge-agents');
1334 return;
1335 }
1336 if (id === 'openSettingsBackup') {
1337 openSettings();
1338 return;
1339 }
1340 if (id === 'openSettingsIntegrations') {
1341 openSettingsIntegrationsTab();
1342 return;
1343 }
1344 if (id === 'howToSetup4') {
1345 openHowToUse('setup', 'how-to-step-selfhosted-index');
1346 return;
1347 }
1348 if (id === 'howToSetup3') {
1349 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1350 return;
1351 }
1352 if (id === 'openWhyTokenDoc') {
1353 window.open(
1354 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1355 '_blank',
1356 'noopener,noreferrer',
1357 );
1358 return;
1359 }
1360 if (id === 'openImportModal') {
1361 closeOnboardingWizardResume();
1362 openImportModal();
1363 return;
1364 }
1365 if (id === 'openImportSourcesDoc') {
1366 window.open(
1367 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1368 '_blank',
1369 'noopener,noreferrer',
1370 );
1371 return;
1372 }
1373 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1374 window.open(
1375 id === 'openAgentDocProposals'
1376 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1377 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1378 '_blank',
1379 'noopener,noreferrer',
1380 );
1381 return;
1382 }
1383 if (id === 'focusSuggestedTab') {
1384 closeOnboardingWizardResume();
1385 switchHubMainTab('suggested');
1386 return;
1387 }
1388 }
1389
1390 onboardingRenderStep = function renderOnboardingStep() {
1391 const userKey = getOnboardingUserKey();
1392 const isHosted = wizardHostedFromContext();
1393 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1394 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1395 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1396 st = mod.createFreshState(userKey, hostingPath);
1397 }
1398 const total = mod.getStepCount(isHosted);
1399 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1400 const content = mod.getStepContent(isHosted, idx);
1401 if (body) body.innerHTML = content ? content.bodyHtml : '';
1402 if (content && content.id === 'h-imports' && body) {
1403 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1404 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1405 }
1406
1407 if (progress) {
1408 progress.innerHTML = '';
1409 for (let i = 0; i < total; i++) {
1410 const d = document.createElement('span');
1411 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1412 d.title = 'Step ' + (i + 1) + ' of ' + total;
1413 progress.appendChild(d);
1414 }
1415 }
1416 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1417
1418 if (btnBack) btnBack.disabled = idx <= 0;
1419 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1420
1421 if (secondary) {
1422 secondary.innerHTML = '';
1423 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1424 const b = document.createElement('button');
1425 b.type = 'button';
1426 b.className = 'btn-link btn-link-small';
1427 b.textContent = a.label;
1428 b.addEventListener('click', () => handleSecondaryAction(a.id));
1429 secondary.appendChild(b);
1430 });
1431 }
1432 };
1433
1434 if (btnBack) {
1435 btnBack.addEventListener('click', () => {
1436 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1437 if (!st || st.status !== 'in_progress') return;
1438 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1439 onboardingRenderStep();
1440 });
1441 }
1442 if (btnNext) {
1443 btnNext.addEventListener('click', () => {
1444 const userKey = getOnboardingUserKey();
1445 const isHosted = wizardHostedFromContext();
1446 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1447 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1448 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1449 const total = mod.getStepCount(isHosted);
1450 if (st.stepIndex >= total - 1) {
1451 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1452 if (modal) modal.classList.add('hidden');
1453 return;
1454 }
1455 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1456 onboardingRenderStep();
1457 });
1458 }
1459 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1460 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1461 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1462
1463 modal.addEventListener('click', (ev) => {
1464 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1465 if (!copyBtn || !body) return;
1466 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1467 const txt = ta && ta.value ? String(ta.value) : '';
1468 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1469 ev.preventDefault();
1470 void navigator.clipboard.writeText(txt).then(() => {
1471 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1472 });
1473 });
1474 }
1475
1476 async function openOnboardingWizard(opts) {
1477 const restart = opts && opts.restart;
1478 const mod = await loadOnboardingModule();
1479 bindOnboardingWizardOnce(mod);
1480 const userKey = getOnboardingUserKey();
1481 const isHosted = wizardHostedFromContext();
1482 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1483 if (restart) {
1484 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1485 } else {
1486 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1487 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1488 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1489 }
1490 }
1491 const modal = el('modal-onboarding');
1492 if (modal) modal.classList.remove('hidden');
1493 onboardingRenderStep();
1494 const btnNext = el('btn-onboarding-next');
1495 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1496 }
1497
1498 async function scheduleMaybeShowOnboardingWizard(_s) {
1499 // Auto-popup removed: open only from How to use → Open setup walkthrough / Setup guide.
1500 return;
1501 }
1502
1503 function syncHubHeaderOffset() {
1504 const header = document.querySelector('.hub-header');
1505 if (!header) return;
1506 const h = Math.max(48, Math.round(header.getBoundingClientRect().height));
1507 document.documentElement.style.setProperty('--hub-header-offset', h + 'px');
1508 }
1509
1510 function showMain() {
1511 if (app) app.classList.remove('login-screen');
1512 loginRequired.classList.add('hidden');
1513 main.classList.remove('hidden');
1514 btnHowToUse.classList.remove('hidden');
1515 if (btnSettings) btnSettings.classList.remove('hidden');
1516 syncHubHeaderOffset();
1517 syncModeToolbars(getActiveHubMainTab());
1518 if (token) {
1519 btnLoginGoogle.classList.add('hidden');
1520 btnLoginGithub.classList.add('hidden');
1521 oauthNotConfigured.classList.add('hidden');
1522 btnLogout.classList.remove('hidden');
1523 try {
1524 const payload = JSON.parse(atob(token.split('.')[1]));
1525 userName.textContent = payload.name || payload.sub || 'Logged in';
1526 window.__hubUserRole = payload.role || 'member';
1527 const isViewer = window.__hubUserRole === 'viewer';
1528 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1529 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1530 const railImport = el('hub-rail-import');
1531 if (railImport) railImport.classList.toggle('hidden', isViewer);
1532 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1533 refreshDeleteProjectPanelVisibility();
1534 void refreshReviewBadge();
1535 } catch (_) {
1536 userName.textContent = 'Logged in';
1537 window.__hubUserRole = 'member';
1538 if (btnNewNote) btnNewNote.classList.remove('hidden');
1539 if (btnImport) btnImport.classList.remove('hidden');
1540 const railImport = el('hub-rail-import');
1541 if (railImport) railImport.classList.remove('hidden');
1542 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1543 refreshDeleteProjectPanelVisibility();
1544 void refreshReviewBadge();
1545 }
1546 } else {
1547 if (btnNewNote) btnNewNote.classList.add('hidden');
1548 if (btnImport) btnImport.classList.add('hidden');
1549 const railImport = el('hub-rail-import');
1550 if (railImport) railImport.classList.add('hidden');
1551 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1552 applyReviewBadgeCount(0);
1553 }
1554 hubRefreshIndexStaleBanner();
1555 }
1556
1557 function loginUrl(provider) {
1558 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1559 const invite = params.get('invite');
1560 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1561 }
1562 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1563 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1564 // because a direct window.location.href navigation has no retry mechanism.
1565 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1566 // guaranteed warm, and the OAuth redirect hits a hot instance.
1567 async function oauthNavigate(provider, btn) {
1568 const original = btn.textContent;
1569 btn.disabled = true;
1570 btn.textContent = 'Connecting…';
1571 try {
1572 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1573 // during this time so the user knows something is happening.
1574 await fetch(apiBase + '/api/v1/auth/providers', {
1575 cache: 'no-store',
1576 signal: AbortSignal.timeout(22000),
1577 });
1578 } catch (_) {
1579 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1580 // OAuth handler itself has the full 26 s budget once TCP is established.
1581 }
1582 window.location.href = loginUrl(provider);
1583 // Navigation is underway; restore button state in case the browser returns here.
1584 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1585 }
1586 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1587 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1588
1589 btnLogout.onclick = () => {
1590 // Revoke the refresh token server-side (real logout), then clear local state regardless
1591 // of whether the network call succeeds.
1592 try {
1593 fetch(apiBase + '/api/v1/auth/logout', {
1594 method: 'POST',
1595 credentials: 'include',
1596 cache: 'no-store',
1597 headers: { 'Content-Type': 'application/json' },
1598 }).catch(() => {});
1599 } catch (_) { /* best effort */ }
1600 token = null;
1601 localStorage.removeItem('hub_token');
1602 if (app) app.classList.add('login-screen');
1603 main.classList.add('hidden');
1604 browseToolbar.classList.add('hidden');
1605 btnNewNote.classList.add('hidden');
1606 if (btnImport) btnImport.classList.add('hidden');
1607 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1608 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1609 if (btnSettings) btnSettings.classList.add('hidden');
1610 closeOnboardingWizardResume();
1611 loginRequired.classList.remove('hidden');
1612 if (loginIntro) loginIntro.classList.remove('hidden');
1613 showLoginChrome();
1614 };
1615
1616 async function initProviders() {
1617 for (let attempt = 0; attempt < 3; attempt++) {
1618 try {
1619 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1620 if (!r.ok) throw new Error('providers');
1621 providers = await r.json();
1622 break;
1623 } catch (_) {
1624 if (attempt < 2) {
1625 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1626 continue;
1627 }
1628 providers = { google: false, github: false };
1629 oauthNotConfigured.classList.remove('hidden');
1630 if (loginIntro) loginIntro.classList.add('hidden');
1631 const first = oauthNotConfigured.querySelector('p');
1632 if (first) {
1633 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1634 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1635 if (isHosted && sameOrigin) {
1636 first.innerHTML =
1637 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1638 '</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.';
1639 } else if (isHosted && !sameOrigin) {
1640 first.innerHTML =
1641 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1642 '</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.';
1643 } else {
1644 first.innerHTML =
1645 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1646 escapeHtml(apiBase) +
1647 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1648 }
1649 }
1650 return;
1651 }
1652 }
1653
1654 if (!providers.google && !providers.github) {
1655 oauthNotConfigured.classList.remove('hidden');
1656 if (loginIntro) loginIntro.classList.add('hidden');
1657 } else {
1658 oauthNotConfigured.classList.add('hidden');
1659 if (loginIntro) loginIntro.classList.remove('hidden');
1660 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1661 const loggedIn =
1662 Boolean(token) ||
1663 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1664 if (!loggedIn) {
1665 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1666 if (providers.github) btnLoginGithub.classList.remove('hidden');
1667 }
1668 }
1669 }
1670
1671 if (token) {
1672 if (params.get('invite')) {
1673 (async () => {
1674 const inviteToken = params.get('invite');
1675 let lastErr;
1676 for (let attempt = 0; attempt < 3; attempt++) {
1677 try {
1678 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1679 const u = new URL(location.href);
1680 u.searchParams.delete('invite');
1681 u.searchParams.set('invite_accepted', '1');
1682 history.replaceState({}, '', u.toString());
1683 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1684 return;
1685 } catch (e) {
1686 lastErr = e;
1687 const code = e && e.code;
1688 const msg = String(e && e.message ? e.message : e || '');
1689 const staleInvite =
1690 code === 'NOT_FOUND' ||
1691 code === 'EXPIRED' ||
1692 /not found|already used|expired/i.test(msg);
1693 if (staleInvite) {
1694 const u = new URL(location.href);
1695 u.searchParams.delete('invite');
1696 history.replaceState({}, '', u.toString());
1697 if (code === 'EXPIRED' && typeof showToast === 'function') {
1698 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
1699 }
1700 return;
1701 }
1702 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
1703 }
1704 }
1705 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
1706 })();
1707 }
1708 showMain();
1709 getImageProxyToken().catch(function () {});
1710 (async function ensureVaultAndSwitcherThenLoad() {
1711 let settingsPayload = null;
1712 try {
1713 settingsPayload = await api('/api/v1/settings');
1714 applySettingsPayloadToHubChrome(settingsPayload);
1715 } catch (_) {}
1716 syncHubListSortUI('notes');
1717 syncModeToolbars('notes');
1718 refreshNewProposalTabVisibility();
1719 loadFacets();
1720 loadNotes();
1721 loadProposals();
1722 loadActivity();
1723 renderPresets();
1724 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
1725 })();
1726 initProviders();
1727 if (params.get('open') === 'billing') {
1728 const checkoutSuccess = params.get('checkout') === 'success';
1729 // Clean up params before opening so back-button doesn't re-trigger.
1730 const u = new URL(location.href);
1731 u.searchParams.delete('open');
1732 u.searchParams.delete('checkout');
1733 history.replaceState({}, '', u.toString());
1734 // Small delay so the main Hub has rendered before the modal opens.
1735 setTimeout(() => {
1736 openSettingsBillingTab();
1737 if (checkoutSuccess && typeof showToast === 'function') {
1738 showToast('Subscription activated — welcome to your new plan!');
1739 }
1740 }, 400);
1741 }
1742 if (params.get('github_connected') === '1') {
1743 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
1744 setTimeout(() => {
1745 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
1746 const u = new URL(location.href);
1747 u.searchParams.delete('github_connected');
1748 history.replaceState({}, '', u.toString());
1749 }, 500);
1750 } else if (params.get('github_connect_error')) {
1751 setTimeout(() => {
1752 const code = params.get('github_connect_error');
1753 const msg =
1754 code === 'blob_storage'
1755 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
1756 : 'GitHub connect: ' + code;
1757 if (typeof showToast === 'function') showToast(msg, true);
1758 const u = new URL(location.href);
1759 u.searchParams.delete('github_connect_error');
1760 history.replaceState({}, '', u.toString());
1761 }, 500);
1762 }
1763 } else {
1764 if (app) app.classList.add('login-screen');
1765 main.classList.add('hidden');
1766 loginRequired.classList.remove('hidden');
1767 btnNewNote.classList.add('hidden');
1768 if (btnImport) btnImport.classList.add('hidden');
1769 const inviteBanner = el('login-invite-banner');
1770 if (inviteBanner && params.get('invite')) {
1771 inviteBanner.textContent = "You've been invited. Sign in to join.";
1772 inviteBanner.classList.remove('hidden');
1773 }
1774 initProviders();
1775 }
1776 refreshApiBaseFootgunBanner();
1777 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
1778 setTimeout(() => {
1779 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1780 const u = new URL(location.href);
1781 u.searchParams.delete('invite_accepted');
1782 history.replaceState({}, '', u.pathname + u.search);
1783 }, 500);
1784 }
1785
1786 function dateSlice(d) {
1787 if (!d || typeof d !== 'string') return '';
1788 return d.trim().slice(0, 10);
1789 }
1790
1791 /** 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. */
1792 function materializeFrontmatter(fm) {
1793 if (fm == null) return {};
1794 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
1795 if (typeof fm === 'string') {
1796 let cur = fm.replace(/^\uFEFF/, '').trim();
1797 if (!cur) return {};
1798 for (let i = 0; i < 8; i++) {
1799 try {
1800 const o = JSON.parse(cur);
1801 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
1802 if (typeof o === 'string') {
1803 const next = o.trim();
1804 if (next === cur) return {};
1805 cur = next;
1806 continue;
1807 }
1808 return {};
1809 } catch {
1810 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
1811 try {
1812 const inner = JSON.parse(cur);
1813 if (typeof inner === 'string') {
1814 cur = inner.trim();
1815 continue;
1816 }
1817 } catch {
1818 /* fall through */
1819 }
1820 }
1821 return {};
1822 }
1823 }
1824 return {};
1825 }
1826 return {};
1827 }
1828
1829 function tagsFromFrontmatter(fm) {
1830 const raw = fm && fm.tags;
1831 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1832 if (typeof raw === 'string' && raw.trim()) {
1833 return raw
1834 .split(/[,\n]/)
1835 .map((s) => s.trim())
1836 .filter(Boolean);
1837 }
1838 return [];
1839 }
1840
1841 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
1842 function isoDateLocalFromMs(ms) {
1843 const d = new Date(ms);
1844 if (Number.isNaN(d.getTime())) return null;
1845 const y = d.getFullYear();
1846 const mo = String(d.getMonth() + 1).padStart(2, '0');
1847 const day = String(d.getDate()).padStart(2, '0');
1848 return y + '-' + mo + '-' + day;
1849 }
1850
1851 /**
1852 * Calendar bucket for Hub list/calendar/overview.
1853 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
1854 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
1855 */
1856 function calendarDisplayDayKey(raw) {
1857 if (raw == null) return null;
1858 const s = String(raw).trim();
1859 if (!s) return null;
1860 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
1861 const ms = Date.parse(s);
1862 if (Number.isNaN(ms)) return s.slice(0, 10);
1863 return isoDateLocalFromMs(ms);
1864 }
1865
1866 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
1867 function inferredDisplayDateFromNotePath(notePath) {
1868 if (!notePath || typeof notePath !== 'string') return null;
1869 const base = notePath.split('/').pop() || '';
1870 const m = /^note-(\d{10,})\.md$/i.exec(base);
1871 if (!m) return null;
1872 const ms = Number(m[1]);
1873 if (!Number.isFinite(ms)) return null;
1874 return isoDateLocalFromMs(ms);
1875 }
1876
1877 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
1878 function listItemDisplayDate(n, fm) {
1879 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
1880 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
1881 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
1882 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
1883 const inferred = inferredDisplayDateFromNotePath(n.path);
1884 return inferred || null;
1885 }
1886
1887 function noteSortOrCalendarDay(n) {
1888 const raw = n.date || n.updated || '';
1889 return calendarDisplayDayKey(raw) || dateSlice(raw);
1890 }
1891
1892 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
1893 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
1894 const HUB_SORT_NOTES_OPTS = [
1895 { v: 'date_desc', l: 'Newest first' },
1896 { v: 'date_asc', l: 'Oldest first' },
1897 { v: 'year_desc', l: 'Year (newest first)' },
1898 { v: 'year_asc', l: 'Year (oldest first)' },
1899 { v: 'path_asc', l: 'Path A–Z' },
1900 { v: 'title_asc', l: 'Title A–Z' },
1901 ];
1902 const HUB_SORT_PROP_OPTS = [
1903 { v: 'updated_desc', l: 'Newest first' },
1904 { v: 'updated_asc', l: 'Oldest first' },
1905 { v: 'path_asc', l: 'Path A–Z' },
1906 { v: 'status_asc', l: 'Status A–Z' },
1907 ];
1908
1909 function hubListSortGetSelect() {
1910 return el('hub-list-sort');
1911 }
1912
1913 function syncHubListSortUI(activeTab) {
1914 const sel = hubListSortGetSelect();
1915 if (!sel) return;
1916 const isNotes = activeTab === 'notes';
1917 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
1918 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
1919 let saved = '';
1920 try {
1921 saved = localStorage.getItem(key) || '';
1922 } catch (_) {}
1923 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
1924 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
1925 sel.value = saved;
1926 }
1927
1928 function setProposalFiltersBarVisible(show) {
1929 const bar = el('proposal-filters-bar');
1930 if (bar) bar.classList.toggle('hidden', !show);
1931 }
1932
1933 function refreshNewProposalTabVisibility() {
1934 const btn = el('btn-new-proposal');
1935 if (!btn) return;
1936 const tab = getActiveHubMainTab();
1937 const show = tab === 'suggested' && hubUserCanWriteNotes();
1938 btn.classList.toggle('hidden', !show);
1939 }
1940
1941 function applySortedNotesClient(notes) {
1942 const tab = getActiveHubMainTab();
1943 if (tab !== 'notes') return notes;
1944 const S = globalThis.HubListSort;
1945 const sel = hubListSortGetSelect();
1946 const mode = sel && sel.value ? sel.value : 'date_desc';
1947 if (!S || typeof S.sortNotesList !== 'function') return notes;
1948 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
1949 }
1950
1951 function applySortedProposalsClient(list) {
1952 const S = globalThis.HubListSort;
1953 const sel = hubListSortGetSelect();
1954 const mode = sel && sel.value ? sel.value : 'updated_desc';
1955 if (!S || typeof S.sortProposalsList !== 'function') return list;
1956 return S.sortProposalsList(list, mode);
1957 }
1958
1959 function normalizeHubListItem(n) {
1960 if (!n || typeof n !== 'object') return n;
1961 const fm = materializeFrontmatter(n.frontmatter);
1962 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
1963 const displayDate = listItemDisplayDate(n, fm);
1964 const updated =
1965 n.updated != null
1966 ? String(n.updated)
1967 : fm.knowtation_edited_at != null
1968 ? String(fm.knowtation_edited_at)
1969 : null;
1970 return {
1971 ...n,
1972 frontmatter: fm,
1973 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
1974 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
1975 tags,
1976 date: displayDate,
1977 updated,
1978 };
1979 }
1980
1981 function facetsAreEmpty(f) {
1982 if (!f || typeof f !== 'object') return true;
1983 const pl = f.projects && f.projects.length;
1984 const tl = f.tags && f.tags.length;
1985 const fl = f.folders && f.folders.length;
1986 return !pl && !tl && !fl;
1987 }
1988
1989 async function deriveFacetsFromNotes() {
1990 const out = await api('/api/v1/notes?limit=500&offset=0');
1991 const projects = new Set();
1992 const tags = new Set();
1993 const folders = new Set();
1994 for (const raw of out.notes || []) {
1995 const n = normalizeHubListItem(raw);
1996 if (n.path) {
1997 const seg = String(n.path).split('/')[0];
1998 if (seg) folders.add(seg);
1999 }
2000 if (n.project) projects.add(String(n.project));
2001 (n.tags || []).forEach((t) => tags.add(String(t)));
2002 }
2003 return {
2004 projects: [...projects].sort((a, b) => a.localeCompare(b)),
2005 tags: [...tags].sort((a, b) => a.localeCompare(b)),
2006 folders: [...folders].sort((a, b) => a.localeCompare(b)),
2007 };
2008 }
2009
2010 async function fetchFacetsResolved() {
2011 let facets = await api('/api/v1/notes/facets');
2012 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
2013 return facets;
2014 }
2015
2016 function hubRowIsApprovalLog(n) {
2017 if (!n || !n.path) return false;
2018 const path = String(n.path).replace(/\\/g, '/');
2019 if (path === 'approvals' || path.startsWith('approvals/')) return true;
2020 const k =
2021 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
2022 return String(k) === 'approval_log';
2023 }
2024
2025 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
2026 function applyVaultListFilters(notes, opts) {
2027 let out = notes.slice();
2028 if (opts.folder) {
2029 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
2030 const prefix = f + '/';
2031 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
2032 }
2033 if (opts.project) {
2034 const p = normSlug(opts.project);
2035 out = out.filter(
2036 (n) =>
2037 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
2038 );
2039 }
2040 if (opts.tag) {
2041 const t = normSlug(opts.tag);
2042 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
2043 }
2044 if (opts.since) {
2045 const s = dateSlice(opts.since);
2046 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
2047 }
2048 if (opts.until) {
2049 const u = dateSlice(opts.until);
2050 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
2051 }
2052 const cs = opts.content_scope;
2053 if (cs === 'notes') {
2054 out = out.filter((n) => !hubRowIsApprovalLog(n));
2055 } else if (cs === 'approval_logs') {
2056 out = out.filter((n) => hubRowIsApprovalLog(n));
2057 }
2058 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
2059 if (opts.network) {
2060 const net = String(opts.network).trim().toLowerCase();
2061 out = out.filter((n) => {
2062 const v = n.frontmatter?.network ?? n.network;
2063 return v != null && String(v).trim().toLowerCase() === net;
2064 });
2065 }
2066 if (opts.wallet_address) {
2067 const wa = String(opts.wallet_address).trim().toLowerCase();
2068 out = out.filter((n) => {
2069 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
2070 return v != null && String(v).trim().toLowerCase() === wa;
2071 });
2072 }
2073 if (opts.payment_status) {
2074 const ps = String(opts.payment_status).trim().toLowerCase();
2075 out = out.filter((n) => {
2076 const v = n.frontmatter?.payment_status ?? n.payment_status;
2077 return v != null && String(v).trim().toLowerCase() === ps;
2078 });
2079 }
2080 return out;
2081 }
2082
2083 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
2084 const HUB_RESERVED_FM_KEYS = new Set([
2085 'knowtation_editor',
2086 'knowtation_edited_at',
2087 'author_kind',
2088 'knowtation_proposed_by',
2089 'knowtation_approved_by',
2090 ]);
2091
2092 function stripReservedHubFm(fm) {
2093 const out = {};
2094 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
2095 for (const [k, v] of Object.entries(fm)) {
2096 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
2097 out[k] = v;
2098 }
2099 return out;
2100 }
2101
2102 /**
2103 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
2104 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
2105 */
2106 function stringifyNotePostPayload(path, body, frontmatter) {
2107 const fmStr =
2108 typeof frontmatter === 'string'
2109 ? frontmatter
2110 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
2111 return JSON.stringify({ path, body, frontmatter: fmStr });
2112 }
2113
2114 const DETAIL_EDIT_FM_KEYS = [
2115 'title',
2116 'date',
2117 'project',
2118 'tags',
2119 'causal_chain_id',
2120 'entity',
2121 'episode_id',
2122 'follows',
2123 ];
2124
2125 function mergedFrontmatterForDetailSave() {
2126 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
2127 const preserved = {};
2128 for (const [k, v] of Object.entries(base)) {
2129 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
2130 }
2131 const dateVal =
2132 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
2133 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
2134 const tTitle = title.trim();
2135 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2136 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
2137 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
2138 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
2139 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
2140 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
2141 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
2142 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
2143 const follows = followsRaw
2144 ? followsRaw.includes(',')
2145 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
2146 : followsRaw
2147 : undefined;
2148 const out = { ...preserved, date: dateVal };
2149 if (tTitle) out.title = tTitle;
2150 else delete out.title;
2151 if (project) out.project = project;
2152 else delete out.project;
2153 if (tags) out.tags = tags;
2154 else delete out.tags;
2155 if (causalChain) out.causal_chain_id = causalChain;
2156 else delete out.causal_chain_id;
2157 if (entity.length) out.entity = entity;
2158 else delete out.entity;
2159 if (episode) out.episode_id = episode;
2160 else delete out.episode_id;
2161 if (follows) out.follows = follows;
2162 else delete out.follows;
2163 return out;
2164 }
2165
2166 function fillDetailEditFieldsFromFrontmatter(fm) {
2167 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
2168 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2169 const savedProj = f.project != null ? String(f.project).trim() : '';
2170 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
2171 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
2172 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
2173 if (el('detail-edit-project')) {
2174 const inp = el('detail-edit-project');
2175 if (pathProj) {
2176 inp.value = pathProj;
2177 inp.readOnly = true;
2178 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
2179 } else {
2180 inp.readOnly = false;
2181 inp.title = '';
2182 inp.value = savedProj;
2183 }
2184 }
2185 const hint = el('detail-edit-project-hint');
2186 if (hint) {
2187 if (pathProj) {
2188 hint.classList.remove('hidden');
2189 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
2190 hint.textContent = mismatch
2191 ? 'Path implies project «' +
2192 pathProj +
2193 '»; saved frontmatter had «' +
2194 savedProj +
2195 '». Saving will store «' +
2196 pathProj +
2197 '» to match the path.'
2198 : 'Project slug matches vault path projects/' + pathProj + '/.';
2199 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
2200 } else {
2201 hint.classList.remove('hidden');
2202 hint.className = 'muted small detail-project-hint';
2203 hint.textContent =
2204 '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.';
2205 }
2206 }
2207 const pathTypoEl = el('detail-edit-path-typo-hint');
2208 if (pathTypoEl && currentOpenNote) {
2209 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
2210 if (sug) {
2211 pathTypoEl.textContent =
2212 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
2213 sug +
2214 '. Rename or move the file in your vault (path cannot be edited here).';
2215 pathTypoEl.className = 'muted small detail-project-hint warn';
2216 pathTypoEl.classList.remove('hidden');
2217 } else {
2218 pathTypoEl.textContent = '';
2219 pathTypoEl.className = 'muted small detail-project-hint hidden';
2220 pathTypoEl.classList.add('hidden');
2221 }
2222 }
2223 const tags = f.tags;
2224 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
2225 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
2226 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
2227 const ent = f.entity;
2228 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
2229 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
2230 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
2231 const fol = f.follows;
2232 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
2233 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
2234 }
2235
2236 async function loadFacets() {
2237 try {
2238 const savedProject = filterProject.value;
2239 const savedTag = filterTag.value;
2240 const savedFolder = filterFolder.value;
2241 const savedNetwork = filterNetwork ? filterNetwork.value : '';
2242 const savedWallet = filterWallet ? filterWallet.value : '';
2243 const facets = await fetchFacetsResolved();
2244 lastHubFacets = facets;
2245 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
2246 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
2247 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
2248 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
2249 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
2250 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
2251 // Phase 12 — blockchain filter dropdowns (hidden when no data)
2252 if (filterNetwork) {
2253 const nets = facets.networks || [];
2254 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
2255 filterNetwork.classList.toggle('hidden', nets.length === 0);
2256 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
2257 }
2258 if (filterWallet) {
2259 const wallets = facets.wallets || [];
2260 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
2261 filterWallet.classList.toggle('hidden', wallets.length === 0);
2262 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
2263 }
2264 renderFilterChips(facets);
2265 hydrateFullCreateProjectSlugSelect(facets);
2266 hydrateImportCreateProjectSlugSelect(facets);
2267 } catch (_) {
2268 renderFilterChips(null);
2269 lastHubFacets = null;
2270 hydrateFullCreateProjectSlugSelect(null);
2271 hydrateImportCreateProjectSlugSelect(null);
2272 }
2273 }
2274
2275 function normSlug(s) {
2276 return String(s || '')
2277 .toLowerCase()
2278 .replace(/[^a-z0-9-]/g, '-')
2279 .replace(/-+/g, '-')
2280 .replace(/^-|-$/g, '');
2281 }
2282
2283 /**
2284 * First path segment after `projects/` (vault-relative). Used so project frontmatter
2285 * stays aligned with on-disk layout (projects/<slug>/…).
2286 */
2287 function projectSlugFromProjectsPath(path) {
2288 if (!path || typeof path !== 'string') return null;
2289 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
2290 return m ? m[1] : null;
2291 }
2292
2293 /**
2294 * Common typo: vault path starts with `project/` instead of `projects/`.
2295 * Returns the same path with the corrected prefix, or null if no typo.
2296 */
2297 function projectsPathTypoSuggestion(path) {
2298 const p = String(path || '').trim();
2299 if (!p) return null;
2300 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2301 return null;
2302 }
2303
2304 function normalizeProjectKeyForSimilarity(s) {
2305 return String(s || '')
2306 .toLowerCase()
2307 .trim()
2308 .replace(/[\s_]+/g, '-')
2309 .replace(/-+/g, '-')
2310 .replace(/^-|-$/g, '');
2311 }
2312
2313 function levenshteinHub(a, b) {
2314 const m = a.length;
2315 const n = b.length;
2316 if (!m) return n;
2317 if (!n) return m;
2318 const row = new Array(n + 1);
2319 for (let j = 0; j <= n; j++) row[j] = j;
2320 for (let i = 1; i <= m; i++) {
2321 let prev = row[0];
2322 row[0] = i;
2323 for (let j = 1; j <= n; j++) {
2324 const cur = row[j];
2325 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2326 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2327 prev = cur;
2328 }
2329 }
2330 return row[n];
2331 }
2332
2333 /**
2334 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2335 * Exact normSlug match returns null (no warning).
2336 */
2337 function findSimilarFacetProject(userSlug, projectsArr) {
2338 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2339 const uNorm = normSlug(String(userSlug));
2340 if (!uNorm) return null;
2341 for (const p of projectsArr) {
2342 if (normSlug(String(p)) === uNorm) return null;
2343 }
2344 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2345 let best = null;
2346 let bestScore = Infinity;
2347 for (const p of projectsArr) {
2348 const pv = String(p).trim();
2349 if (!pv) continue;
2350 const pNorm = normSlug(pv);
2351 if (!pNorm) continue;
2352 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2353 let score = Infinity;
2354 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2355 if (score > 0) {
2356 const a = normalizeProjectKeyForSimilarity(userSlug);
2357 const b = normalizeProjectKeyForSimilarity(pv);
2358 const d = levenshteinHub(a, b);
2359 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2360 }
2361 if (score > 0) {
2362 const a = normalizeProjectKeyForSimilarity(userSlug);
2363 const b = normalizeProjectKeyForSimilarity(pv);
2364 const shorter = a.length <= b.length ? a : b;
2365 const longer = a.length <= b.length ? b : a;
2366 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2367 score = Math.min(score, longer.length - shorter.length + 0.5);
2368 }
2369 }
2370 if (score < bestScore) {
2371 bestScore = score;
2372 best = pv;
2373 }
2374 }
2375 return bestScore < 10 ? best : null;
2376 }
2377
2378 function collectProjectSubroots(slug, folderStrings) {
2379 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2380 const subs = new Set();
2381 for (const f of folderStrings || []) {
2382 if (!f || typeof f !== 'string') continue;
2383 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2384 if (!n.startsWith(prefix)) continue;
2385 const rest = n.slice(prefix.length);
2386 if (!rest) continue;
2387 const first = rest.split('/')[0];
2388 if (first) subs.add(first);
2389 }
2390 return [...subs].sort((a, b) => a.localeCompare(b));
2391 }
2392
2393 function fullCreatePathFilename(pathVal) {
2394 const t = String(pathVal || '').trim();
2395 const parts = t.split('/').filter(Boolean);
2396 const last = parts[parts.length - 1];
2397 if (last && /\.md$/i.test(last)) return last;
2398 return 'note-' + Date.now() + '.md';
2399 }
2400
2401 function mergeFolderStringsForSubroots() {
2402 const out = new Set();
2403 for (const f of lastVaultFoldersForCreate || []) {
2404 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2405 }
2406 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2407 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2408 }
2409 return [...out];
2410 }
2411
2412 function updateFullCreatePathLayoutVisibility() {
2413 const slugSel = el('full-create-project-slug');
2414 const subWrap = el('full-create-project-subroot-wrap');
2415 const nonProj = el('full-create-nonproject-folder-wrap');
2416 const subSel = el('full-create-project-subroot');
2417 if (!slugSel) return;
2418 const v = slugSel.value;
2419 const useProject = v && v !== '__custom__';
2420 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2421 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2422 if (subSel) subSel.disabled = !useProject;
2423 }
2424
2425 function refreshFullCreateSubrootSelect() {
2426 const slugSel = el('full-create-project-slug');
2427 const subSel = el('full-create-project-subroot');
2428 if (!slugSel || !subSel) return;
2429 const slug = slugSel.value;
2430 const preserve = subSel.value;
2431 if (!slug || slug === '__custom__') {
2432 subSel.innerHTML = '';
2433 subSel.disabled = true;
2434 return;
2435 }
2436 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2437 const head = document.createElement('option');
2438 head.value = '';
2439 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2440 subSel.innerHTML = '';
2441 subSel.appendChild(head);
2442 for (const s of subs) {
2443 const o = document.createElement('option');
2444 o.value = s;
2445 o.textContent = s;
2446 subSel.appendChild(o);
2447 }
2448 const custom = document.createElement('option');
2449 custom.value = '__custom_sub__';
2450 custom.textContent = 'Custom (edit path)';
2451 subSel.appendChild(custom);
2452 subSel.disabled = false;
2453 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2454 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2455 else if (subs.includes('inbox')) subSel.value = 'inbox';
2456 else if (subs.length === 1) subSel.value = subs[0];
2457 else subSel.value = '';
2458 }
2459
2460 function composeFullPathFromCreatePickers() {
2461 const slugSel = el('full-create-project-slug');
2462 const subSel = el('full-create-project-subroot');
2463 const pathInp = el('full-path');
2464 if (!slugSel || !pathInp) return;
2465 const slugVal = slugSel.value;
2466 if (!slugVal || slugVal === '__custom__') return;
2467 if (subSel && subSel.value === '__custom_sub__') return;
2468 const sub =
2469 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2470 const fname = fullCreatePathFilename(pathInp.value);
2471 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2472 pathInp.value = base;
2473 }
2474
2475 function syncFullCreatePickersFromPath() {
2476 const slugSel = el('full-create-project-slug');
2477 const subSel = el('full-create-project-subroot');
2478 const pathInp = el('full-path');
2479 if (!slugSel || !pathInp) return;
2480 const raw = pathInp.value.trim();
2481 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2482 if (!m) {
2483 slugSel.value = raw ? '__custom__' : '';
2484 refreshFullCreateSubrootSelect();
2485 updateFullCreatePathLayoutVisibility();
2486 return;
2487 }
2488 const diskSlug = m[1];
2489 const rest = m[2];
2490 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2491 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2492 if (match) slugSel.value = match;
2493 else slugSel.value = '__custom__';
2494 refreshFullCreateSubrootSelect();
2495 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2496 const segments = rest.split('/').filter(Boolean);
2497 const lastSeg = segments[segments.length - 1];
2498 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2499 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2500 const firstDir = dirParts[0] || '';
2501 const allowed = new Set(
2502 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2503 );
2504 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2505 else if (firstDir) subSel.value = '__custom_sub__';
2506 else subSel.value = '';
2507 }
2508 updateFullCreatePathLayoutVisibility();
2509 }
2510
2511 function hydrateFullCreateProjectSlugSelect(facets) {
2512 const sel = el('full-create-project-slug');
2513 if (!sel) return;
2514 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2515 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2516 const preserve = sel.value;
2517 sel.innerHTML =
2518 '<option value="">— Not under projects/ —</option>' +
2519 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2520 '<option value="__custom__">Custom (type full path)</option>';
2521 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2522 refreshFullCreateSubrootSelect();
2523 updateFullCreatePathLayoutVisibility();
2524 }
2525
2526 function updateImportPathLayoutVisibility() {
2527 const slugSel = el('import-create-project-slug');
2528 const subWrap = el('import-create-project-subroot-wrap');
2529 const nonProj = el('import-nonproject-folder-wrap');
2530 const subSel = el('import-create-project-subroot');
2531 if (!slugSel) return;
2532 const v = slugSel.value;
2533 const useProject = v && v !== '__custom__';
2534 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2535 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2536 if (subSel) subSel.disabled = !useProject;
2537 }
2538
2539 function refreshImportCreateSubrootSelect() {
2540 const slugSel = el('import-create-project-slug');
2541 const subSel = el('import-create-project-subroot');
2542 if (!slugSel || !subSel) return;
2543 const slug = slugSel.value;
2544 const preserve = subSel.value;
2545 if (!slug || slug === '__custom__') {
2546 subSel.innerHTML = '';
2547 subSel.disabled = true;
2548 return;
2549 }
2550 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2551 const head = document.createElement('option');
2552 head.value = '';
2553 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2554 subSel.innerHTML = '';
2555 subSel.appendChild(head);
2556 for (const s of subs) {
2557 const o = document.createElement('option');
2558 o.value = s;
2559 o.textContent = s;
2560 subSel.appendChild(o);
2561 }
2562 const custom = document.createElement('option');
2563 custom.value = '__custom_sub__';
2564 custom.textContent = 'Custom (edit path)';
2565 subSel.appendChild(custom);
2566 subSel.disabled = false;
2567 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2568 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2569 else if (subs.includes('inbox')) subSel.value = 'inbox';
2570 else if (subs.length === 1) subSel.value = subs[0];
2571 else subSel.value = '';
2572 }
2573
2574 function composeImportOutputDirFromPickers() {
2575 const slugSel = el('import-create-project-slug');
2576 const subSel = el('import-create-project-subroot');
2577 const outInp = el('import-output-dir');
2578 if (!slugSel || !outInp) return;
2579 const slugVal = slugSel.value;
2580 if (!slugVal || slugVal === '__custom__') return;
2581 if (subSel && subSel.value === '__custom_sub__') return;
2582 const sub =
2583 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2584 const subUse = sub || 'inbox';
2585 outInp.value = 'projects/' + slugVal + '/' + subUse;
2586 }
2587
2588 function syncImportPickersFromOutputDir() {
2589 const slugSel = el('import-create-project-slug');
2590 const subSel = el('import-create-project-subroot');
2591 const outInp = el('import-output-dir');
2592 if (!slugSel || !outInp) return;
2593 const raw = outInp.value.trim().replace(/\/+$/, '');
2594 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2595 if (!m) {
2596 slugSel.value = raw ? '__custom__' : '';
2597 refreshImportCreateSubrootSelect();
2598 updateImportPathLayoutVisibility();
2599 return;
2600 }
2601 const diskSlug = m[1];
2602 const rest = m[2] || '';
2603 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2604 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2605 if (match) slugSel.value = match;
2606 else slugSel.value = '__custom__';
2607 refreshImportCreateSubrootSelect();
2608 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2609 const segments = rest.split('/').filter(Boolean);
2610 const firstDir = segments[0] || '';
2611 const allowed = new Set(
2612 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2613 );
2614 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2615 else if (firstDir) subSel.value = '__custom_sub__';
2616 else subSel.value = '';
2617 }
2618 updateImportPathLayoutVisibility();
2619 }
2620
2621 function hydrateImportCreateProjectSlugSelect(facets) {
2622 const sel = el('import-create-project-slug');
2623 if (!sel) return;
2624 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2625 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2626 const preserve = sel.value;
2627 sel.innerHTML =
2628 '<option value="">— Not under projects/ —</option>' +
2629 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2630 '<option value="__custom__">Custom (type full path)</option>';
2631 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2632 refreshImportCreateSubrootSelect();
2633 updateImportPathLayoutVisibility();
2634 }
2635
2636 function syncImportFolderSelectToOutputDir() {
2637 const outInp = el('import-output-dir');
2638 const sel = el('import-vault-folder');
2639 if (!outInp || !sel) return;
2640 const p = outInp.value.trim().replace(/\/+$/, '');
2641 if (!p) return;
2642 let best = '__custom__';
2643 let bestLen = -1;
2644 for (const opt of sel.options) {
2645 const v = opt.value;
2646 if (v === '__custom__') continue;
2647 if (p === v || p.startsWith(v + '/')) {
2648 if (v.length > bestLen) {
2649 best = v;
2650 bestLen = v.length;
2651 }
2652 }
2653 }
2654 sel.value = bestLen >= 0 ? best : '__custom__';
2655 }
2656
2657 function defaultImportOutputDir() {
2658 const slugSel = el('import-create-project-slug');
2659 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2660 const subSel = el('import-create-project-subroot');
2661 const sub =
2662 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2663 const subUse = sub || 'inbox';
2664 return 'projects/' + slugSel.value + '/' + subUse;
2665 }
2666 const sel = el('import-vault-folder');
2667 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2668 return folder;
2669 }
2670
2671 function getImportProjectAndOutputDir() {
2672 const outInp = el('import-output-dir');
2673 const slugSel = el('import-create-project-slug');
2674 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2675 if (raw) {
2676 const sug = projectsPathTypoSuggestion(raw);
2677 if (sug) {
2678 return {
2679 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
2680 project: '',
2681 outputDir: undefined,
2682 };
2683 }
2684 }
2685 const outputDir = raw || undefined;
2686 let project = '';
2687 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2688 project = normSlug(slugSel.value);
2689 }
2690 if (!project && outputDir) {
2691 const m = outputDir.match(/^projects\/([^/]+)/);
2692 if (m) project = normSlug(m[1]);
2693 }
2694 return { err: null, project: project || '', outputDir: outputDir || undefined };
2695 }
2696
2697 function updateFullCreateSimilarInlineHint() {
2698 const hint = el('full-path-similar-hint');
2699 const btn = el('btn-full-path-use-similar-project');
2700 const pathInp = el('full-path');
2701 if (!hint || !pathInp) return;
2702 const notePath = pathInp.value.trim();
2703 const slug = projectSlugFromProjectsPath(notePath);
2704 const similar =
2705 slug && (lastHubFacets && lastHubFacets.projects)
2706 ? findSimilarFacetProject(slug, lastHubFacets.projects)
2707 : null;
2708 if (similar && notePath.startsWith('projects/')) {
2709 hint.textContent =
2710 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
2711 hint.className = 'muted small detail-project-hint warn';
2712 hint.classList.remove('hidden');
2713 if (btn) {
2714 btn.classList.remove('hidden');
2715 btn.onclick = () => {
2716 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
2717 pathInp.value = fixed;
2718 syncFolderSelectToPathInput();
2719 syncFullCreatePickersFromPath();
2720 syncFullProjectFromPath();
2721 updateFullPathProjectTypoHint();
2722 updateFullCreateSimilarInlineHint();
2723 };
2724 }
2725 } else {
2726 hint.textContent = '';
2727 hint.className = 'muted small detail-project-hint hidden';
2728 hint.classList.add('hidden');
2729 if (btn) {
2730 btn.classList.add('hidden');
2731 btn.onclick = null;
2732 }
2733 }
2734 }
2735
2736 function scheduleFullCreateSimilarHint() {
2737 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
2738 fullPathSimilarDebounceTimer = window.setTimeout(() => {
2739 fullPathSimilarDebounceTimer = 0;
2740 updateFullCreateSimilarInlineHint();
2741 }, 220);
2742 }
2743
2744 function openFullCreateSimilarModal(notePath, suggestedSlug) {
2745 const modal = el('modal-create-similar-project');
2746 const body = el('modal-create-similar-project-body');
2747 if (!modal || !body) return;
2748 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
2749 fullCreateSimilarModalPendingPath = notePath;
2750 const bad = projectSlugFromProjectsPath(notePath) || '…';
2751 body.textContent =
2752 'Your path starts with projects/' +
2753 bad +
2754 '/ but an existing project slug is «' +
2755 suggestedSlug +
2756 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
2757 modal.classList.remove('hidden');
2758 const focusBtn = el('btn-modal-create-similar-use-existing');
2759 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
2760 }
2761
2762 function closeFullCreateSimilarModal() {
2763 const modal = el('modal-create-similar-project');
2764 if (modal) modal.classList.add('hidden');
2765 fullCreateSimilarModalSuggestedSlug = '';
2766 fullCreateSimilarModalPendingPath = '';
2767 }
2768
2769 /** True when any list filter used by loadNotes / Quick chips is set. */
2770 function listFacetFiltersActive() {
2771 if (filterProject.value) return true;
2772 if (filterTag.value) return true;
2773 if (filterFolder.value) return true;
2774 if (filterNetwork && filterNetwork.value) return true;
2775 if (filterWallet && filterWallet.value) return true;
2776 const fps = el('filter-payment-status');
2777 if (fps && fps.value) return true;
2778 if (filterSince && filterSince.value) return true;
2779 if (filterUntil && filterUntil.value) return true;
2780 if (filterContentScope && filterContentScope.value) return true;
2781 return false;
2782 }
2783
2784 function clearListFacetFilters() {
2785 filterProject.value = '';
2786 filterTag.value = '';
2787 filterFolder.value = '';
2788 if (filterNetwork) filterNetwork.value = '';
2789 if (filterWallet) filterWallet.value = '';
2790 const fps = el('filter-payment-status');
2791 if (fps) fps.value = '';
2792 if (filterSince) filterSince.value = '';
2793 if (filterUntil) filterUntil.value = '';
2794 if (filterContentScope) filterContentScope.value = '';
2795 }
2796
2797 function renderFilterChips(facets) {
2798 filterChipsEl.innerHTML = '';
2799 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2800
2801 const header = document.createElement('div');
2802 header.className = 'filter-chips-header';
2803
2804 const label = document.createElement('span');
2805 label.className = 'toolbar-label';
2806 label.textContent = 'Quick tags';
2807 label.title = 'Quick tags: project, tag, folder, and network filter chips (not the key glossary)';
2808
2809 const toggle = document.createElement('button');
2810 toggle.type = 'button';
2811 toggle.className = 'filter-chips-toggle';
2812 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2813 toggle.setAttribute('aria-controls', 'filter-chips-panel');
2814 toggle.title = filterChipsExpanded
2815 ? 'Hide Quick tags filter chips'
2816 : 'Show Quick tags filter chips';
2817 toggle.setAttribute(
2818 'aria-label',
2819 filterChipsExpanded
2820 ? 'Collapse Quick tags filter chips'
2821 : 'Expand Quick tags filter chips',
2822 );
2823 toggle.innerHTML =
2824 '<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>';
2825 toggle.onclick = () => {
2826 filterChipsExpanded = !filterChipsExpanded;
2827 try {
2828 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
2829 } catch (_) {}
2830 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2831 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2832 toggle.title = filterChipsExpanded
2833 ? 'Hide Quick tags filter chips'
2834 : 'Show Quick tags filter chips';
2835 toggle.setAttribute(
2836 'aria-label',
2837 filterChipsExpanded
2838 ? 'Collapse Quick tags filter chips'
2839 : 'Expand Quick tags filter chips',
2840 );
2841 };
2842
2843 header.appendChild(label);
2844 header.appendChild(toggle);
2845 filterChipsEl.appendChild(header);
2846
2847 const panel = document.createElement('div');
2848 panel.id = 'filter-chips-panel';
2849 panel.className = 'filter-chips-panel';
2850 panel.setAttribute('role', 'region');
2851 panel.setAttribute('aria-label', 'Quick tags filter chips');
2852 filterChipsEl.appendChild(panel);
2853
2854 const allBtn = document.createElement('button');
2855 allBtn.type = 'button';
2856 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
2857 allBtn.textContent = 'All';
2858 allBtn.title =
2859 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
2860 allBtn.onclick = () => {
2861 searchQuery.value = '';
2862 clearListFacetFilters();
2863 switchNotesView('list');
2864 loadNotes();
2865 renderFilterChips(null);
2866 };
2867 panel.appendChild(allBtn);
2868
2869 const apply = (f) => {
2870 if (!f) return;
2871 (f.projects || []).slice(0, 12).forEach((p) => {
2872 const b = document.createElement('button');
2873 b.type = 'button';
2874 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
2875 b.textContent = 'project:' + p;
2876 b.onclick = () => {
2877 searchQuery.value = '';
2878 filterProject.value = p;
2879 filterTag.value = '';
2880 filterFolder.value = '';
2881 switchNotesView('list');
2882 loadNotes();
2883 renderFilterChips(null);
2884 };
2885 panel.appendChild(b);
2886 });
2887 (f.tags || []).slice(0, 10).forEach((t) => {
2888 const b = document.createElement('button');
2889 b.type = 'button';
2890 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
2891 b.textContent = 'tag:' + t;
2892 b.onclick = () => {
2893 searchQuery.value = '';
2894 filterTag.value = t;
2895 filterProject.value = '';
2896 filterFolder.value = '';
2897 switchNotesView('list');
2898 loadNotes();
2899 renderFilterChips(null);
2900 };
2901 panel.appendChild(b);
2902 });
2903 (f.folders || []).slice(0, 12).forEach((folder) => {
2904 const b = document.createElement('button');
2905 b.type = 'button';
2906 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
2907 b.textContent = 'folder:' + folder;
2908 b.onclick = () => {
2909 searchQuery.value = '';
2910 filterFolder.value = folder;
2911 filterProject.value = '';
2912 filterTag.value = '';
2913 switchNotesView('list');
2914 loadNotes();
2915 renderFilterChips(null);
2916 };
2917 panel.appendChild(b);
2918 });
2919 // Phase 12 — network chips
2920 (f.networks || []).slice(0, 8).forEach((net) => {
2921 const b = document.createElement('button');
2922 b.type = 'button';
2923 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
2924 b.textContent = 'net:' + net;
2925 b.onclick = () => {
2926 searchQuery.value = '';
2927 if (filterNetwork) filterNetwork.value = net;
2928 switchNotesView('list');
2929 loadNotes();
2930 renderFilterChips(null);
2931 };
2932 panel.appendChild(b);
2933 });
2934 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
2935 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
2936 const payStatuses = ['pending', 'settled', 'failed'];
2937 payStatuses.forEach((ps) => {
2938 const b = document.createElement('button');
2939 b.type = 'button';
2940 b.className = 'chip-btn chip-blockchain';
2941 b.textContent = 'status:' + ps;
2942 b.onclick = () => {
2943 searchQuery.value = '';
2944 const fpsEl = el('filter-payment-status');
2945 if (fpsEl) fpsEl.value = ps;
2946 switchNotesView('list');
2947 loadNotes();
2948 renderFilterChips(null);
2949 };
2950 panel.appendChild(b);
2951 });
2952 }
2953 };
2954 if (facets) apply(facets);
2955 else fetchFacetsResolved().then(apply).catch(() => {});
2956 }
2957
2958 function getPresets() {
2959 try {
2960 const raw = localStorage.getItem(PRESETS_KEY);
2961 return raw ? JSON.parse(raw) : [];
2962 } catch (_) {
2963 return [];
2964 }
2965 }
2966
2967 function savePreset() {
2968 const name = (presetNameInput.value || '').trim();
2969 if (!name) return;
2970 const presets = getPresets().filter((p) => p.name !== name);
2971 presets.push({
2972 name,
2973 project: filterProject.value,
2974 tag: filterTag.value,
2975 folder: filterFolder.value,
2976 since: filterSince?.value || '',
2977 until: filterUntil?.value || '',
2978 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2979 });
2980 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
2981 presetNameInput.value = '';
2982 renderPresets();
2983 }
2984
2985 function renderPresets() {
2986 presetsListEl.innerHTML = '';
2987 getPresets().forEach((p) => {
2988 const b = document.createElement('button');
2989 b.type = 'button';
2990 b.className = 'preset-pill';
2991 b.textContent = p.name;
2992 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(' ');
2993 b.onclick = () => {
2994 filterProject.value = p.project || '';
2995 filterTag.value = p.tag || '';
2996 filterFolder.value = p.folder || '';
2997 if (filterSince) filterSince.value = p.since || '';
2998 if (filterUntil) filterUntil.value = p.until || '';
2999 if (filterContentScope) filterContentScope.value = p.content_scope || '';
3000 switchNotesView('list');
3001 loadNotes();
3002 renderFilterChips(null);
3003 };
3004 presetsListEl.appendChild(b);
3005 });
3006 }
3007
3008 el('btn-save-preset').onclick = savePreset;
3009
3010 function renderNoteRow(n) {
3011 const title = n.title || n.path;
3012 const isLog = hubRowIsApprovalLog(n);
3013 const chips = [];
3014 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
3015 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3016 const meta = [n.date].filter(Boolean).join(' · ');
3017 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3018 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3019 return (
3020 '<div class="' +
3021 rowClass +
3022 '" data-path="' +
3023 escapeHtml(n.path) +
3024 '"><span class="row-title">' +
3025 escapeHtml(title) +
3026 badge +
3027 '</span><div class="row-chips">' +
3028 chips.join('') +
3029 '</div>' +
3030 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
3031 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
3032 '</div>'
3033 );
3034 }
3035
3036 function bindNoteClicks(container) {
3037 container.querySelectorAll('.list-item').forEach((item) => {
3038 item.onclick = () => openNote(item.dataset.path);
3039 const delBtn = item.querySelector('.list-item-delete');
3040 if (delBtn) {
3041 delBtn.onclick = async (e) => {
3042 e.stopPropagation();
3043 const path = item.dataset.path;
3044 if (!path) return;
3045 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
3046 try {
3047 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
3048 if (typeof showToast === 'function') showToast('Deleted: ' + path);
3049 hubMarkSemanticIndexStale();
3050 if (currentOpenNote && currentOpenNote.path === path) {
3051 currentOpenNote = null;
3052 resetDetailSectionSourceState();
3053 hideDetailPanelChrome();
3054 }
3055 loadNotes();
3056 loadFacets();
3057 } catch (err) {
3058 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
3059 }
3060 };
3061 }
3062 });
3063 }
3064
3065 function hasActiveNoteListFilters() {
3066 if (filterProject && filterProject.value) return true;
3067 if (filterTag && filterTag.value) return true;
3068 if (filterFolder && filterFolder.value) return true;
3069 if (filterSince && filterSince.value) return true;
3070 if (filterUntil && filterUntil.value) return true;
3071 if (filterContentScope && filterContentScope.value) return true;
3072 if (filterNetwork && filterNetwork.value) return true;
3073 if (filterWallet && filterWallet.value) return true;
3074 const paymentStatusEl = el('filter-payment-status');
3075 if (paymentStatusEl && paymentStatusEl.value) return true;
3076 return false;
3077 }
3078
3079 function readOnboardingDismissedSync() {
3080 try {
3081 const raw = localStorage.getItem('knowtation_onboarding_v1');
3082 if (!raw) return false;
3083 const o = JSON.parse(raw);
3084 return Boolean(o && o.v === 1 && o.status === 'dismissed');
3085 } catch (_) {
3086 return false;
3087 }
3088 }
3089
3090 function isSearchResultsView() {
3091 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
3092 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
3093 }
3094
3095 function updateEmptyVaultStripVisibility() {
3096 const strip = el('hub-empty-vault-strip');
3097 if (!strip) return;
3098 const mainVisible = main && !main.classList.contains('hidden');
3099 const notesTab = getActiveHubMainTab() === 'notes';
3100 const q = searchQuery && String(searchQuery.value).trim();
3101 const show =
3102 Boolean(mainVisible && token) &&
3103 readOnboardingDismissedSync() &&
3104 hubBrowseListEmptyUnfiltered &&
3105 notesTab &&
3106 !q &&
3107 !isSearchResultsView();
3108 strip.classList.toggle('hidden', !show);
3109 }
3110
3111 async function loadNotes() {
3112 const q = new URLSearchParams();
3113 q.set('limit', '100');
3114 if (filterFolder.value) q.set('folder', filterFolder.value);
3115 if (filterProject.value) q.set('project', filterProject.value);
3116 if (filterTag.value) q.set('tag', filterTag.value);
3117 if (filterSince && filterSince.value) q.set('since', filterSince.value);
3118 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
3119 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
3120 // Phase 12 — blockchain filters
3121 const networkVal = filterNetwork ? filterNetwork.value : '';
3122 const walletVal = filterWallet ? filterWallet.value : '';
3123 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
3124 if (networkVal) q.set('network', networkVal);
3125 if (walletVal) q.set('wallet_address', walletVal);
3126 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
3127 notesList.innerHTML = loadingHtml;
3128 notesTotal.textContent = '';
3129 try {
3130 const out = await api('/api/v1/notes?' + q.toString());
3131 let notes = (out.notes || []).map(normalizeHubListItem);
3132 notes = applyVaultListFilters(notes, {
3133 folder: filterFolder.value,
3134 project: filterProject.value,
3135 tag: filterTag.value,
3136 since: filterSince?.value || '',
3137 until: filterUntil?.value || '',
3138 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
3139 network: networkVal,
3140 wallet_address: walletVal,
3141 payment_status: paymentStatusVal,
3142 });
3143 notes = applySortedNotesClient(notes);
3144 const totalCount = notes.length;
3145 notes = notes.slice(0, 100);
3146 if (notes.length === 0) {
3147 notesList.innerHTML =
3148 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
3149 const ea = el('empty-add');
3150 if (ea) ea.onclick = () => openCreateModal();
3151 notesTotal.textContent = 'Total: 0';
3152 } else {
3153 notesList.innerHTML = notes.map(renderNoteRow).join('');
3154 notesTotal.textContent = 'Total: ' + totalCount;
3155 bindNoteClicks(notesList);
3156 listSelectedIndex = 0;
3157 updateListSelection();
3158 }
3159 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
3160 updateEmptyVaultStripVisibility();
3161 } catch (e) {
3162 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3163 notesTotal.textContent = '';
3164 hubBrowseListEmptyUnfiltered = false;
3165 updateEmptyVaultStripVisibility();
3166 }
3167 }
3168
3169 function switchHubMainTab(name) {
3170 closeHubMoreSheet();
3171 document.querySelectorAll('[data-tab].tab').forEach((t) => {
3172 t.classList.toggle('active', t.dataset.tab === name);
3173 });
3174 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3175 syncHubListSortUI(name);
3176 refreshNewProposalTabVisibility();
3177 const panel = el(
3178 'tab-' +
3179 (name === 'notes'
3180 ? 'notes'
3181 : name === 'activity'
3182 ? 'activity'
3183 : name === 'suggested'
3184 ? 'suggested'
3185 : 'problem'),
3186 );
3187 if (panel) panel.classList.remove('hidden');
3188 if (name === 'notes') {
3189 const graphPanel = el('notes-view-graph');
3190 if (graphPanel && !graphPanel.classList.contains('hidden')) {
3191 switchNotesView('list');
3192 } else {
3193 syncHubRailChrome(name);
3194 syncModeToolbars(name);
3195 }
3196 loadNotes();
3197 updateNeedsYouBanner(hubReviewBadgePrevCount);
3198 } else {
3199 syncHubRailChrome(name);
3200 syncModeToolbars(name);
3201 if (name === 'activity') loadActivity();
3202 if (name === 'suggested' || name === 'problem') loadProposals();
3203 updateEmptyVaultStripVisibility();
3204 updateNeedsYouBanner(hubReviewBadgePrevCount);
3205 }
3206 }
3207
3208 function updateListSelection() {
3209 const container = notesList;
3210 const items = container.querySelectorAll('.list-item');
3211 if (items.length === 0) { listSelectedIndex = 0; return; }
3212 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
3213 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
3214 const sel = items[listSelectedIndex];
3215 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3216 }
3217
3218 btnApplyFilters.onclick = () => {
3219 switchNotesView('list');
3220 loadNotes();
3221 renderFilterChips(null);
3222 syncVaultAdvancedFiltersOpen();
3223 };
3224
3225 if (filterContentScope) {
3226 filterContentScope.addEventListener('change', () => {
3227 switchNotesView('list');
3228 loadNotes();
3229 renderFilterChips(null);
3230 });
3231 }
3232
3233 function formatSearchScopeSummary() {
3234 const parts = [];
3235 if (filterProject.value) parts.push('project: ' + filterProject.value);
3236 if (filterTag.value) parts.push('tag: ' + filterTag.value);
3237 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
3238 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
3239 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
3240 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
3241 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
3242 return parts.length ? parts.join(' · ') : '';
3243 }
3244
3245 function semanticMatchStrengthLabel(score) {
3246 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3247 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3248 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
3249 }
3250
3251 function keywordMatchStrengthLabel(score) {
3252 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3253 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3254 return 'Keyword match ~' + pct + '% (text overlap)';
3255 }
3256
3257 if (btnClearSearch) {
3258 btnClearSearch.onclick = () => {
3259 searchQuery.value = '';
3260 clearListFacetFilters();
3261 switchNotesView('list');
3262 switchHubMainTab('notes');
3263 renderFilterChips(null);
3264 const adv = el('hub-search-advanced');
3265 if (adv && !hasActiveNoteListFilters()) adv.open = false;
3266 };
3267 }
3268
3269 function showToast(message, isError = false) {
3270 const toast = document.createElement('div');
3271 toast.className = 'toast' + (isError ? ' toast-err' : '');
3272 toast.textContent = message;
3273 toast.setAttribute('role', 'status');
3274 document.body.appendChild(toast);
3275 requestAnimationFrame(() => toast.classList.add('toast-show'));
3276 setTimeout(() => {
3277 toast.classList.remove('toast-show');
3278 setTimeout(() => toast.remove(), 300);
3279 }, 3000);
3280 }
3281
3282 const proposalFilterApply = el('proposal-filter-apply');
3283 if (proposalFilterApply) {
3284 proposalFilterApply.onclick = () => {
3285 loadProposals();
3286 loadActivity();
3287 syncPendingEvalQuickChip();
3288 };
3289 }
3290 const proposalFilterClear = el('proposal-filter-clear');
3291 if (proposalFilterClear) {
3292 proposalFilterClear.onclick = () => {
3293 const lf = el('proposal-filter-label');
3294 const sf = el('proposal-filter-source');
3295 const pf = el('proposal-filter-path-prefix');
3296 const pe = el('proposal-filter-pending-eval');
3297 const rq = el('proposal-filter-review-queue');
3298 const rs = el('proposal-filter-review-severity');
3299 if (lf) lf.value = '';
3300 if (sf) sf.value = '';
3301 if (pf) pf.value = '';
3302 if (pe) pe.checked = false;
3303 if (rq) rq.value = '';
3304 if (rs) rs.value = '';
3305 loadProposals();
3306 loadActivity();
3307 syncPendingEvalQuickChip();
3308 };
3309 }
3310 const pendingEvalChip = el('proposal-pending-eval-chip');
3311 if (pendingEvalChip) {
3312 pendingEvalChip.onclick = () => {
3313 const pe = el('proposal-filter-pending-eval');
3314 if (!pe) return;
3315 pe.checked = !pe.checked;
3316 syncPendingEvalQuickChip();
3317 loadProposals();
3318 loadActivity();
3319 };
3320 }
3321
3322 const hubListSortEl = hubListSortGetSelect();
3323 if (hubListSortEl) {
3324 hubListSortEl.addEventListener('change', () => {
3325 const tab = getActiveHubMainTab();
3326 try {
3327 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3328 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3329 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3330 }
3331 } catch (_) {}
3332 if (tab === 'notes') loadNotes();
3333 else if (tab === 'activity') loadActivity();
3334 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3335 });
3336 }
3337
3338 if (btnReindex) {
3339 btnReindex.onclick = async () => {
3340 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3341 try {
3342 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3343 // (see api() helper). Bridge may return one of three shapes:
3344 // 200 {ok:true, ...} → sync completed
3345 // 202 {status:'background', ...} → routed to bridge-index-background fn
3346 // 409 {status:'already_running'} → another background job in flight
3347 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3348 if (out && out.status === 'background') {
3349 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3350 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3351 } else if (out && out.status === 'already_running') {
3352 showToast(out.message || 'A background re-index is already running for this vault.');
3353 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3354 } else {
3355 const n = out.notesProcessed ?? 0;
3356 const c = out.chunksIndexed ?? 0;
3357 const skipped = out.chunksSkippedCached ?? 0;
3358 const embedded = out.chunksEmbedded ?? c;
3359 const detail = skipped > 0
3360 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3361 : '';
3362 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3363 hubClearSemanticIndexStale();
3364 loadFacets();
3365 loadNotes();
3366 hubLoadIndexStatus().catch(() => {});
3367 }
3368 } catch (e) {
3369 showToast(e.message || 'Re-index failed', true);
3370 }
3371 });
3372 };
3373 }
3374
3375 /*
3376 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3377 * Reads from `GET /api/v1/index/status` which both sync and background paths
3378 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3379 * background job is in flight so the line flips from
3380 * "Re-indexing in background…" → "Last indexed: just now"
3381 * without the user needing to click anything.
3382 */
3383 let _hubIndexStatusPollTimer = null;
3384 function hubFormatRelativeTime(epochMs) {
3385 if (!Number.isFinite(epochMs)) return '';
3386 const ageMs = Date.now() - epochMs;
3387 if (ageMs < 0) return 'just now';
3388 const sec = Math.round(ageMs / 1000);
3389 if (sec < 45) return 'just now';
3390 const min = Math.round(sec / 60);
3391 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3392 const hr = Math.round(min / 60);
3393 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3394 const days = Math.round(hr / 24);
3395 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3396 }
3397 async function hubLoadIndexStatus(opts) {
3398 opts = opts || {};
3399 const el = document.getElementById('hub-index-status');
3400 if (!el) return;
3401 let status;
3402 try {
3403 status = await api('/api/v1/index/status', { method: 'GET' });
3404 } catch (_) {
3405 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3406 el.textContent = '';
3407 el.classList.remove('hub-index-status-running');
3408 return;
3409 }
3410 if (status && status.inProgress) {
3411 el.textContent = 'Re-indexing in background…';
3412 el.classList.add('hub-index-status-running');
3413 // Keep polling so the line auto-clears when the background job finishes.
3414 // 5-second cadence matches typical embedding batch completion granularity
3415 // and stays well under any sane rate limit.
3416 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3417 _hubIndexStatusPollTimer = setInterval(() => {
3418 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3419 }, 5000);
3420 }
3421 return;
3422 }
3423 // No in-flight job — stop polling if we were.
3424 if (_hubIndexStatusPollTimer != null) {
3425 clearInterval(_hubIndexStatusPollTimer);
3426 _hubIndexStatusPollTimer = null;
3427 }
3428 el.classList.remove('hub-index-status-running');
3429 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3430 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3431 el.textContent = 'Last indexed: ' + rel;
3432 el.title =
3433 'Last successful index: ' +
3434 (status.lastIndexed.lastIndexedAt || '') +
3435 ' · ' +
3436 (status.lastIndexed.chunksIndexed || 0) +
3437 ' chunks · mode: ' +
3438 (status.lastIndexed.mode || 'sync');
3439 } else {
3440 el.textContent = '';
3441 el.title = '';
3442 }
3443 }
3444 // Kick off an initial status load once the user is logged in (the API call
3445 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3446 // that already happens after token validation succeeds.
3447 hubLoadIndexStatus().catch(() => {});
3448
3449 const hubIndexStaleRun = el('hub-index-stale-run');
3450 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3451 if (hubIndexStaleRun && btnReindex) {
3452 hubIndexStaleRun.onclick = () => {
3453 btnReindex.click();
3454 };
3455 }
3456 if (hubIndexStaleDismiss) {
3457 hubIndexStaleDismiss.onclick = () => {
3458 hubClearSemanticIndexStale();
3459 };
3460 }
3461
3462 function proposalFilterQuerySuffix() {
3463 const params = [];
3464 const lab = el('proposal-filter-label');
3465 const src = el('proposal-filter-source');
3466 const pre = el('proposal-filter-path-prefix');
3467 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3468 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3469 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3470 const pe = el('proposal-filter-pending-eval');
3471 if (pe && pe.checked) params.push('evaluation_status=pending');
3472 const rq = el('proposal-filter-review-queue');
3473 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3474 const rs = el('proposal-filter-review-severity');
3475 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3476 return params.length ? '&' + params.join('&') : '';
3477 }
3478
3479 // Discard a proposal directly from the list without opening the detail panel.
3480 async function discardProposalInline(id, itemEl) {
3481 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3482 try {
3483 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3484 if (typeof showToast === 'function') showToast('Proposal discarded.');
3485 const panel = el('detail-panel');
3486 if (panel && !panel.classList.contains('hidden')) {
3487 hideDetailPanelChrome();
3488 }
3489 loadProposals();
3490 loadActivity();
3491 } catch (err) {
3492 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3493 }
3494 }
3495
3496 async function loadProposals() {
3497 void refreshReviewBadge();
3498 syncPendingEvalQuickChip();
3499 const SI = hubShellIa();
3500 const primaryCta =
3501 SI && typeof SI.emptyReviewPrimaryCtaLabel === 'function'
3502 ? SI.emptyReviewPrimaryCtaLabel()
3503 : 'New proposal';
3504 const secondaryCta =
3505 SI && typeof SI.emptyReviewSecondaryCtaLabel === 'function'
3506 ? SI.emptyReviewSecondaryCtaLabel()
3507 : 'How Review works';
3508 const canCreate = hubUserCanWriteNotes();
3509 const emptySuggested =
3510 '<div class="empty-state empty-state-suggested">' +
3511 '<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>' +
3512 '<p class="empty-state-suggested-actions">' +
3513 (canCreate
3514 ? '<button type="button" class="btn-primary" id="empty-suggested-new">' +
3515 escapeHtml(primaryCta) +
3516 '</button>'
3517 : '') +
3518 '<button type="button" class="btn-secondary" id="empty-suggested-how-to">' +
3519 escapeHtml(secondaryCta) +
3520 '</button>' +
3521 '</p>' +
3522 '</div>';
3523 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3524 const fq = proposalFilterQuerySuffix();
3525 [
3526 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3527 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3528 ].forEach(({ kind, status, empty: emptyHtml }) => {
3529 const container = el('proposals-' + kind);
3530 if (!container) return;
3531 container.innerHTML = loadingHtml;
3532 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3533 .then((out) => {
3534 let list = out.proposals || [];
3535 list = applySortedProposalsClient(list);
3536 if (list.length === 0) {
3537 container.innerHTML = emptyHtml;
3538 if (kind === 'suggested') {
3539 proposalListIds = [];
3540 clearReviewSplitPosition();
3541 const how = container.querySelector('#empty-suggested-how-to');
3542 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3543 const neu = container.querySelector('#empty-suggested-new');
3544 if (neu) neu.onclick = () => openCreateProposalModal({});
3545 const peChip = el('proposal-pending-eval-chip');
3546 if (peChip && !peChip.classList.contains('hidden')) {
3547 // chip remains available above empty state when policy requires eval
3548 }
3549 }
3550 return;
3551 }
3552 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3553 if (kind === 'suggested') {
3554 proposalListIds = list.map((p) => String(p.proposal_id));
3555 proposalListSelectedIndex = 0;
3556 }
3557 container.innerHTML = list
3558 .map((p) => {
3559 const srcChip = p.source
3560 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3561 : '';
3562 const pendingChip =
3563 SI && typeof SI.reviewRowNeedsPendingEvalChip === 'function'
3564 ? SI.reviewRowNeedsPendingEvalChip(p.evaluation_status)
3565 : String(p.evaluation_status || '').toLowerCase() === 'pending';
3566 const pendingHtml = pendingChip
3567 ? '<span class="proposal-chip proposal-chip-pending-eval">Pending eval</span>'
3568 : '';
3569 const rel =
3570 SI && typeof SI.formatRelativeTime === 'function'
3571 ? SI.formatRelativeTime(p.updated_at || p.created_at)
3572 : '';
3573 const timeHtml = rel
3574 ? '<span class="row-time">' + escapeHtml(rel) + '</span>'
3575 : p.updated_at
3576 ? '<span class="row-time">' +
3577 escapeHtml(calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) +
3578 '</span>'
3579 : '';
3580 const discardBtn = canDiscard
3581 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3582 : '';
3583 return (
3584 '<div class="list-item review-row" data-id="' +
3585 escapeHtml(p.proposal_id) +
3586 '"><span class="row-title">' +
3587 escapeHtml(p.path) +
3588 '</span><div class="row-meta">' +
3589 srcChip +
3590 pendingHtml +
3591 timeHtml +
3592 '</div>' +
3593 discardBtn +
3594 '</div>'
3595 );
3596 })
3597 .join('');
3598 container.querySelectorAll('.list-item').forEach((item, idx) => {
3599 item.onclick = () => {
3600 proposalListSelectedIndex = idx;
3601 updateProposalListSelection(container);
3602 if (kind === 'suggested') {
3603 setReviewSplitPosition(idx + 1, list.length);
3604 }
3605 openProposal(item.dataset.id);
3606 };
3607 const db = item.querySelector('.list-item-delete');
3608 if (db) {
3609 db.onclick = (e) => {
3610 e.stopPropagation();
3611 discardProposalInline(item.dataset.id, item);
3612 };
3613 }
3614 });
3615 if (kind === 'suggested') updateProposalListSelection(container);
3616 })
3617 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3618 });
3619 }
3620
3621 async function loadActivity() {
3622 const container = el('proposals-activity');
3623 if (!container) return;
3624 container.innerHTML = loadingHtml;
3625 try {
3626 const fq = proposalFilterQuerySuffix();
3627 const out = await api('/api/v1/proposals?limit=100' + fq);
3628 let list = out.proposals || [];
3629 list = applySortedProposalsClient(list);
3630 if (list.length === 0) {
3631 container.innerHTML =
3632 '<div class="empty-state empty-state-activity">' +
3633 '<p>No proposal activity yet.</p>' +
3634 '<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>' +
3635 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Review</button></p>' +
3636 '</div>';
3637 const go = container.querySelector('#empty-activity-goto-suggested');
3638 if (go) go.onclick = () => switchHubMainTab('suggested');
3639 return;
3640 }
3641 const canDiscard = hubUserCanWriteNotes();
3642 container.innerHTML = list
3643 .map((p) => {
3644 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3645 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3646 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3647 // approved records stay as-is unless the user opens them.
3648 const showDiscard = canDiscard && p.status !== 'approved';
3649 const discardBtn = showDiscard
3650 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3651 : '';
3652 return (
3653 '<div class="list-item activity-item ' +
3654 statusClass +
3655 '" data-id="' +
3656 escapeHtml(p.proposal_id) +
3657 '"><span class="row-title">' +
3658 escapeHtml(p.path) +
3659 '</span><div class="status">' +
3660 escapeHtml(p.status) +
3661 ' · ' +
3662 escapeHtml(date) +
3663 '</div>' + discardBtn + '</div>'
3664 );
3665 })
3666 .join('');
3667 container.querySelectorAll('.list-item').forEach((item) => {
3668 item.onclick = () => openProposal(item.dataset.id);
3669 const db = item.querySelector('.list-item-delete');
3670 if (db) {
3671 db.onclick = (e) => {
3672 e.stopPropagation();
3673 discardProposalInline(item.dataset.id, item);
3674 };
3675 }
3676 });
3677 } catch (e) {
3678 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3679 }
3680 }
3681
3682 async function runVaultSearch() {
3683 const query = searchQuery.value.trim();
3684 if (!query) return;
3685 hubBrowseListEmptyUnfiltered = false;
3686 updateEmptyVaultStripVisibility();
3687 const activeMainTab = getActiveHubMainTab();
3688 const useKeyword = searchMode && searchMode.value === 'keyword';
3689 if (activeMainTab && activeMainTab !== 'notes') {
3690 showToast(useKeyword ? 'Keyword results are shown under Vault.' : 'Semantic results are shown under Vault.');
3691 }
3692 switchNotesView('list');
3693 document.querySelectorAll('[data-tab].tab').forEach((t) => {
3694 t.classList.toggle('active', t.dataset.tab === 'notes');
3695 });
3696 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3697 const tabNotes = el('tab-notes');
3698 if (tabNotes) tabNotes.classList.remove('hidden');
3699 setProposalFiltersBarVisible(false);
3700 refreshNewProposalTabVisibility();
3701 syncHubRailChrome('notes');
3702 syncModeToolbars('notes');
3703 syncHubListSortUI('notes');
3704 notesList.innerHTML = loadingHtml;
3705 notesTotal.textContent = '';
3706 const scopeSummary = formatSearchScopeSummary();
3707 const scopeSuffix = scopeSummary
3708 ? ' · scope: ' + scopeSummary
3709 : ' · scope: entire vault (use dropdowns to narrow)';
3710 try {
3711 const body = { query, limit: 20 };
3712 if (useKeyword) body.mode = 'keyword';
3713 if (filterProject.value) body.project = filterProject.value;
3714 if (filterTag.value) body.tag = filterTag.value;
3715 if (filterFolder.value) body.folder = filterFolder.value;
3716 if (filterSince && filterSince.value) body.since = filterSince.value;
3717 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
3718 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
3719 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
3720 const results = out.results || [];
3721 if (results.length === 0) {
3722 notesList.innerHTML = useKeyword
3723 ? '<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>'
3724 : '<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>';
3725 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
3726 return;
3727 }
3728 notesList.innerHTML = results
3729 .map((r) => {
3730 const chips = [];
3731 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
3732 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3733 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
3734 const pathStr = String(r.path || '').replace(/\\/g, '/');
3735 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
3736 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3737 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3738 return (
3739 '<div class="' +
3740 rowClass +
3741 '" data-path="' +
3742 escapeHtml(r.path) +
3743 '"><span class="row-title">' +
3744 escapeHtml(r.path) +
3745 badge +
3746 '</span><div class="row-chips">' +
3747 chips.join('') +
3748 '</div>' +
3749 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
3750 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
3751 '</div>'
3752 );
3753 })
3754 .join('');
3755 notesTotal.textContent =
3756 results.length +
3757 (useKeyword ? ' keyword' : ' semantic') +
3758 ' result' +
3759 (results.length === 1 ? '' : 's') +
3760 scopeSuffix;
3761 bindNoteClicks(notesList);
3762 listSelectedIndex = 0;
3763 updateListSelection();
3764 } catch (e) {
3765 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3766 notesTotal.textContent = '';
3767 }
3768 }
3769
3770 btnSearch.onclick = () => {
3771 void runVaultSearch();
3772 };
3773
3774 searchQuery.addEventListener('keydown', (e) => {
3775 if (e.key === 'Enter') {
3776 e.preventDefault();
3777 void runVaultSearch();
3778 }
3779 });
3780 searchQuery.addEventListener('input', () => {
3781 updateEmptyVaultStripVisibility();
3782 });
3783
3784 function switchNotesView(view) {
3785 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
3786 el('notes-view-list').classList.toggle('hidden', view !== 'list');
3787 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
3788 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
3789 if (view === 'calendar') renderCalendar();
3790 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
3791 syncHubRailChrome(getActiveHubMainTab());
3792 syncModeToolbars(getActiveHubMainTab());
3793 }
3794
3795 document.querySelectorAll('.view-tab').forEach((t) => {
3796 t.onclick = () => switchNotesView(t.dataset.view);
3797 });
3798
3799 function ymd(d) {
3800 const y = d.getFullYear();
3801 const m = String(d.getMonth() + 1).padStart(2, '0');
3802 const day = String(d.getDate()).padStart(2, '0');
3803 return y + '-' + m + '-' + day;
3804 }
3805
3806 async function renderCalendar() {
3807 const grid = el('calendar-grid');
3808 const title = el('cal-title');
3809 const dayList = el('calendar-day-list');
3810 const dayNotes = el('calendar-day-notes');
3811 dayList.classList.add('hidden');
3812 grid.classList.remove('hidden');
3813 el('calendar-nav').classList.remove('hidden');
3814
3815 const y = calendarMonth.getFullYear();
3816 const m = calendarMonth.getMonth();
3817 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
3818
3819 grid.innerHTML = loadingHtml;
3820 const first = new Date(y, m, 1);
3821 const last = new Date(y, m + 1, 0);
3822 const since = ymd(first);
3823 const until = ymd(last);
3824
3825 let notesInMonth = [];
3826 try {
3827 const q = new URLSearchParams({ since, until, limit: '100' });
3828 const out = await api('/api/v1/notes?' + q.toString());
3829 notesInMonth = (out.notes || [])
3830 .map(normalizeHubListItem)
3831 .filter((n) => {
3832 const ds = noteSortOrCalendarDay(n);
3833 return ds >= since && ds <= until;
3834 });
3835 } catch (_) {
3836 notesInMonth = [];
3837 }
3838
3839 const byDay = {};
3840 notesInMonth.forEach((n) => {
3841 const ds = noteSortOrCalendarDay(n);
3842 if (ds >= since && ds <= until) {
3843 byDay[ds] = (byDay[ds] || 0) + 1;
3844 }
3845 });
3846
3847 const startPad = first.getDay();
3848 const daysInMonth = last.getDate();
3849 const cells = [];
3850 const prevLast = new Date(y, m, 0).getDate();
3851 for (let i = 0; i < startPad; i++) {
3852 const d = prevLast - startPad + i + 1;
3853 cells.push({ out: true, day: d, key: null });
3854 }
3855 for (let d = 1; d <= daysInMonth; d++) {
3856 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
3857 }
3858 let nextMonthDay = 1;
3859 while (cells.length % 7 !== 0 || cells.length < 42) {
3860 cells.push({ out: true, day: nextMonthDay++, key: null });
3861 }
3862
3863 const today = ymd(new Date());
3864 grid.innerHTML = cells
3865 .map((c) => {
3866 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
3867 const cnt = byDay[c.key] || 0;
3868 const isToday = c.key === today;
3869 return (
3870 '<div class="cal-cell' +
3871 (isToday ? ' today' : '') +
3872 '" data-day="' +
3873 escapeHtml(c.key) +
3874 '"><span class="cal-day-num">' +
3875 c.day +
3876 '</span>' +
3877 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
3878 '</div>'
3879 );
3880 })
3881 .join('');
3882
3883 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
3884 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
3885 });
3886 }
3887
3888 el('cal-prev').onclick = () => {
3889 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
3890 renderCalendar();
3891 };
3892 el('cal-next').onclick = () => {
3893 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
3894 renderCalendar();
3895 };
3896 el('cal-back').onclick = () => {
3897 el('calendar-day-list').classList.add('hidden');
3898 el('calendar-grid').classList.remove('hidden');
3899 el('calendar-nav').classList.remove('hidden');
3900 };
3901
3902 function showCalendarDay(dayKey, notesInMonth) {
3903 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
3904 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
3905 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
3906 bindNoteClicks(el('calendar-day-notes'));
3907 el('calendar-grid').classList.add('hidden');
3908 el('calendar-nav').classList.add('hidden');
3909 el('calendar-day-list').classList.remove('hidden');
3910 }
3911
3912 async function fetchNotesForDashboard() {
3913 const all = [];
3914 let offset = 0;
3915 const limit = 100;
3916 let total = Infinity;
3917 while (offset < 500 && all.length < total) {
3918 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
3919 total = out.total ?? 0;
3920 const batch = (out.notes || []).map(normalizeHubListItem);
3921 all.push(...batch);
3922 if (batch.length < limit) break;
3923 offset += limit;
3924 }
3925 return { notes: all, total };
3926 }
3927
3928 async function renderDashboard() {
3929 chartInstances.forEach((c) => c.destroy());
3930 chartInstances = [];
3931 const cards = el('dashboard-cards');
3932 const foot = el('dashboard-footnote');
3933 cards.innerHTML = loadingHtml;
3934 foot.textContent = '';
3935
3936 let notes, total;
3937 try {
3938 const r = await fetchNotesForDashboard();
3939 notes = r.notes;
3940 total = r.total;
3941 } catch (e) {
3942 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3943 return;
3944 }
3945
3946 const weekAgo = new Date();
3947 weekAgo.setDate(weekAgo.getDate() - 7);
3948 const weekStr = ymd(weekAgo);
3949 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
3950
3951 const byProject = {};
3952 const byTag = {};
3953 const byWeek = {};
3954 notes.forEach((n) => {
3955 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
3956 (n.tags || []).forEach((t) => {
3957 byTag[t] = (byTag[t] || 0) + 1;
3958 });
3959 const ds = noteSortOrCalendarDay(n);
3960 if (ds) {
3961 const w = ds.slice(0, 7);
3962 byWeek[w] = (byWeek[w] || 0) + 1;
3963 }
3964 });
3965
3966 const topProjects = Object.entries(byProject)
3967 .sort((a, b) => b[1] - a[1])
3968 .slice(0, 8);
3969 const topTags = Object.entries(byTag)
3970 .sort((a, b) => b[1] - a[1])
3971 .slice(0, 8);
3972 const weeks = Object.keys(byWeek).sort();
3973
3974 cards.innerHTML =
3975 '<div class="dash-card"><div class="dash-value">' +
3976 total +
3977 '</div><div class="dash-label">Notes (indexed)</div></div>' +
3978 '<div class="dash-card"><div class="dash-value">' +
3979 thisWeek +
3980 '</div><div class="dash-label">Last 7 days</div></div>' +
3981 '<div class="dash-card"><div class="dash-value">' +
3982 Object.keys(byProject).length +
3983 '</div><div class="dash-label">Projects</div></div>' +
3984 '<div class="dash-card"><div class="dash-value">' +
3985 Object.keys(byTag).length +
3986 '</div><div class="dash-label">Tags</div></div>';
3987
3988 if (notes.length < total) {
3989 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
3990 }
3991
3992 if (typeof Chart === 'undefined') {
3993 foot.textContent += ' Chart.js failed to load.';
3994 return;
3995 }
3996
3997 const commonOpts = {
3998 responsive: true,
3999 maintainAspectRatio: false,
4000 plugins: { legend: { labels: { color: '#a1a1a1' } } },
4001 scales: {
4002 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4003 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4004 },
4005 };
4006
4007 const ctxP = el('chart-projects').getContext('2d');
4008 chartInstances.push(
4009 new Chart(ctxP, {
4010 type: 'bar',
4011 data: {
4012 labels: topProjects.map((x) => x[0]),
4013 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
4014 },
4015 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
4016 })
4017 );
4018
4019 const ctxT = el('chart-tags').getContext('2d');
4020 chartInstances.push(
4021 new Chart(ctxT, {
4022 type: 'doughnut',
4023 data: {
4024 labels: topTags.map((x) => x[0]),
4025 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
4026 },
4027 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
4028 })
4029 );
4030
4031 const ctxL = el('chart-timeline').getContext('2d');
4032 chartInstances.push(
4033 new Chart(ctxL, {
4034 type: 'line',
4035 data: {
4036 labels: weeks,
4037 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 }],
4038 },
4039 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
4040 })
4041 );
4042 }
4043
4044 function resetDuplicateCreateState() {
4045 pendingDuplicateDeleteSource = null;
4046 const ban = el('duplicate-source-banner');
4047 if (ban) ban.classList.add('hidden');
4048 const chk = el('duplicate-delete-after-save');
4049 if (chk) chk.checked = false;
4050 const mt = el('modal-create-title');
4051 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
4052 const fs = el('btn-full-save');
4053 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
4054 }
4055
4056 function openCreateModal() {
4057 resetDuplicateCreateState();
4058 closeCreateProposalModal();
4059 closeFullCreateSimilarModal();
4060 hideDetailPanelChrome();
4061 el('modal-create').classList.remove('hidden');
4062 el('create-msg-quick').textContent = '';
4063 el('create-msg-quick').className = 'create-msg';
4064 el('create-msg-full').textContent = '';
4065 el('create-msg-full').className = 'create-msg';
4066 fullCreateSimilarOverrideOnce = false;
4067 if (token) {
4068 void (async () => {
4069 await refreshFullPathFolderSelect();
4070 if (!lastHubFacets) {
4071 try {
4072 lastHubFacets = await fetchFacetsResolved();
4073 } catch (_) {}
4074 }
4075 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4076 })();
4077 }
4078 }
4079
4080 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
4081 function suggestDuplicateVaultPath(srcPath) {
4082 const t = String(srcPath || '')
4083 .replace(/\\/g, '/')
4084 .trim();
4085 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
4086 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
4087 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
4088 }
4089
4090 function tagsInputFromFrontmatter(tagsVal) {
4091 if (tagsVal == null) return '';
4092 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
4093 return String(tagsVal).trim();
4094 }
4095
4096 /**
4097 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
4098 * Optional checkbox deletes the source path after a successful save (different path only).
4099 */
4100 async function openDuplicateNoteModal() {
4101 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
4102 if (!token) {
4103 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
4104 return;
4105 }
4106 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
4107 closeCreateProposalModal();
4108 closeFullCreateSimilarModal();
4109 el('modal-create').classList.remove('hidden');
4110 el('create-msg-quick').textContent = '';
4111 el('create-msg-quick').className = 'create-msg';
4112 el('create-msg-full').textContent = '';
4113 el('create-msg-full').className = 'create-msg';
4114 fullCreateSimilarOverrideOnce = false;
4115 const mt = el('modal-create-title');
4116 if (mt) mt.textContent = 'Duplicate note';
4117 const fs = el('btn-full-save');
4118 if (fs) fs.textContent = 'Save duplicate';
4119 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
4120 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
4121 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
4122 if (tabFull) tabFull.classList.add('active');
4123 if (tabQuick) tabQuick.classList.remove('active');
4124 el('create-quick').classList.add('hidden');
4125 el('create-full').classList.remove('hidden');
4126 if (token) {
4127 try {
4128 await refreshFullPathFolderSelect();
4129 if (!lastHubFacets) {
4130 try {
4131 lastHubFacets = await fetchFacetsResolved();
4132 } catch (_) {}
4133 }
4134 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4135 } catch (_) {}
4136 }
4137 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
4138 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
4139 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
4140 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
4141 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
4142 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
4143 if (el('full-entity')) {
4144 const ent = fm.entity;
4145 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
4146 }
4147 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
4148 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
4149 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
4150 if (el('full-path')) {
4151 el('full-path').value = sug;
4152 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
4153 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
4154 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
4155 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
4156 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
4157 }
4158 const dsp = el('duplicate-source-path');
4159 if (dsp) dsp.textContent = currentOpenNote.path;
4160 const ban = el('duplicate-source-banner');
4161 if (ban) ban.classList.remove('hidden');
4162 const chk = el('duplicate-delete-after-save');
4163 if (chk) chk.checked = false;
4164 }
4165
4166 function closeCreateModal() {
4167 closeFullCreateSimilarModal();
4168 resetDuplicateCreateState();
4169 el('modal-create').classList.add('hidden');
4170 }
4171 function closeCreateProposalModal() {
4172 const m = el('modal-create-proposal');
4173 if (m) m.classList.add('hidden');
4174 const pathInput = el('proposal-create-path');
4175 if (pathInput) pathInput.readOnly = false;
4176 }
4177 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
4178 function openCreateProposalModal(opts) {
4179 if (!token) {
4180 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
4181 return;
4182 }
4183 if (!hubUserCanWriteNotes()) {
4184 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
4185 return;
4186 }
4187 closeCreateModal();
4188 closeImportModal();
4189 hideDetailPanelChrome();
4190 const modal = el('modal-create-proposal');
4191 const pathInput = el('proposal-create-path');
4192 const hint = el('modal-create-proposal-hint');
4193 const bodyEl = el('proposal-create-body');
4194 const intentEl = el('proposal-create-intent');
4195 const msgEl = el('proposal-create-msg');
4196 if (!modal || !pathInput || !bodyEl || !intentEl) return;
4197 if (opts && opts.fromNote) {
4198 pathInput.readOnly = true;
4199 pathInput.value = opts.path || '';
4200 if (hint)
4201 hint.textContent =
4202 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
4203 } else {
4204 pathInput.readOnly = false;
4205 pathInput.value = (opts && opts.path) || '';
4206 if (hint)
4207 hint.textContent =
4208 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in Review.';
4209 }
4210 bodyEl.value = (opts && opts.body) || '';
4211 intentEl.value = (opts && opts.intent) || '';
4212 if (msgEl) {
4213 msgEl.textContent = '';
4214 msgEl.className = 'create-msg';
4215 }
4216 modal.classList.remove('hidden');
4217 }
4218 btnNewNote.onclick = openCreateModal;
4219 el('modal-create-backdrop').onclick = closeCreateModal;
4220 el('modal-create-close').onclick = closeCreateModal;
4221
4222 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
4223 const modalCreateProposalClose = el('modal-create-proposal-close');
4224 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
4225 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
4226
4227 const btnNewProposal = el('btn-new-proposal');
4228 if (btnNewProposal) {
4229 btnNewProposal.onclick = () => openCreateProposalModal({});
4230 }
4231
4232 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
4233 if (btnProposalCreateSubmit) {
4234 btnProposalCreateSubmit.onclick = async () => {
4235 const pathInput = el('proposal-create-path');
4236 const bodyInput = el('proposal-create-body');
4237 const intentInput = el('proposal-create-intent');
4238 const msgEl = el('proposal-create-msg');
4239 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
4240 if (!rawPath) {
4241 if (msgEl) {
4242 msgEl.textContent = 'Path is required.';
4243 msgEl.className = 'create-msg err';
4244 }
4245 return;
4246 }
4247 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
4248 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
4249 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
4250 try {
4251 await api('/api/v1/proposals', {
4252 method: 'POST',
4253 body: JSON.stringify({
4254 path: rawPath,
4255 body,
4256 ...(intent ? { intent } : {}),
4257 source: 'hub_ui',
4258 }),
4259 });
4260 closeCreateProposalModal();
4261 if (typeof showToast === 'function') showToast('Proposal submitted');
4262 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
4263 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
4264 const suggestedTab = document.querySelector('[data-tab="suggested"]');
4265 const suggestedPanel = el('tab-suggested');
4266 if (suggestedTab) suggestedTab.classList.add('active');
4267 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
4268 syncHubListSortUI('suggested');
4269 syncModeToolbars('suggested');
4270 refreshNewProposalTabVisibility();
4271 loadProposals();
4272 } catch (e) {
4273 if (msgEl) {
4274 msgEl.textContent = e.message || 'Proposal failed';
4275 msgEl.className = 'create-msg err';
4276 }
4277 }
4278 });
4279 };
4280 }
4281
4282 function syncImportSheetsBlock() {
4283 const sel = el('import-source-type');
4284 const block = el('import-sheets-block');
4285 if (block && sel) block.hidden = sel.value !== 'google-sheets';
4286 }
4287
4288 function openImportModal(preselectSourceType) {
4289 if (!token) {
4290 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
4291 return;
4292 }
4293 closeCreateModal();
4294 closeCreateProposalModal();
4295 hideDetailPanelChrome();
4296 el('modal-import').classList.remove('hidden');
4297 el('import-msg').textContent = '';
4298 if (importFileEl) importFileEl.value = '';
4299 if (importFileFolderEl) importFileFolderEl.value = '';
4300 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4301 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4302 setImportBatchAria('');
4303 clearImportDropPending();
4304 const urlIn = el('import-url');
4305 if (urlIn) urlIn.value = '';
4306 const sid = el('import-spreadsheet-id');
4307 const srange = el('import-sheets-range');
4308 if (sid) sid.value = '';
4309 if (srange) srange.value = '';
4310 const importSel = el('import-source-type');
4311 if (importSel && preselectSourceType) {
4312 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
4313 if (hasOption) importSel.value = preselectSourceType;
4314 }
4315 syncImportSheetsBlock();
4316 const outDirEl = el('import-output-dir');
4317 if (outDirEl) outDirEl.value = '';
4318 void (async () => {
4319 await refreshImportVaultFolderSelect();
4320 if (!lastHubFacets) {
4321 try {
4322 lastHubFacets = await fetchFacetsResolved();
4323 } catch (_) {}
4324 }
4325 hydrateImportCreateProjectSlugSelect(lastHubFacets);
4326 const out = el('import-output-dir');
4327 if (out) out.value = defaultImportOutputDir();
4328 syncImportFolderSelectToOutputDir();
4329 syncImportPickersFromOutputDir();
4330 updateImportPathLayoutVisibility();
4331 })();
4332 }
4333 function closeImportModal() {
4334 el('modal-import').classList.add('hidden');
4335 clearImportDropPending();
4336 }
4337 if (btnImport) btnImport.onclick = openImportModal;
4338 el('modal-import-backdrop').onclick = closeImportModal;
4339 el('modal-import-close').onclick = closeImportModal;
4340 const importSourceTypeEl = el('import-source-type');
4341 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
4342
4343 function closeProjectsHelpModal() {
4344 const m = el('modal-projects-help');
4345 if (m) m.classList.add('hidden');
4346 }
4347 function openProjectsHelpModal() {
4348 closeCreateModal();
4349 closeCreateProposalModal();
4350 hideDetailPanelChrome();
4351 const m = el('modal-projects-help');
4352 if (m) m.classList.remove('hidden');
4353 }
4354 const btnProjectsHelp = el('btn-projects-help');
4355 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
4356 const btnFullProjectHelp = el('btn-full-project-help');
4357 if (btnFullProjectHelp) {
4358 btnFullProjectHelp.onclick = () => {
4359 const m = el('modal-projects-help');
4360 if (m) m.classList.remove('hidden');
4361 };
4362 }
4363 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
4364 const modalProjectsHelpClose = el('modal-projects-help-close');
4365 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
4366 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
4367
4368 if (btnImportChooseFolder && importFileFolderEl) {
4369 btnImportChooseFolder.onclick = () => {
4370 importFileFolderEl.click();
4371 };
4372 }
4373 if (importFileFolderEl) {
4374 importFileFolderEl.addEventListener('change', () => {
4375 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4376 clearImportDropPending();
4377 if (importFileEl) importFileEl.value = '';
4378 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4379 }
4380 });
4381 }
4382 if (importFileEl) {
4383 importFileEl.addEventListener('change', () => {
4384 clearImportDropPending();
4385 if (importFileFolderEl) importFileFolderEl.value = '';
4386 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4387 });
4388 }
4389 if (importDropZoneEl) {
4390 let dragOverCount = 0;
4391 const setOver = (on) => {
4392 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4393 else importDropZoneEl.classList.remove('import-drop-zone--over');
4394 };
4395 importDropZoneEl.addEventListener('dragenter', (e) => {
4396 e.preventDefault();
4397 dragOverCount += 1;
4398 setOver(true);
4399 });
4400 importDropZoneEl.addEventListener('dragleave', (e) => {
4401 e.preventDefault();
4402 dragOverCount = Math.max(0, dragOverCount - 1);
4403 if (dragOverCount === 0) setOver(false);
4404 });
4405 importDropZoneEl.addEventListener('dragover', (e) => {
4406 e.preventDefault();
4407 e.stopPropagation();
4408 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4409 });
4410 importDropZoneEl.addEventListener('drop', (e) => {
4411 e.preventDefault();
4412 e.stopPropagation();
4413 dragOverCount = 0;
4414 setOver(false);
4415 const msgEl = el('import-msg');
4416 const p = (async () => {
4417 if (!e.dataTransfer) {
4418 if (msgEl) {
4419 msgEl.textContent = 'Drop did not include any files.';
4420 msgEl.className = 'create-msg err';
4421 }
4422 return;
4423 }
4424 let files;
4425 try {
4426 files = await collectFilesFromDataTransfer(e.dataTransfer);
4427 } catch (dropErr) {
4428 if (msgEl) {
4429 msgEl.textContent =
4430 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4431 msgEl.className = 'create-msg err';
4432 }
4433 return;
4434 }
4435 if (!files || files.length === 0) {
4436 if (msgEl) {
4437 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4438 msgEl.className = 'create-msg err';
4439 }
4440 return;
4441 }
4442 importPendingDropFiles = files;
4443 if (importFileEl) importFileEl.value = '';
4444 if (importFileFolderEl) importFileFolderEl.value = '';
4445 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4446 updateImportDropStatusUi();
4447 if (msgEl) {
4448 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4449 msgEl.className = 'create-msg';
4450 }
4451 })();
4452 p.catch((err) => {
4453 if (el('import-msg')) {
4454 const msg = el('import-msg');
4455 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4456 msg.className = 'create-msg err';
4457 }
4458 });
4459 });
4460 }
4461 if (importBatchCancelBtn) {
4462 importBatchCancelBtn.onclick = () => {
4463 if (importBatchAbort) importBatchAbort.abort();
4464 };
4465 }
4466
4467 /**
4468 * @param {string} postPath
4469 * @param {FormData} formData
4470 * @param {Record<string, string>} importHeaders
4471 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4472 */
4473 async function hubPostImportOnce(postPath, formData, importHeaders) {
4474 let res;
4475 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4476 try {
4477 res = await fetch(postPath, {
4478 method: 'POST',
4479 cache: 'no-store',
4480 headers: importHeaders,
4481 body: formData,
4482 });
4483 break;
4484 } catch (importErr) {
4485 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4486 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4487 await new Promise((r) => setTimeout(r, 3000));
4488 continue;
4489 }
4490 return { ok: false, errText: em, status: 0 };
4491 }
4492 }
4493 const text = await res.text();
4494 let data = {};
4495 try {
4496 data = text ? JSON.parse(text) : {};
4497 } catch (_) {
4498 data = {};
4499 }
4500 if (!res.ok) {
4501 let apiErr = '';
4502 if (data && typeof data === 'object') {
4503 const parts = [data.error, data.message, data.detail].filter(
4504 (x) => x != null && String(x).trim().length > 0,
4505 );
4506 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4507 }
4508 if (!apiErr && text) {
4509 const t = text.trim();
4510 if (t.startsWith('<')) {
4511 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4512 } else {
4513 apiErr = t.slice(0, 280);
4514 }
4515 }
4516 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4517 }
4518 return { ok: true, data };
4519 }
4520
4521 el('btn-import-submit').onclick = async () => {
4522 const importSubmitBtn = el('btn-import-submit');
4523 const sourceType = el('import-source-type').value;
4524 const fileInput = el('import-file');
4525 const urlInput = el('import-url');
4526 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4527 const msgEl = el('import-msg');
4528 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4529 const kz = globalThis.knowtationHubImportZip;
4530
4531 if (!token) {
4532 msgEl.textContent = 'Sign in to import.';
4533 msgEl.className = 'create-msg err';
4534 return;
4535 }
4536 const useUrlImport = urlTrim.length > 0;
4537 if (sourceType === 'url' && !useUrlImport) {
4538 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4539 msgEl.className = 'create-msg err';
4540 return;
4541 }
4542 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4543 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4544 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4545 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4546 const fileArr = usedDrop
4547 ? importPendingDropFiles
4548 : usedFolder
4549 ? Array.from(importFileFolderEl.files)
4550 : fileInput && fileInput.files
4551 ? Array.from(fileInput.files)
4552 : [];
4553 if (sourceType === 'google-sheets' && !useUrlImport) {
4554 if (!sheetId) {
4555 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4556 msgEl.className = 'create-msg err';
4557 return;
4558 }
4559 if (fileArr.length > 0) {
4560 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4561 msgEl.className = 'create-msg err';
4562 return;
4563 }
4564 }
4565 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4566 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4567 msgEl.className = 'create-msg err';
4568 return;
4569 }
4570 if (sourceType === 'notion' && fileArr.length > 1) {
4571 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4572 msgEl.className = 'create-msg err';
4573 return;
4574 }
4575
4576 const dest = getImportProjectAndOutputDir();
4577 if (dest.err) {
4578 msgEl.textContent = dest.err;
4579 msgEl.className = 'create-msg err';
4580 return;
4581 }
4582 const project = dest.project || '';
4583 const outputDir = dest.outputDir;
4584 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4585 const urlModeEl = el('import-url-mode');
4586 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4587 const importPostPath = apiBase + '/api/v1/import';
4588 const urlPostPath = apiBase + '/api/v1/import-url';
4589 const mode =
4590 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4591 ? kz.getHubImportFileMode(sourceType, fileArr)
4592 : 'direct';
4593
4594 if (!useUrlImport && !kz && fileArr.length > 1) {
4595 msgEl.textContent =
4596 '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.';
4597 msgEl.className = 'create-msg err';
4598 return;
4599 }
4600
4601 if (!useUrlImport && mode === 'client_zip' && !kz) {
4602 msgEl.textContent =
4603 '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.';
4604 msgEl.className = 'create-msg err';
4605 return;
4606 }
4607 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4608 msgEl.textContent =
4609 'Too many files for one batch (max ' +
4610 HUB_IMPORT_MAX_SEQUENTIAL +
4611 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4612 msgEl.className = 'create-msg err';
4613 return;
4614 }
4615
4616 if (useUrlImport) {
4617 const jsonBody = { url: urlTrim, mode: urlMode };
4618 if (project) jsonBody.project = project;
4619 if (outputDir) jsonBody.output_dir = outputDir;
4620 if (tags) jsonBody.tags = tags;
4621 msgEl.textContent = 'Importing…';
4622 msgEl.className = 'create-msg';
4623 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4624 try {
4625 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4626 const importVaultId = getCurrentVaultId();
4627 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4628 let res;
4629 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4630 try {
4631 res = await fetch(urlPostPath, {
4632 method: 'POST',
4633 cache: 'no-store',
4634 headers: importHeaders,
4635 body: JSON.stringify(jsonBody),
4636 });
4637 break;
4638 } catch (importErr) {
4639 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4640 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4641 await new Promise((r) => setTimeout(r, 3000));
4642 continue;
4643 }
4644 throw importErr;
4645 }
4646 }
4647 const text = await res.text();
4648 let data = {};
4649 try {
4650 data = text ? JSON.parse(text) : {};
4651 } catch (_) {
4652 data = {};
4653 }
4654 if (!res.ok) {
4655 let apiErr = '';
4656 if (data && typeof data === 'object') {
4657 const parts = [data.error, data.message, data.detail].filter(
4658 (x) => x != null && String(x).trim().length > 0,
4659 );
4660 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4661 }
4662 if (!apiErr && text) {
4663 const t = text.trim();
4664 if (t.startsWith('<')) {
4665 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4666 } else {
4667 apiErr = t.slice(0, 280);
4668 }
4669 }
4670 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4671 msgEl.className = 'create-msg err';
4672 return;
4673 }
4674 const count = data.count ?? data.imported?.length ?? 0;
4675 if (count === 0) {
4676 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
4677 msgEl.className = 'create-msg warn';
4678 } else {
4679 msgEl.textContent = 'Imported ' + count + ' note(s).';
4680 msgEl.className = 'create-msg ok';
4681 }
4682 if (count > 0) hubMarkSemanticIndexStale();
4683 if (typeof loadNotes === 'function') loadNotes();
4684 if (typeof loadFacets === 'function') loadFacets();
4685 if (typeof showToast === 'function') showToast('Import complete');
4686 setTimeout(() => closeImportModal(), 1500);
4687 } catch (e) {
4688 const raw = e && e.message ? String(e.message) : 'Import failed';
4689 const isNetwork =
4690 raw === 'Failed to fetch' ||
4691 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4692 msgEl.textContent = isNetwork
4693 ? raw +
4694 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
4695 : raw;
4696 msgEl.className = 'create-msg err';
4697 }
4698 });
4699 return;
4700 }
4701
4702 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
4703 const importVaultId = getCurrentVaultId();
4704 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
4705
4706 if (mode === 'sequential') {
4707 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
4708 importBatchAbort = new AbortController();
4709 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
4710 msgEl.className = 'create-msg';
4711 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
4712 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4713 const failures = [];
4714 let totalImported = 0;
4715 let okN = 0;
4716 for (let i = 0; i < fileArr.length; i++) {
4717 if (importBatchAbort && importBatchAbort.signal.aborted) {
4718 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
4719 break;
4720 }
4721 const f = fileArr[i];
4722 try {
4723 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
4724 } catch (limErr) {
4725 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
4726 continue;
4727 }
4728 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
4729 const fd = new FormData();
4730 fd.append('source_type', sourceType);
4731 fd.append('file', f);
4732 if (project) fd.append('project', project);
4733 if (outputDir) fd.append('output_dir', outputDir);
4734 if (tags) fd.append('tags', tags);
4735 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4736 if (r.ok && r.data) {
4737 const c = r.data.count ?? r.data.imported?.length ?? 0;
4738 totalImported += typeof c === 'number' ? c : 0;
4739 okN++;
4740 } else {
4741 failures.push({ name: f.name, err: r.errText || 'error' });
4742 }
4743 }
4744 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4745 importBatchAbort = null;
4746 const fl = failures.length
4747 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
4748 : '.';
4749 msgEl.textContent =
4750 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
4751 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
4752 setImportBatchAria(msgEl.textContent);
4753 if (totalImported > 0) hubMarkSemanticIndexStale();
4754 if (typeof loadNotes === 'function') loadNotes();
4755 if (typeof loadFacets === 'function') loadFacets();
4756 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
4757 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
4758 });
4759 return;
4760 }
4761
4762 msgEl.textContent = 'Importing…';
4763 msgEl.className = 'create-msg';
4764 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4765 try {
4766 if (sourceType === 'google-sheets') {
4767 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
4768 ? el('import-spreadsheet-id').value.trim()
4769 : '';
4770 if (!sid) {
4771 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
4772 msgEl.className = 'create-msg err';
4773 return;
4774 }
4775 const rEl = el('import-sheets-range');
4776 const range = rEl && rEl.value ? rEl.value.trim() : '';
4777 const fd = new FormData();
4778 fd.append('source_type', 'google-sheets');
4779 fd.append('spreadsheet_id', sid);
4780 if (range) fd.append('sheets_range', range);
4781 if (project) fd.append('project', project);
4782 if (outputDir) fd.append('output_dir', outputDir);
4783 if (tags) fd.append('tags', tags);
4784 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4785 if (!r.ok) {
4786 msgEl.textContent = r.errText || 'Import failed';
4787 msgEl.className = 'create-msg err';
4788 return;
4789 }
4790 const data = r.data || {};
4791 const count = data.count ?? data.imported?.length ?? 0;
4792 if (count === 0) {
4793 msgEl.textContent =
4794 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
4795 msgEl.className = 'create-msg warn';
4796 } else {
4797 msgEl.textContent = 'Imported ' + count + ' note(s).';
4798 msgEl.className = 'create-msg ok';
4799 }
4800 if (count > 0) hubMarkSemanticIndexStale();
4801 if (typeof loadNotes === 'function') loadNotes();
4802 if (typeof loadFacets === 'function') loadFacets();
4803 if (typeof showToast === 'function') showToast('Import complete');
4804 setTimeout(() => closeImportModal(), 1500);
4805 return;
4806 }
4807 const dupWarn = [];
4808 const warnFn = (s) => {
4809 dupWarn.push(s);
4810 };
4811 /** @type {FormData} */
4812 let formData;
4813 if (mode === 'client_zip' && kz) {
4814 const blob = await kz.buildImportZipBlob(fileArr, {
4815 signal: null,
4816 warn: warnFn,
4817 });
4818 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
4819 formData = new FormData();
4820 formData.append('source_type', sourceType);
4821 formData.append('file', fileOut);
4822 if (project) formData.append('project', project);
4823 if (outputDir) formData.append('output_dir', outputDir);
4824 if (tags) formData.append('tags', tags);
4825 if (dupWarn.length) {
4826 msgEl.className = 'create-msg';
4827 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
4828 }
4829 } else {
4830 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
4831 try {
4832 kz.assertSingleFileWithinLimit(fileArr[0]);
4833 } catch (e1) {
4834 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
4835 msgEl.className = 'create-msg err';
4836 return;
4837 }
4838 }
4839 formData = new FormData();
4840 formData.append('source_type', sourceType);
4841 formData.append('file', fileArr[0]);
4842 if (project) formData.append('project', project);
4843 if (outputDir) formData.append('output_dir', outputDir);
4844 if (tags) formData.append('tags', tags);
4845 }
4846 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
4847 if (!r.ok) {
4848 msgEl.textContent = r.errText || 'Import failed';
4849 msgEl.className = 'create-msg err';
4850 return;
4851 }
4852 const data = r.data || {};
4853 const count = data.count ?? data.imported?.length ?? 0;
4854 let extra = '';
4855 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
4856 if (count === 0) {
4857 const zeroMsg =
4858 sourceType === 'markdown'
4859 ? '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).'
4860 : sourceType === 'pdf'
4861 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
4862 : sourceType === 'docx'
4863 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
4864 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
4865 msgEl.textContent = zeroMsg + extra;
4866 msgEl.className = 'create-msg warn';
4867 } else {
4868 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
4869 msgEl.className = 'create-msg ok';
4870 }
4871 if (count > 0) hubMarkSemanticIndexStale();
4872 if (typeof loadNotes === 'function') loadNotes();
4873 if (typeof loadFacets === 'function') loadFacets();
4874 if (typeof showToast === 'function') showToast('Import complete');
4875 setTimeout(() => closeImportModal(), 1500);
4876 } catch (e) {
4877 const raw = e && e.message ? String(e.message) : 'Import failed';
4878 if (e && e.name === 'AbortError') {
4879 msgEl.textContent = 'Cancelled.';
4880 } else {
4881 const isNetwork =
4882 raw === 'Failed to fetch' ||
4883 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4884 msgEl.textContent = isNetwork
4885 ? raw +
4886 ' — 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.'
4887 : raw;
4888 }
4889 msgEl.className = 'create-msg err';
4890 }
4891 });
4892 };
4893
4894 function openHowToUse(tabId, scrollToId) {
4895 const id = tabId || 'setup';
4896 el('modal-how-to-use').classList.remove('hidden');
4897 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
4898 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
4899 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
4900 if (scrollToId) {
4901 requestAnimationFrame(() => {
4902 const target = document.getElementById(scrollToId);
4903 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4904 });
4905 }
4906 }
4907 function closeHowToUse() {
4908 el('modal-how-to-use').classList.add('hidden');
4909 }
4910 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
4911 const btnLoginHowToUse = el('btn-login-how-to-use');
4912 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
4913 const btnSettingsHelp = el('btn-settings-help');
4914 if (btnSettingsHelp) {
4915 btnSettingsHelp.onclick = () => {
4916 closeSettings();
4917 openHowToUse('knowledge-agents');
4918 };
4919 }
4920 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
4921 el('modal-how-to-use-close').onclick = closeHowToUse;
4922
4923 document.querySelectorAll('.how-to-tab').forEach((tab) => {
4924 tab.addEventListener('click', () => {
4925 const id = tab.dataset.howToTab;
4926 document.querySelectorAll('.how-to-tab').forEach((t) => {
4927 t.classList.toggle('active', t.dataset.howToTab === id);
4928 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
4929 });
4930 document.querySelectorAll('.how-to-panel').forEach((p) => {
4931 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
4932 });
4933 });
4934 });
4935
4936 const modalHowTo = el('modal-how-to-use');
4937 if (modalHowTo) {
4938 modalHowTo.addEventListener('click', (e) => {
4939 const t = e.target;
4940 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
4941 e.preventDefault();
4942 openHowToUse('consolidation');
4943 }
4944 });
4945 }
4946
4947 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
4948 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
4949 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
4950 btnHowToOpenOnboarding.addEventListener('click', () => {
4951 closeHowToUse();
4952 void openOnboardingWizard({ restart: false });
4953 });
4954 }
4955 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
4956 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
4957 btnEmptyStripWizard.dataset.knowtationBound = '1';
4958 btnEmptyStripWizard.addEventListener('click', () => {
4959 void openOnboardingWizard({ restart: true });
4960 });
4961 }
4962 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
4963 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
4964 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
4965 btnEmptyStripGettingStarted.addEventListener('click', () => {
4966 openHowToUse('getting-started');
4967 });
4968 }
4969
4970 function openTokenSavingsHowToFromSettings() {
4971 closeSettings();
4972 openHowToUse('token-savings');
4973 }
4974 const btnConsolToken = el('btn-consol-how-token-savings');
4975 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4976 const btnIntegToken = el('btn-integrations-how-token-savings');
4977 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4978 const btnAgentsToken = el('btn-agents-how-token-savings');
4979 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4980
4981 function openSettings() {
4982 refreshApiBaseFootgunBanner();
4983 closeCreateModal();
4984 el('modal-settings').classList.remove('hidden');
4985 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
4986 document.querySelectorAll('.settings-panel').forEach((p) => {
4987 p.classList.toggle('active', p.id === 'settings-panel-backup');
4988 });
4989 syncAccentUI();
4990 syncThemeUI();
4991 syncColorPaletteUI();
4992 refreshIntegApiStatus();
4993 el('settings-sync-msg').textContent = '';
4994 el('settings-sync-msg').className = 'settings-msg';
4995 el('settings-save-msg').textContent = '';
4996 el('settings-save-msg').className = 'settings-msg';
4997 const policyMsg = el('settings-proposal-policy-msg');
4998 if (policyMsg) {
4999 policyMsg.textContent = '';
5000 policyMsg.className = 'settings-msg';
5001 }
5002 el('settings-mode-display').textContent = 'Loading…';
5003 el('settings-vault-display').textContent = 'Loading…';
5004 el('settings-git-status').textContent = 'Loading…';
5005 const ghStatus = el('settings-github-status');
5006 if (ghStatus) ghStatus.textContent = 'Loading…';
5007 fetchSettingsForBackupModal()
5008 .then((s) => {
5009 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
5010 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
5011 throw new Error(
5012 '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.',
5013 );
5014 }
5015 applySettingsPayloadToHubChrome(s);
5016 const roleEl = el('settings-role-display');
5017 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
5018 const userIdEl = el('settings-user-id');
5019 if (userIdEl) userIdEl.textContent = s.user_id || '—';
5020 const vaultDisplay = s.vault_path_display || '—';
5021 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
5022 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
5023 el('settings-vault-display').textContent = vaultDisplay;
5024 const configureSection = el('settings-configure-backup-section');
5025 const configureHr = el('settings-hr-configure');
5026 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
5027 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
5028 const vg = s.vault_git || {};
5029 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
5030 const step1 = document.getElementById('setup-step-1');
5031 const step4 = document.getElementById('setup-step-4');
5032 const step1Label = el('setup-step-1-label');
5033 const step1Hint = el('setup-step-1-hint');
5034 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
5035 if (step1Hint) {
5036 step1Hint.textContent = isHosted
5037 ? 'Your notes live in your hosted vault'
5038 : 'Set below under Configure backup';
5039 }
5040 if (step1) {
5041 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
5042 step1.classList.toggle('setup-step-done', done);
5043 const icon = step1.querySelector('.setup-step-icon');
5044 if (icon) icon.textContent = done ? '✓' : '';
5045 }
5046 if (step4) {
5047 const done = !!(vg.enabled && vg.has_remote);
5048 step4.classList.toggle('setup-step-done', done);
5049 const icon = step4.querySelector('.setup-step-icon');
5050 if (icon) icon.textContent = done ? '✓' : '';
5051 }
5052 let gitText = 'Not configured';
5053 if (vg.enabled && vg.has_remote) {
5054 gitText = 'Configured';
5055 if (vg.auto_commit) gitText += ' (auto-commit on)';
5056 if (vg.auto_push) gitText += ', auto-push on';
5057 } else if (vg.enabled) gitText = 'Enabled but no remote set';
5058 el('settings-git-status').textContent = gitText;
5059 const evalReqEl = el('settings-proposal-eval-required');
5060 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
5061 const hintsEl = el('settings-proposal-hints-enabled');
5062 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
5063 const enrichStatusEl = el('settings-proposal-enrich-enabled');
5064 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
5065 const evApEl = el('settings-evaluator-may-approve');
5066 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
5067 const syncBtn = el('btn-settings-sync');
5068 const isAdmin = s.role === 'admin';
5069 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
5070 const saveSetupBtn = el('btn-settings-save');
5071 if (saveSetupBtn) {
5072 saveSetupBtn.disabled = false;
5073 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
5074 }
5075 const teamTab = el('settings-tab-team');
5076 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
5077 const vaultsTab = el('settings-tab-vaults');
5078 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
5079 const policyAdmin = el('settings-proposal-policy-admin');
5080 const storedPolicy = s.proposal_policy_stored || {};
5081 const policyLocks = s.proposal_policy_env_locked || {};
5082 if (policyAdmin) {
5083 policyAdmin.classList.toggle('hidden', !isAdmin);
5084 const cEval = el('settings-policy-eval');
5085 const cHints = el('settings-policy-hints');
5086 const cEnrich = el('settings-policy-enrich');
5087 if (cEval && cHints && cEnrich) {
5088 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
5089 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
5090 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
5091 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
5092 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
5093 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
5094 const lockHint =
5095 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5096 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
5097 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
5098 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
5099 }
5100 }
5101 const connectBtn = el('btn-connect-github');
5102 const ghStatus = el('settings-github-status');
5103 const hostedGhHint = el('settings-hosted-connect-github-hint');
5104 if (s.github_connect_available) {
5105 if (connectBtn) {
5106 connectBtn.classList.remove('hidden');
5107 connectBtn.onclick = () => {
5108 const base = apiBase.replace(/\/$/, '');
5109 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
5110 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
5111 };
5112 }
5113 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
5114 } else {
5115 if (connectBtn) {
5116 connectBtn.classList.add('hidden');
5117 connectBtn.onclick = null;
5118 }
5119 if (ghStatus) ghStatus.textContent = '—';
5120 }
5121 if (hostedGhHint) {
5122 const vd = s.vault_path_display || '';
5123 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
5124 }
5125 const hostedRepoSection = el('settings-hosted-backup-repo-section');
5126 const hostedRepoInput = el('settings-hosted-repo');
5127 if (hostedRepoSection) {
5128 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
5129 }
5130 if (hostedRepoInput && isHosted && s.github_connect_available) {
5131 if (!hostedRepoInput.value.trim()) {
5132 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
5133 }
5134 if (!hostedRepoInput.dataset.knowtationBound) {
5135 hostedRepoInput.dataset.knowtationBound = '1';
5136 hostedRepoInput.addEventListener('input', () => {
5137 const syncBtn = el('btn-settings-sync');
5138 if (!syncBtn || !lastBackupSettingsPayload) return;
5139 const vd = lastBackupSettingsPayload.vault_path_display || '';
5140 const ih = (vd + '').toLowerCase() === 'canister';
5141 if (ih && lastBackupSettingsPayload.github_connect_available) {
5142 const vg = lastBackupSettingsPayload.vault_git || {};
5143 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
5144 }
5145 });
5146 }
5147 }
5148 const ed = s.embedding_display || {};
5149 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
5150 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
5151 const ollamaRow = el('agents-ollama-row');
5152 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
5153 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
5154 applyChatProviderSettings(s);
5155 const apiRow = el('settings-api-base-row');
5156 const apiDisp = el('settings-api-base-display');
5157 if (apiRow && apiDisp) {
5158 if (isLocalHubHostname()) {
5159 apiRow.classList.remove('hidden');
5160 apiDisp.textContent = apiBase;
5161 } else {
5162 apiRow.classList.add('hidden');
5163 }
5164 }
5165 refreshApiBaseFootgunBanner();
5166 void refreshBulkDeletePresetDropdowns();
5167 })
5168 .catch((e) => {
5169 const syncMsg = el('settings-sync-msg');
5170 if (syncMsg) {
5171 const m = e && e.message ? String(e.message) : 'Could not load settings.';
5172 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
5173 syncMsg.className = 'settings-msg err';
5174 }
5175 if (typeof console !== 'undefined' && console.error) {
5176 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
5177 }
5178 const hostedGhHint = el('settings-hosted-connect-github-hint');
5179 if (hostedGhHint) hostedGhHint.classList.add('hidden');
5180 const roleEl = el('settings-role-display');
5181 if (roleEl) roleEl.textContent = '—';
5182 const userIdEl = el('settings-user-id');
5183 if (userIdEl) userIdEl.textContent = '—';
5184 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
5185 el('settings-vault-display').textContent = '—';
5186 el('settings-git-status').textContent = 'Could not load';
5187 const evalReqErr = el('settings-proposal-eval-required');
5188 if (evalReqErr) evalReqErr.textContent = '—';
5189 const hintsErr = el('settings-proposal-hints-enabled');
5190 if (hintsErr) hintsErr.textContent = '—';
5191 const enrichErr = el('settings-proposal-enrich-enabled');
5192 if (enrichErr) enrichErr.textContent = '—';
5193 const evApErr = el('settings-evaluator-may-approve');
5194 if (evApErr) evApErr.textContent = '—';
5195 const configureSection = el('settings-configure-backup-section');
5196 const configureHr = el('settings-hr-configure');
5197 if (configureSection) configureSection.style.display = '';
5198 if (configureHr) configureHr.style.display = '';
5199 const ghStatus = el('settings-github-status');
5200 if (ghStatus) ghStatus.textContent = '—';
5201 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
5202 const apiRowErr = el('settings-api-base-row');
5203 const apiDispErr = el('settings-api-base-display');
5204 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
5205 apiRowErr.classList.remove('hidden');
5206 apiDispErr.textContent = apiBase;
5207 }
5208 refreshApiBaseFootgunBanner();
5209 });
5210 api('/api/v1/setup')
5211 .then((u) => {
5212 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
5213 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
5214 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
5215 })
5216 .catch(() => {});
5217 }
5218 function closeSettings() {
5219 el('modal-settings').classList.add('hidden');
5220 }
5221 function openSettingsBillingTab() {
5222 openSettings();
5223 document.querySelectorAll('.settings-tab').forEach((t) => {
5224 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
5225 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
5226 });
5227 document.querySelectorAll('.settings-panel').forEach((p) => {
5228 p.classList.toggle('active', p.id === 'settings-panel-billing');
5229 });
5230 loadBillingPanel();
5231 }
5232
5233 function openSettingsIntegrationsTab() {
5234 openSettings();
5235 document.querySelectorAll('.settings-tab').forEach((t) => {
5236 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
5237 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
5238 });
5239 document.querySelectorAll('.settings-panel').forEach((p) => {
5240 p.classList.toggle('active', p.id === 'settings-panel-integrations');
5241 });
5242 refreshIntegApiStatus();
5243 applyMuseBridgePanel(lastBackupSettingsPayload);
5244 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
5245 }
5246
5247 if (btnSettings) btnSettings.onclick = openSettings;
5248
5249 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
5250 if (btnSettingsSetupGuide) {
5251 btnSettingsSetupGuide.addEventListener('click', () => {
5252 closeSettings();
5253 void openOnboardingWizard({ restart: true });
5254 });
5255 }
5256
5257 const btnProposalPolicySave = el('btn-proposal-policy-save');
5258 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
5259 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
5260 btnProposalPolicySave.addEventListener('click', async () => {
5261 const msg = el('settings-proposal-policy-msg');
5262 if (msg) {
5263 msg.textContent = '';
5264 msg.className = 'settings-msg';
5265 }
5266 try {
5267 await api('/api/v1/settings/proposal-policy', {
5268 method: 'POST',
5269 body: JSON.stringify({
5270 proposal_evaluation_required: el('settings-policy-eval').checked,
5271 review_hints_enabled: el('settings-policy-hints').checked,
5272 enrich_enabled: el('settings-policy-enrich').checked,
5273 }),
5274 });
5275 if (msg) {
5276 msg.textContent = 'Saved.';
5277 msg.className = 'settings-msg ok';
5278 }
5279 const fresh = await fetchSettingsForBackupModal();
5280 applySettingsPayloadToHubChrome(fresh);
5281 const evalReqEl = el('settings-proposal-eval-required');
5282 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
5283 const hintsEl2 = el('settings-proposal-hints-enabled');
5284 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
5285 const enrichEl2 = el('settings-proposal-enrich-enabled');
5286 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
5287 const st = fresh.proposal_policy_stored || {};
5288 const lk = fresh.proposal_policy_env_locked || {};
5289 const ce = el('settings-policy-eval');
5290 const ch = el('settings-policy-hints');
5291 const cr = el('settings-policy-enrich');
5292 if (ce && ch && cr) {
5293 ce.checked = Boolean(st.proposal_evaluation_required);
5294 ch.checked = Boolean(st.review_hints_enabled);
5295 cr.checked = Boolean(st.enrich_enabled);
5296 ce.disabled = Boolean(lk.proposal_evaluation_required);
5297 ch.disabled = Boolean(lk.review_hints_enabled);
5298 cr.disabled = Boolean(lk.enrich_enabled);
5299 const lockHint =
5300 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5301 ce.title = lk.proposal_evaluation_required ? lockHint : '';
5302 ch.title = lk.review_hints_enabled ? lockHint : '';
5303 cr.title = lk.enrich_enabled ? lockHint : '';
5304 }
5305 } catch (e) {
5306 if (msg) {
5307 msg.textContent = e && e.message ? String(e.message) : String(e);
5308 msg.className = 'settings-msg err';
5309 }
5310 }
5311 });
5312 }
5313 el('modal-settings-backdrop').onclick = closeSettings;
5314 el('modal-settings-close').onclick = closeSettings;
5315
5316 el('btn-copy-env-agentception').onclick = () => {
5317 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
5318 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
5319 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
5320 const lines = [];
5321 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
5322 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
5323 }
5324 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
5325 const snippet = lines.join('\n');
5326 const msg = el('agents-copy-msg');
5327 if (navigator.clipboard && navigator.clipboard.writeText) {
5328 navigator.clipboard.writeText(snippet).then(() => {
5329 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
5330 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
5331 }).catch(() => {
5332 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
5333 });
5334 } else {
5335 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
5336 }
5337 };
5338
5339 function refreshIntegApiStatus() {
5340 var dot = el('integ-api-status');
5341 if (!dot) return;
5342 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
5343 dot.classList.toggle('active', hasToken);
5344 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
5345 }
5346
5347 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
5348 let activeIntegGuide = null;
5349
5350 function closeIntegGuideModal() {
5351 const modal = el('modal-integ-guide');
5352 if (modal) modal.classList.add('hidden');
5353 activeIntegGuide = null;
5354 }
5355
5356 function openIntegGuideModal(guide) {
5357 const mod = globalThis.HubIntegrationGuides;
5358 if (!mod || !guide) return;
5359 const modal = el('modal-integ-guide');
5360 const iconEl = el('modal-integ-guide-icon');
5361 const nameEl = el('modal-integ-guide-name');
5362 const leadEl = el('modal-integ-guide-lead');
5363 const contentEl = el('modal-integ-guide-content');
5364 const importBtn = el('btn-integ-guide-import');
5365 const teamBtn = el('btn-integ-guide-team');
5366 const msgEl = el('modal-integ-guide-msg');
5367 if (!modal || !contentEl) return;
5368 activeIntegGuide = guide;
5369 if (iconEl) iconEl.textContent = guide.icon || '';
5370 if (nameEl) nameEl.textContent = guide.name || 'Integration';
5371 if (leadEl) {
5372 leadEl.textContent =
5373 guide.kind === 'capture'
5374 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
5375 : guide.desc || 'Import files or exports into your vault.';
5376 }
5377 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5378 if (msgEl) msgEl.textContent = '';
5379 if (importBtn) {
5380 const importSel = el('import-source-type');
5381 const canPreselect =
5382 guide.hubImport &&
5383 guide.sourceType &&
5384 importSel &&
5385 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5386 const showImport =
5387 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5388 importBtn.classList.toggle('hidden', !showImport);
5389 importBtn.textContent =
5390 guide.id === 'hermes'
5391 ? 'Open Import (Markdown)'
5392 : guide.id === 'imports'
5393 ? 'Open Import'
5394 : 'Open Import';
5395 }
5396 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5397 modal.classList.remove('hidden');
5398 }
5399
5400 let integGuideControlsBound = false;
5401
5402 function bindIntegrationGuideModalControlsOnce() {
5403 if (integGuideControlsBound) return;
5404 integGuideControlsBound = true;
5405 const backdrop = el('modal-integ-guide-backdrop');
5406 const closeBtn = el('modal-integ-guide-close');
5407 const importBtn = el('btn-integ-guide-import');
5408 const teamBtn = el('btn-integ-guide-team');
5409 const contentEl = el('modal-integ-guide-content');
5410 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5411 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5412 if (contentEl) {
5413 contentEl.addEventListener('click', (ev) => {
5414 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5415 if (!btn) return;
5416 const text = btn.getAttribute('data-copy') || '';
5417 const msgEl = el('modal-integ-guide-msg');
5418 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5419 navigator.clipboard.writeText(text).then(() => {
5420 if (msgEl) {
5421 msgEl.textContent = 'Copied.';
5422 msgEl.className = 'settings-msg ok';
5423 }
5424 setTimeout(() => {
5425 if (msgEl) msgEl.textContent = '';
5426 }, 2000);
5427 }).catch(() => {
5428 if (msgEl) {
5429 msgEl.textContent = 'Copy failed';
5430 msgEl.className = 'settings-msg err';
5431 }
5432 });
5433 } else if (msgEl) {
5434 msgEl.textContent = 'Clipboard not available';
5435 msgEl.className = 'settings-msg err';
5436 }
5437 });
5438 }
5439 if (importBtn) {
5440 importBtn.onclick = () => {
5441 const guide = activeIntegGuide;
5442 closeIntegGuideModal();
5443 closeSettings();
5444 const preselect =
5445 guide && guide.id === 'hermes'
5446 ? 'markdown'
5447 : guide && guide.sourceType
5448 ? guide.sourceType
5449 : undefined;
5450 openImportModal(preselect);
5451 };
5452 }
5453 if (teamBtn) {
5454 teamBtn.onclick = () => {
5455 closeIntegGuideModal();
5456 openSettings();
5457 document.querySelectorAll('.settings-tab').forEach((t) => {
5458 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5459 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5460 });
5461 document.querySelectorAll('.settings-panel').forEach((p) => {
5462 p.classList.toggle('active', p.id === 'settings-panel-team');
5463 });
5464 };
5465 }
5466 document.addEventListener('click', (ev) => {
5467 const tile =
5468 ev.target instanceof Element
5469 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5470 : null;
5471 if (!tile) return;
5472 const mod = globalThis.HubIntegrationGuides;
5473 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5474 if (typeof showToast === 'function') {
5475 showToast('Integration details still loading — try again in a moment.', true);
5476 }
5477 scheduleIntegrationGuidesInit(0);
5478 return;
5479 }
5480 const id = tile.getAttribute('data-integ-id');
5481 const guide = id ? mod.getIntegrationGuide(id) : null;
5482 if (guide) {
5483 ev.preventDefault();
5484 openIntegGuideModal(guide);
5485 }
5486 });
5487 }
5488
5489 function scheduleIntegrationGuidesInit(attempt) {
5490 bindIntegrationGuideModalControlsOnce();
5491 if (globalThis.HubIntegrationGuides) return;
5492 if (attempt >= 80) return;
5493 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5494 }
5495
5496 scheduleIntegrationGuidesInit(0);
5497
5498 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5499 if (btnCopyMcpPrime) {
5500 btnCopyMcpPrime.onclick = () => {
5501 const base = String(apiBase || '').replace(/\/$/, '');
5502 const vaultId = getCurrentVaultId() || 'default';
5503 const msg = el('integrations-hub-api-copy-msg');
5504 const payload = {
5505 schema: 'knowtation.hub_copy_prime/v1',
5506 mcp_read_resource_uri: 'knowtation://hosted/prime',
5507 instructions:
5508 '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 ' +
5509 INTEGRATION_DOC_URL,
5510 KNOWTATION_HUB_URL: base,
5511 KNOWTATION_HUB_VAULT_ID: vaultId,
5512 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5513 };
5514 const snippet = JSON.stringify(payload, null, 2);
5515 if (navigator.clipboard && navigator.clipboard.writeText) {
5516 navigator.clipboard.writeText(snippet).then(() => {
5517 if (msg) {
5518 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5519 msg.className = 'settings-msg';
5520 }
5521 setTimeout(() => {
5522 if (msg) msg.textContent = '';
5523 }, 2800);
5524 }).catch(() => {
5525 if (msg) {
5526 msg.textContent = 'Copy failed';
5527 msg.className = 'settings-msg err';
5528 }
5529 });
5530 } else if (msg) {
5531 msg.textContent = 'Clipboard not available';
5532 msg.className = 'settings-msg err';
5533 }
5534 };
5535 }
5536
5537 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5538 if (btnCopyHubApiEnv) {
5539 btnCopyHubApiEnv.onclick = () => {
5540 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5541 const vaultId = getCurrentVaultId() || 'default';
5542 const base = String(apiBase || '').replace(/\/$/, '');
5543 const msg = el('integrations-hub-api-copy-msg');
5544 if (!hubTok) {
5545 if (msg) {
5546 msg.textContent = 'Sign in first, then copy again.';
5547 msg.className = 'settings-msg err';
5548 }
5549 return;
5550 }
5551 const copyLines = [
5552 'KNOWTATION_HUB_URL=' + base,
5553 'KNOWTATION_HUB_TOKEN=' + hubTok,
5554 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5555 ];
5556 if (mcpPublicUrl !== '') {
5557 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5558 }
5559 copyLines.push('');
5560 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5561 copyLines.push(
5562 '# Example curl (append these headers to any Hub REST call): ' +
5563 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5564 '-H "Content-Type: application/json" ' +
5565 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5566 );
5567 const snippet = copyLines.join('\n');
5568 if (navigator.clipboard && navigator.clipboard.writeText) {
5569 navigator.clipboard.writeText(snippet).then(() => {
5570 if (msg) {
5571 msg.textContent = 'Copied session access token (expires — not for always-on agents).';
5572 msg.className = 'settings-msg';
5573 }
5574 refreshIntegApiStatus();
5575 setTimeout(() => {
5576 if (msg) msg.textContent = '';
5577 }, 3500);
5578 }).catch(() => {
5579 if (msg) {
5580 msg.textContent = 'Copy failed';
5581 msg.className = 'settings-msg err';
5582 }
5583 });
5584 } else if (msg) {
5585 msg.textContent = 'Clipboard not available';
5586 msg.className = 'settings-msg err';
5587 }
5588 };
5589 }
5590
5591 /** Settings → Integrations → Connect cloud agent (RFC 8628 device approval). */
5592 /** Device auth mounts on the persistent MCP host — not Netlify api.knowtation.store. */
5593 function deviceAuthBase() {
5594 if (mcpPublicUrl) {
5595 try {
5596 const u = new URL(mcpPublicUrl);
5597 return u.origin;
5598 } catch (_) { /* fall through */ }
5599 }
5600 return String(apiBase || '').replace(/\/$/, '');
5601 }
5602
5603 function setDeviceConnectMsg(text, isErr) {
5604 const msg = el('device-connect-msg');
5605 if (!msg) return;
5606 msg.textContent = text || '';
5607 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5608 }
5609
5610 async function refreshDevicePendingList() {
5611 const list = el('device-pending-list');
5612 if (!list) return;
5613 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5614 if (!hubTok) {
5615 list.innerHTML = '<li>Sign in to see pending agent codes.</li>';
5616 return;
5617 }
5618 try {
5619 const res = await fetch(deviceAuthBase() + '/api/v1/auth/device/pending', {
5620 headers: { Authorization: 'Bearer ' + hubTok },
5621 credentials: 'omit',
5622 });
5623 if (!res.ok) {
5624 list.innerHTML = '<li>Pending list unavailable on this host (device auth mounts on the persistent MCP gateway).</li>';
5625 return;
5626 }
5627 const data = await res.json();
5628 const pending = Array.isArray(data.pending) ? data.pending : [];
5629 if (pending.length === 0) {
5630 list.innerHTML = '<li>No pending cloud-agent codes.</li>';
5631 return;
5632 }
5633 list.innerHTML = pending
5634 .map(function (p) {
5635 const code = String(p.userCode || '').replace(/[<>&]/g, '');
5636 const name = String(p.clientName || p.clientId || 'agent').replace(/[<>&]/g, '');
5637 return '<li><strong>' + code + '</strong> — ' + name + '</li>';
5638 })
5639 .join('');
5640 } catch (_) {
5641 list.innerHTML = '<li>Could not load pending codes.</li>';
5642 }
5643 }
5644
5645 async function postDeviceApproveOrDeny(path) {
5646 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5647 const input = el('device-user-code-input');
5648 const userCode = input ? String(input.value || '').trim() : '';
5649 if (!hubTok) {
5650 setDeviceConnectMsg('Sign in first.', true);
5651 return;
5652 }
5653 if (!userCode) {
5654 setDeviceConnectMsg('Enter the user code shown by your agent.', true);
5655 return;
5656 }
5657 try {
5658 const res = await fetch(deviceAuthBase() + path, {
5659 method: 'POST',
5660 headers: {
5661 Authorization: 'Bearer ' + hubTok,
5662 'Content-Type': 'application/json',
5663 },
5664 credentials: 'omit',
5665 body: JSON.stringify({
5666 user_code: userCode,
5667 vault_id: getCurrentVaultId() || 'default',
5668 }),
5669 });
5670 const data = await res.json().catch(function () { return {}; });
5671 if (!res.ok) {
5672 setDeviceConnectMsg(data.error || ('Request failed (' + res.status + ')'), true);
5673 return;
5674 }
5675 setDeviceConnectMsg(path.indexOf('deny') >= 0 ? 'Denied.' : 'Approved — agent can finish polling.', false);
5676 if (input) input.value = '';
5677 refreshDevicePendingList();
5678 } catch (_) {
5679 setDeviceConnectMsg('Network error talking to device auth endpoint.', true);
5680 }
5681 }
5682
5683 const btnDeviceApprove = el('btn-device-approve');
5684 if (btnDeviceApprove) {
5685 btnDeviceApprove.onclick = function () {
5686 postDeviceApproveOrDeny('/api/v1/auth/device/approve');
5687 };
5688 }
5689 const btnDeviceDeny = el('btn-device-deny');
5690 if (btnDeviceDeny) {
5691 btnDeviceDeny.onclick = function () {
5692 postDeviceApproveOrDeny('/api/v1/auth/device/deny');
5693 };
5694 }
5695 const btnDeviceRefreshPending = el('btn-device-refresh-pending');
5696 if (btnDeviceRefreshPending) {
5697 btnDeviceRefreshPending.onclick = function () {
5698 refreshDevicePendingList();
5699 };
5700 }
5701 const btnCopyCloudSetupPack = el('btn-copy-cloud-setup-pack');
5702 if (btnCopyCloudSetupPack) {
5703 btnCopyCloudSetupPack.onclick = function () {
5704 const pack =
5705 '# Knowtation cloud agent setup (NO SECRETS)\n' +
5706 '# MCP URL: https://mcp.knowtation.store/mcp\n' +
5707 '# Prefer: Hub Settings → Integrations → Connect cloud agent (device code)\n' +
5708 '# Interim (Hostinger Hermes): desktop mcp-remote OAuth → copy ~/.mcp-auth/mcp-remote-* to agent HOME\n' +
5709 '# → Hermes stdio: npx -y mcp-remote https://mcp.knowtation.store/mcp\n' +
5710 '# DO NOT: paste Hub session JWT into always-on .env\n' +
5711 '# DO NOT: use api.knowtation.store/mcp or Netlify /mcp\n' +
5712 '# Full guide: docs/AGENT-INTEGRATION.md (Always-on cloud agents)\n';
5713 if (navigator.clipboard && navigator.clipboard.writeText) {
5714 navigator.clipboard.writeText(pack).then(function () {
5715 setDeviceConnectMsg('Copied non-secret setup pack.', false);
5716 }).catch(function () {
5717 setDeviceConnectMsg('Copy failed', true);
5718 });
5719 } else {
5720 setDeviceConnectMsg('Clipboard not available', true);
5721 }
5722 };
5723 }
5724 try {
5725 var _ucParams = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
5726 var _uc = _ucParams ? _ucParams.get('user_code') : null;
5727 if (!_uc && typeof location !== 'undefined' && location.hash && location.hash.indexOf('user_code=') >= 0) {
5728 var _hq = location.hash.split('?')[1] || '';
5729 _uc = new URLSearchParams(_hq).get('user_code');
5730 }
5731 if (_uc && el('device-user-code-input')) {
5732 el('device-user-code-input').value = String(_uc).toUpperCase();
5733 }
5734 } catch (_) { /* ignore */ }
5735
5736 /** Settings → Integrations → Agent credentials (REST / Paperclip / cron) — Phase C. */
5737 function setAgentCredMsg(text, isErr) {
5738 const msg = el('agent-cred-msg');
5739 if (!msg) return;
5740 msg.textContent = text || '';
5741 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5742 }
5743
5744 function agentCredAuthHeaders() {
5745 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5746 return {
5747 Authorization: 'Bearer ' + hubTok,
5748 'Content-Type': 'application/json',
5749 Accept: 'application/json',
5750 };
5751 }
5752
5753 function formatAgentCredTs(ms) {
5754 if (ms == null || !Number.isFinite(Number(ms))) return '—';
5755 try {
5756 return new Date(Number(ms)).toISOString().slice(0, 19) + 'Z';
5757 } catch (_) {
5758 return '—';
5759 }
5760 }
5761
5762 /** Populate vault multi-select (freeze §8); default current vault selected. */
5763 function refreshAgentCredVaultSelect() {
5764 const sel = el('agent-cred-vault-select');
5765 if (!sel) return;
5766 const current = String(getCurrentVaultId() || 'default');
5767 const s = lastBackupSettingsPayload;
5768 let allowed = [];
5769 if (s && Array.isArray(s.allowed_vault_ids) && s.allowed_vault_ids.length) {
5770 allowed = s.allowed_vault_ids.map(String).filter(Boolean);
5771 } else if (s && Array.isArray(s.vault_list)) {
5772 allowed = s.vault_list
5773 .map(function (v) {
5774 return v && v.id != null ? String(v.id) : '';
5775 })
5776 .filter(Boolean);
5777 }
5778 if (allowed.length === 0) allowed = [current];
5779 if (allowed.indexOf(current) < 0) allowed = [current].concat(allowed);
5780 const prev = Array.prototype.slice
5781 .call(sel.selectedOptions || [])
5782 .map(function (o) {
5783 return o.value;
5784 });
5785 sel.innerHTML = '';
5786 allowed.forEach(function (vid) {
5787 const opt = document.createElement('option');
5788 opt.value = vid;
5789 opt.textContent = vid;
5790 opt.selected = prev.length ? prev.indexOf(vid) >= 0 : vid === current;
5791 sel.appendChild(opt);
5792 });
5793 if (!sel.selectedOptions || sel.selectedOptions.length === 0) {
5794 const fallback =
5795 Array.prototype.find.call(sel.options, function (o) {
5796 return o.value === current;
5797 }) || sel.options[0];
5798 if (fallback) fallback.selected = true;
5799 }
5800 }
5801
5802 function selectedAgentCredVaultIds() {
5803 const sel = el('agent-cred-vault-select');
5804 if (!sel) return [getCurrentVaultId() || 'default'];
5805 const picked = Array.prototype.slice.call(sel.selectedOptions || []).map(function (o) { return String(o.value || '').trim(); }).filter(Boolean);
5806 if (picked.length) return picked.slice(0, 32);
5807 return [getCurrentVaultId() || 'default'];
5808 }
5809
5810 function syncAgentCredWriteWarn() {
5811 const warn = el('agent-cred-write-warn');
5812 const box = el('agent-cred-scope-write');
5813 if (!warn || !box) return;
5814 warn.style.display = box.checked ? 'block' : 'none';
5815 }
5816
5817 async function refreshAgentCredList() {
5818 const list = el('agent-cred-list');
5819 if (!list) return;
5820 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5821 if (!hubTok) {
5822 list.innerHTML = '<li>Sign in to manage agent credentials.</li>';
5823 return;
5824 }
5825 try {
5826 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5827 headers: agentCredAuthHeaders(),
5828 credentials: 'omit',
5829 });
5830 if (!res.ok) {
5831 list.innerHTML = '<li>Agent credentials unavailable on this host (' + res.status + ').</li>';
5832 return;
5833 }
5834 const data = await res.json();
5835 const creds = Array.isArray(data.credentials) ? data.credentials : [];
5836 if (creds.length === 0) {
5837 list.innerHTML = '<li>No agent credentials yet.</li>';
5838 return;
5839 }
5840 list.innerHTML = creds
5841 .map(function (c) {
5842 const id = String(c.id || '').replace(/[<>&"]/g, '');
5843 const name = String(c.name || '').replace(/[<>&"]/g, '');
5844 const scopes = (Array.isArray(c.scopes) ? c.scopes : []).join(' ').replace(/[<>&"]/g, '');
5845 const vaults = (Array.isArray(c.vault_ids) ? c.vault_ids : []).join(', ').replace(/[<>&"]/g, '') || '—';
5846 const created = formatAgentCredTs(c.created_at);
5847 const expires = formatAgentCredTs(c.expires_at);
5848 const lastUsed = formatAgentCredTs(c.last_used_at);
5849 const revoked = c.revoked ? ' (revoked)' : '';
5850 return (
5851 '<li><strong>' +
5852 name +
5853 '</strong> — vaults: ' +
5854 vaults +
5855 '; scopes: ' +
5856 scopes +
5857 '; created: ' +
5858 created +
5859 '; expires: ' +
5860 expires +
5861 '; last used: ' +
5862 lastUsed +
5863 revoked +
5864 ' <button type="button" class="btn-secondary btn-agent-cred-revoke" data-id="' +
5865 id +
5866 '">Revoke</button> <button type="button" class="btn-secondary btn-agent-cred-rotate" data-id="' +
5867 id +
5868 '">Rotate</button></li>'
5869 );
5870 })
5871 .join('');
5872 list.querySelectorAll('.btn-agent-cred-revoke').forEach(function (btn) {
5873 btn.onclick = async function () {
5874 const id = btn.getAttribute('data-id');
5875 const res = await fetch(
5876 String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials/' + encodeURIComponent(id),
5877 { method: 'DELETE', headers: agentCredAuthHeaders(), credentials: 'omit' }
5878 );
5879 setAgentCredMsg(res.ok ? 'Revoked.' : 'Revoke failed', !res.ok);
5880 refreshAgentCredList();
5881 };
5882 });
5883 list.querySelectorAll('.btn-agent-cred-rotate').forEach(function (btn) {
5884 btn.onclick = async function () {
5885 const id = btn.getAttribute('data-id');
5886 const res = await fetch(
5887 String(apiBase || '').replace(/\/$/, '') +
5888 '/api/v1/auth/agent/credentials/' +
5889 encodeURIComponent(id) +
5890 '/rotate',
5891 { method: 'POST', headers: agentCredAuthHeaders(), credentials: 'omit' }
5892 );
5893 const data = await res.json().catch(function () { return {}; });
5894 if (!res.ok) {
5895 setAgentCredMsg(data.error || 'Rotate failed', true);
5896 return;
5897 }
5898 const once = el('agent-cred-once');
5899 const pack =
5900 'KNOWTATION_HUB_URL=' +
5901 String(apiBase || '').replace(/\/$/, '') +
5902 '\nKNOWTATION_HUB_VAULT_ID=' +
5903 (getCurrentVaultId() || 'default') +
5904 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
5905 String(data.credential || '') +
5906 '\n';
5907 if (once) {
5908 once.style.display = 'block';
5909 once.textContent = pack + '\n# Shown once — copy now.';
5910 }
5911 if (navigator.clipboard && navigator.clipboard.writeText) {
5912 navigator.clipboard.writeText(pack).catch(function () {});
5913 }
5914 setAgentCredMsg('Rotated — new secret copied (shown once).', false);
5915 refreshAgentCredList();
5916 };
5917 });
5918 } catch (_) {
5919 list.innerHTML = '<li>Could not load agent credentials.</li>';
5920 }
5921 }
5922
5923 const btnAgentCredMint = el('btn-agent-cred-mint');
5924 if (btnAgentCredMint) {
5925 btnAgentCredMint.onclick = async function () {
5926 const nameEl = el('agent-cred-name-input');
5927 const name = nameEl ? String(nameEl.value || '').trim() : '';
5928 if (!name) {
5929 setAgentCredMsg('Enter a name.', true);
5930 return;
5931 }
5932 const scopes = [];
5933 if (el('agent-cred-scope-propose') && el('agent-cred-scope-propose').checked) scopes.push('propose');
5934 if (el('agent-cred-scope-read') && el('agent-cred-scope-read').checked) scopes.push('vault:read');
5935 if (el('agent-cred-scope-write') && el('agent-cred-scope-write').checked) scopes.push('vault:write');
5936 try {
5937 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5938 method: 'POST',
5939 headers: agentCredAuthHeaders(),
5940 credentials: 'omit',
5941 body: JSON.stringify({
5942 name: name,
5943 vault_ids: selectedAgentCredVaultIds(),
5944 scopes: scopes.length ? scopes : ['propose', 'vault:read'],
5945 }),
5946 });
5947 const data = await res.json().catch(function () { return {}; });
5948 if (!res.ok) {
5949 setAgentCredMsg(data.error || data.code || 'Mint failed', true);
5950 return;
5951 }
5952 const packVault =
5953 (Array.isArray(data.vault_ids) && data.vault_ids[0]) ||
5954 selectedAgentCredVaultIds()[0] ||
5955 getCurrentVaultId() ||
5956 'default';
5957 const pack =
5958 'KNOWTATION_HUB_URL=' +
5959 String(apiBase || '').replace(/\/$/, '') +
5960 '\nKNOWTATION_HUB_VAULT_ID=' +
5961 packVault +
5962 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
5963 String(data.credential || '') +
5964 '\n';
5965 const once = el('agent-cred-once');
5966 if (once) {
5967 once.style.display = 'block';
5968 once.textContent = pack + '\n# Shown once — copy now. Store in Paperclip secrets.';
5969 }
5970 if (navigator.clipboard && navigator.clipboard.writeText) {
5971 await navigator.clipboard.writeText(pack);
5972 }
5973 setAgentCredMsg('Minted — env block copied (secret shown once).', false);
5974 refreshAgentCredList();
5975 } catch (_) {
5976 setAgentCredMsg('Network error minting credential.', true);
5977 }
5978 };
5979 }
5980 const btnAgentCredRefresh = el('btn-agent-cred-refresh');
5981 if (btnAgentCredRefresh) {
5982 btnAgentCredRefresh.onclick = function () {
5983 refreshAgentCredVaultSelect();
5984 refreshAgentCredList();
5985 };
5986 }
5987 const agentCredWriteBox = el('agent-cred-scope-write');
5988 if (agentCredWriteBox) {
5989 agentCredWriteBox.onchange = syncAgentCredWriteWarn;
5990 syncAgentCredWriteWarn();
5991 }
5992 try {
5993 refreshAgentCredVaultSelect();
5994 refreshAgentCredList();
5995 } catch (_) { /* ignore */ }
5996 refreshDevicePendingList();
5997
5998 const btnSettingsMuseSave = el('btn-settings-muse-save');
5999 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
6000 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
6001 btnSettingsMuseSave.addEventListener('click', async () => {
6002 const msg = el('settings-muse-msg');
6003 if (msg) {
6004 msg.textContent = '';
6005 msg.className = 'settings-msg';
6006 }
6007 const input = el('settings-muse-url');
6008 const url = input ? String(input.value || '').trim() : '';
6009 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
6010 try {
6011 await api('/api/v1/settings/muse', {
6012 method: 'POST',
6013 body: JSON.stringify({ url }),
6014 });
6015 if (msg) {
6016 msg.textContent = 'Saved.';
6017 msg.className = 'settings-msg ok';
6018 }
6019 const s = await api('/api/v1/settings');
6020 applySettingsPayloadToHubChrome(s);
6021 } catch (e) {
6022 if (msg) {
6023 msg.textContent =
6024 e && e.code === 'ENV_CONFLICT'
6025 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
6026 : (e && e.message) || 'Save failed';
6027 msg.className = 'settings-msg err';
6028 }
6029 }
6030 });
6031 });
6032 }
6033
6034 document.querySelectorAll('.settings-tab').forEach((tab) => {
6035 tab.addEventListener('click', () => {
6036 const id = tab.dataset.settingsTab;
6037 document.querySelectorAll('.settings-tab').forEach((t) => {
6038 t.classList.toggle('active', t.dataset.settingsTab === id);
6039 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
6040 });
6041 document.querySelectorAll('.settings-panel').forEach((p) => {
6042 p.classList.toggle('active', p.id === 'settings-panel-' + id);
6043 });
6044 if (id === 'team') {
6045 loadTeamRolesList();
6046 loadInvitesList();
6047 }
6048 if (id === 'integrations') {
6049 refreshDevicePendingList();
6050 }
6051 if (id === 'vaults') loadVaultsPanel();
6052 if (id === 'billing') loadBillingPanel();
6053 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
6054 if (id === 'consolidation') loadConsolidationSettings();
6055 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
6056 });
6057 });
6058
6059 function formatTokenCount(n) {
6060 if (n == null || !Number.isFinite(Number(n))) return '—';
6061 return Number(n).toLocaleString();
6062 }
6063
6064 function formatTokenCountShort(n) {
6065 if (n == null || !Number.isFinite(Number(n))) return '—';
6066 const v = Number(n);
6067 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
6068 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
6069 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
6070 return String(v);
6071 }
6072
6073 /**
6074 * Update the token usage progress bar.
6075 * @param {number} used - tokens used this period
6076 * @param {number|null} included - tokens included (null = unlimited)
6077 */
6078 function updateUsageBar(fillId, used, included) {
6079 const fill = el(fillId);
6080 if (!fill) return;
6081 if (included == null) {
6082 fill.style.width = '15%';
6083 fill.className = 'billing-usage-bar-fill';
6084 return;
6085 }
6086 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
6087 fill.style.width = pct + '%';
6088 fill.className =
6089 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
6090 }
6091
6092 const TIER_LABELS = {
6093 free: 'Free',
6094 plus: 'Plus',
6095 growth: 'Growth',
6096 pro: 'Pro',
6097 beta: 'Beta',
6098 starter: 'Plus',
6099 team: 'Team',
6100 };
6101
6102 const TIER_CSS_CLASSES = {
6103 free: 'tier-free',
6104 plus: 'tier-plus',
6105 growth: 'tier-growth',
6106 pro: 'tier-pro',
6107 beta: 'tier-beta',
6108 starter: 'tier-plus',
6109 team: 'tier-pro',
6110 };
6111
6112 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
6113
6114 const TIER_PLAN_DATA = [
6115 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
6116 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
6117 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
6118 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
6119 ];
6120
6121 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
6122 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
6123
6124 /**
6125 * Render the plan comparison grid into #billing-plan-grid.
6126 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
6127 */
6128 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
6129 const grid = el('billing-plan-grid');
6130 if (!grid) return;
6131
6132 const normalized =
6133 currentTier === 'starter' ? 'plus'
6134 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
6135 : currentTier;
6136 const currentRank = TIER_ORDER.indexOf(normalized);
6137
6138 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
6139 const rank = TIER_ORDER.indexOf(tier);
6140 const isCurrent = rank === currentRank;
6141 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
6142
6143 let ctaHtml = '';
6144 if (isCurrent) {
6145 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
6146 } else if (isUpgrade) {
6147 const label = hasSub
6148 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
6149 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
6150 ctaHtml =
6151 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
6152 tier + '">' + label + '</button>';
6153 }
6154
6155 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
6156 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
6157
6158 return (
6159 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
6160 '<div class="billing-plan-card-header">' +
6161 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
6162 '<span class="billing-plan-card-price">' + price + '</span>' +
6163 '</div>' +
6164 '<ul class="billing-plan-card-features">' +
6165 '<li>' + searches + '</li>' +
6166 '<li>' + indexJobs + '</li>' +
6167 '<li>' + notes + '</li>' +
6168 consolLine +
6169 packLine +
6170 '</ul>' +
6171 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
6172 '</div>'
6173 );
6174 });
6175
6176 grid.innerHTML = cards.join('');
6177
6178 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
6179 btn.addEventListener('click', async () => {
6180 const tier = btn.dataset.tier;
6181 setButtonBusy(btn, true, 'Redirecting\u2026');
6182 try {
6183 await redirectToCheckout({ tier });
6184 } catch (e) {
6185 setButtonBusy(btn, false);
6186 const msg = el('billing-panel-msg');
6187 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
6188 }
6189 });
6190 });
6191 }
6192
6193 /**
6194 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
6195 * @param {{ price_id?: string, tier?: string }} opts
6196 */
6197 async function redirectToCheckout(opts) {
6198 const resp = await api('/api/v1/billing/checkout', {
6199 method: 'POST',
6200 headers: { 'Content-Type': 'application/json' },
6201 body: JSON.stringify({
6202 ...opts,
6203 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
6204 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
6205 }),
6206 });
6207 if (resp && resp.url) {
6208 window.location.href = resp.url;
6209 }
6210 }
6211
6212 /**
6213 * Redirect to Stripe Customer Portal.
6214 */
6215 async function redirectToPortal() {
6216 const resp = await api('/api/v1/billing/portal', {
6217 method: 'POST',
6218 headers: { 'Content-Type': 'application/json' },
6219 body: JSON.stringify({
6220 return_url: window.location.origin + window.location.pathname + '?open=billing',
6221 }),
6222 });
6223 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
6224 if (!url) {
6225 throw new Error(
6226 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
6227 );
6228 }
6229 window.location.assign(url);
6230 }
6231
6232 async function loadBillingPanel() {
6233 const msg = el('billing-panel-msg');
6234 const tierEl = el('billing-tier');
6235 const searchesUsedEl = el('billing-searches-used');
6236 const searchesIncEl = el('billing-searches-included');
6237 const indexJobsUsedEl = el('billing-index-jobs-used');
6238 const indexJobsIncEl = el('billing-index-jobs-included');
6239 const packEl = el('billing-pack-balance');
6240 const packRow = el('billing-pack-balance-row');
6241 const periodEl = el('billing-period');
6242 const renewalEl = el('billing-renewal');
6243 const credEl = el('billing-credits-used');
6244 const credRow = el('billing-credits-row');
6245 const polEl = el('billing-indexing-policy');
6246 const noteCap = el('billing-note-cap');
6247 const refreshBtn = el('btn-billing-refresh');
6248 const upgradeBtn = el('btn-billing-upgrade');
6249 const manageBtn = el('btn-billing-manage');
6250 const packSection = el('billing-pack-section');
6251 if (!tierEl || !searchesUsedEl) return;
6252 if (msg) msg.textContent = '';
6253 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
6254
6255 const setDash = () => {
6256 tierEl.textContent = '—';
6257 tierEl.className = 'billing-plan-badge tier-beta';
6258 if (searchesUsedEl) searchesUsedEl.textContent = '—';
6259 if (searchesIncEl) searchesIncEl.textContent = '—';
6260 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
6261 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
6262 if (packEl) packEl.textContent = '0';
6263 if (packRow) packRow.style.display = 'none';
6264 if (periodEl) periodEl.textContent = '—';
6265 if (renewalEl) renewalEl.textContent = '';
6266 if (credEl) credEl.textContent = '—';
6267 if (credRow) credRow.style.display = 'none';
6268 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
6269 if (noteCap) noteCap.textContent = '—';
6270 if (packSection) packSection.style.display = 'none';
6271 if (upgradeBtn) upgradeBtn.style.display = 'none';
6272 if (manageBtn) manageBtn.style.display = 'none';
6273 updateUsageBar('billing-searches-bar-fill', 0, 0);
6274 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
6275 updateUsageBar('billing-consol-bar-fill', 0, 0);
6276 const consolUsedReset = el('billing-consol-used');
6277 const consolIncReset = el('billing-consol-included');
6278 if (consolUsedReset) consolUsedReset.textContent = '—';
6279 if (consolIncReset) consolIncReset.textContent = '—';
6280 renderBillingPlanGrid('beta', false, false);
6281 };
6282
6283 if (!token) {
6284 setDash();
6285 if (msg) msg.textContent = 'Sign in to view billing usage.';
6286 if (refreshBtn) setButtonBusy(refreshBtn, false);
6287 return;
6288 }
6289
6290 try {
6291 const d = await api('/api/v1/billing/summary');
6292 const tier = d.tier != null ? String(d.tier) : 'beta';
6293
6294 // Plan badge
6295 tierEl.textContent = TIER_LABELS[tier] || tier;
6296 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
6297
6298 // Renewal date
6299 if (renewalEl) {
6300 const pe = d.period_end;
6301 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
6302 }
6303
6304 // Plan comparison grid
6305 const hasSub = Boolean(d.has_active_subscription);
6306 const isFreeTier = tier === 'free' || tier === 'beta';
6307 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
6308
6309 // Legacy upgrade button stays hidden (grid handles upgrades now)
6310 if (upgradeBtn) upgradeBtn.style.display = 'none';
6311 // Manage button: visible for active subscribers to reach the Stripe portal
6312 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
6313
6314 // Searches usage bar
6315 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
6316 const searchesInc = d.monthly_searches_included ?? null;
6317 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
6318 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
6319 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
6320
6321 // Index jobs usage bar
6322 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
6323 const indexJobsInc = d.monthly_index_jobs_included ?? null;
6324 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
6325 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
6326 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
6327
6328 // Consolidation jobs usage bar
6329 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
6330 const consolInc = d.monthly_consolidation_jobs_included ?? null;
6331 const consolUsedEl = el('billing-consol-used');
6332 const consolIncEl = el('billing-consol-included');
6333 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
6334 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
6335 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
6336
6337 // Pack balance
6338 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
6339 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
6340 if (packEl) {
6341 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
6342 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
6343 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
6344 let packText = formatTokenCountShort(packBal) +
6345 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
6346 if (packConsolPasses > 0) {
6347 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
6348 }
6349 packEl.textContent = packText;
6350 }
6351 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
6352
6353 // Period
6354 if (periodEl) {
6355 const ps = d.period_start;
6356 const pe = d.period_end;
6357 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
6358 }
6359
6360 // Note cap
6361 if (noteCap) {
6362 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
6363 }
6364
6365 // Legacy credits row (only show if non-zero)
6366 const mu = Number(d.monthly_used_cents) || 0;
6367 const mi = Number(d.monthly_included_effective_cents) || 0;
6368 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
6369 if (credEl && (mu > 0 || mi > 0)) {
6370 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
6371 }
6372
6373 // Token policy
6374 if (polEl) {
6375 const pol = d.indexing_tokens_policy;
6376 if (pol && String(pol).trim()) {
6377 polEl.textContent = String(pol).trim();
6378 polEl.style.display = '';
6379 } else {
6380 polEl.style.display = 'none';
6381 }
6382 }
6383
6384 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
6385 if (packSection) {
6386 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
6387 packSection.style.display = showPacks ? '' : 'none';
6388 }
6389
6390 if (msg) {
6391 msg.textContent = '';
6392 msg.className = 'settings-intro small muted';
6393 }
6394 } catch (e) {
6395 setDash();
6396 const m = e && e.message ? String(e.message) : String(e);
6397 if (msg) {
6398 msg.textContent =
6399 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
6400 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
6401 : m;
6402 msg.className = 'settings-intro small err';
6403 }
6404 }
6405 if (refreshBtn) setButtonBusy(refreshBtn, false);
6406 }
6407
6408 const btnBillingRefresh = el('btn-billing-refresh');
6409 if (btnBillingRefresh) {
6410 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
6411 }
6412
6413 const btnBillingUpgrade = el('btn-billing-upgrade');
6414 if (btnBillingUpgrade) {
6415 btnBillingUpgrade.addEventListener('click', async () => {
6416 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
6417 try {
6418 await redirectToCheckout({ tier: 'plus' });
6419 } catch (e) {
6420 setButtonBusy(btnBillingUpgrade, false);
6421 const packMsg = el('billing-panel-msg');
6422 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
6423 }
6424 });
6425 }
6426
6427 const btnBillingManage = el('btn-billing-manage');
6428 if (btnBillingManage) {
6429 btnBillingManage.addEventListener('click', async () => {
6430 const panelMsg = el('billing-panel-msg');
6431 if (panelMsg) {
6432 panelMsg.textContent = '';
6433 panelMsg.className = 'settings-intro small muted';
6434 }
6435 setButtonBusy(btnBillingManage, true, 'Redirecting…');
6436 try {
6437 await redirectToPortal();
6438 } catch (e) {
6439 setButtonBusy(btnBillingManage, false);
6440 const errText = e?.message || 'Could not open billing portal.';
6441 if (panelMsg) {
6442 panelMsg.textContent = errText;
6443 panelMsg.className = 'settings-intro small err';
6444 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6445 }
6446 }
6447 });
6448 }
6449
6450 // Token pack purchase buttons
6451 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
6452 btn.addEventListener('click', async () => {
6453 const pack = btn.dataset.pack;
6454 const packMsgEl = el('billing-pack-msg');
6455 setButtonBusy(btn, true, 'Redirecting…');
6456 if (packMsgEl) packMsgEl.textContent = '';
6457 try {
6458 await redirectToCheckout({ pack_size: pack });
6459 } catch (e) {
6460 setButtonBusy(btn, false);
6461 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
6462 }
6463 });
6464 });
6465
6466 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
6467 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
6468 const arr = Array.isArray(vaults) ? vaults : [];
6469 if (arr.length === 0) {
6470 return isHosted
6471 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
6472 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
6473 }
6474 const items = arr
6475 .map((v) => {
6476 if (!v || v.id == null) return '';
6477 const id = escapeHtml(String(v.id).trim());
6478 const lab =
6479 v.label != null && String(v.label).trim()
6480 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
6481 : '';
6482 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
6483 const pathHtml = pathRaw
6484 ? escapeHtml(pathRaw)
6485 : '<span class="muted">—</span>';
6486 return (
6487 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
6488 id +
6489 '</code>' +
6490 lab +
6491 '</div><div class="vaults-summary-path muted small">' +
6492 pathHtml +
6493 '</div></li>'
6494 );
6495 })
6496 .filter(Boolean)
6497 .join('');
6498 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
6499 }
6500
6501 function collectVaultIdsForAccessForm(vaults, settingsRes) {
6502 const set = new Set(['default']);
6503 const allowed =
6504 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
6505 allowed.forEach((id) => {
6506 if (id != null && String(id).trim()) set.add(String(id).trim());
6507 });
6508 (vaults || []).forEach((v) => {
6509 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
6510 });
6511 return Array.from(set).sort((a, b) => {
6512 if (a === 'default') return -1;
6513 if (b === 'default') return 1;
6514 return a.localeCompare(b);
6515 });
6516 }
6517
6518 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
6519 if (!selectEl) return;
6520 const uids = new Set();
6521 (roleIds || []).forEach((id) => {
6522 if (id != null && String(id).trim()) uids.add(String(id).trim());
6523 });
6524 if (currentUserId != null && String(currentUserId).trim()) {
6525 uids.add(String(currentUserId).trim());
6526 }
6527 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
6528 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
6529 sorted.forEach((uid) => {
6530 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
6531 });
6532 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
6533 selectEl.innerHTML = html;
6534 }
6535
6536 function renderAccessVaultCheckboxes(vaultIds) {
6537 const wrap = el('access-form-vault-checkboxes');
6538 if (!wrap) return;
6539 if (!vaultIds.length) {
6540 wrap.innerHTML =
6541 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
6542 return;
6543 }
6544 wrap.innerHTML = vaultIds
6545 .map((id) => {
6546 const idAttr = escapeHtml(id);
6547 return (
6548 '<label><input type="checkbox" name="hub-access-vault" value="' +
6549 idAttr +
6550 '"> <code>' +
6551 idAttr +
6552 '</code></label>'
6553 );
6554 })
6555 .join('');
6556 }
6557
6558 function parseVaultAccessFromTextarea() {
6559 const accessText = el('vault-access-json');
6560 try {
6561 const access = JSON.parse((accessText && accessText.value) || '{}');
6562 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
6563 } catch (_) {
6564 return {};
6565 }
6566 }
6567
6568 function refreshAccessRulesSummary(access) {
6569 const wrap = el('access-rules-summary');
6570 if (!wrap) return;
6571 if (typeof access !== 'object' || access === null) access = {};
6572 const keys = Object.keys(access);
6573 if (keys.length === 0) {
6574 wrap.innerHTML =
6575 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
6576 return;
6577 }
6578 wrap.innerHTML = keys
6579 .sort((a, b) => a.localeCompare(b))
6580 .map((uid) => {
6581 const arr = access[uid];
6582 const vaults =
6583 Array.isArray(arr) && arr.length
6584 ? arr.map((x) => escapeHtml(String(x))).join(', ')
6585 : '<span class="muted">(invalid)</span>';
6586 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
6587 })
6588 .join('');
6589 }
6590
6591 function accessFormToggleOtherInput() {
6592 const sel = el('access-form-user-select');
6593 const wrap = el('access-form-user-other-wrap');
6594 const other = el('access-form-user-other');
6595 if (!sel || !wrap) return;
6596 const show = sel.value === '__other__';
6597 wrap.classList.toggle('hidden', !show);
6598 if (!show && other) other.value = '';
6599 }
6600
6601 function accessFormSyncCheckboxesFromAccessJson() {
6602 const sel = el('access-form-user-select');
6603 const other = el('access-form-user-other');
6604 if (!sel) return;
6605 let uid = '';
6606 if (sel.value === '__other__') {
6607 uid = ((other && other.value) || '').trim();
6608 } else {
6609 uid = (sel.value || '').trim();
6610 }
6611 const access = parseVaultAccessFromTextarea();
6612 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
6613 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
6614 cb.checked = allowed.indexOf(cb.value) !== -1;
6615 });
6616 }
6617
6618 function getAccessFormResolvedUserId() {
6619 const sel = el('access-form-user-select');
6620 const other = el('access-form-user-other');
6621 if (!sel) return '';
6622 if (sel.value === '__other__') return ((other && other.value) || '').trim();
6623 return (sel.value || '').trim();
6624 }
6625
6626 const accessUserSel = el('access-form-user-select');
6627 if (accessUserSel) {
6628 accessUserSel.addEventListener('change', () => {
6629 accessFormToggleOtherInput();
6630 accessFormSyncCheckboxesFromAccessJson();
6631 });
6632 }
6633 const accessUserOther = el('access-form-user-other');
6634 if (accessUserOther) {
6635 accessUserOther.addEventListener('input', () => {
6636 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
6637 accessFormSyncCheckboxesFromAccessJson();
6638 }
6639 });
6640 }
6641 const scopeUserSelInit = el('scope-form-user-select');
6642 if (scopeUserSelInit) {
6643 scopeUserSelInit.addEventListener('change', () => {
6644 const inp = el('scope-form-user-id');
6645 if (scopeUserSelInit.value === '__other__') {
6646 if (inp) inp.focus();
6647 } else if (scopeUserSelInit.value && inp) {
6648 inp.value = scopeUserSelInit.value;
6649 }
6650 });
6651 }
6652
6653 function populateVaultListExistingSelect(vaults) {
6654 const sel = el('vault-list-form-existing');
6655 if (!sel) return;
6656 let html = '<option value="">New vault</option>';
6657 (vaults || []).forEach((v) => {
6658 if (v && v.id != null && String(v.id).trim()) {
6659 const id = String(v.id).trim();
6660 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
6661 }
6662 });
6663 sel.innerHTML = html;
6664 }
6665
6666 function parseVaultsJsonArrayFromTextarea() {
6667 const ta = el('vaults-json');
6668 try {
6669 const arr = JSON.parse((ta && ta.value) || '[]');
6670 return Array.isArray(arr) ? arr : [];
6671 } catch (_) {
6672 return null;
6673 }
6674 }
6675
6676 function fillVaultListFormFromExisting() {
6677 const sel = el('vault-list-form-existing');
6678 const idInp = el('vault-list-form-id');
6679 const pathInp = el('vault-list-form-path');
6680 const labelInp = el('vault-list-form-label');
6681 if (!sel) return;
6682 if (!sel.value) {
6683 if (idInp) {
6684 idInp.value = '';
6685 idInp.readOnly = false;
6686 }
6687 if (pathInp) pathInp.value = '';
6688 if (labelInp) labelInp.value = '';
6689 return;
6690 }
6691 const vaults = parseVaultsJsonArrayFromTextarea();
6692 if (!vaults) return;
6693 const v = vaults.find((x) => x && String(x.id) === sel.value);
6694 if (v) {
6695 if (idInp) {
6696 idInp.value = String(v.id);
6697 idInp.readOnly = true;
6698 }
6699 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
6700 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
6701 }
6702 }
6703
6704 function toggleVaultsInfoPanel(panelId) {
6705 const panel = el(panelId);
6706 const modal = el('modal-settings');
6707 if (!panel || !modal) return;
6708 const wasHidden = panel.classList.contains('hidden');
6709 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
6710 if (wasHidden) panel.classList.remove('hidden');
6711 }
6712
6713 const modalSettingsForVaultsInfo = el('modal-settings');
6714 if (modalSettingsForVaultsInfo) {
6715 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
6716 const infoBtn = e.target.closest('.btn-settings-info');
6717 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
6718 e.stopPropagation();
6719 const tid = infoBtn.getAttribute('data-settings-info-target');
6720 if (tid) toggleVaultsInfoPanel(tid);
6721 return;
6722 }
6723 if (
6724 !e.target.closest('.settings-info-panel') &&
6725 !e.target.closest('.btn-settings-info')
6726 ) {
6727 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
6728 p.classList.add('hidden');
6729 });
6730 }
6731 });
6732 }
6733
6734 const vaultListExistingSel = el('vault-list-form-existing');
6735 if (vaultListExistingSel) {
6736 vaultListExistingSel.addEventListener('change', () => {
6737 fillVaultListFormFromExisting();
6738 const msg = el('vault-list-form-msg');
6739 if (msg) msg.textContent = '';
6740 });
6741 }
6742
6743 const btnVaultListFormApply = el('btn-vault-list-form-apply');
6744 if (btnVaultListFormApply) {
6745 btnVaultListFormApply.onclick = () => {
6746 const msg = el('vault-list-form-msg');
6747 const ta = el('vaults-json');
6748 const idInp = el('vault-list-form-id');
6749 const pathInp = el('vault-list-form-path');
6750 const labelInp = el('vault-list-form-label');
6751 const vaults = parseVaultsJsonArrayFromTextarea();
6752 if (!vaults) {
6753 if (msg) {
6754 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
6755 msg.className = 'settings-msg err';
6756 }
6757 return;
6758 }
6759 const id = ((idInp && idInp.value) || '').trim();
6760 const path = ((pathInp && pathInp.value) || '').trim();
6761 const label = ((labelInp && labelInp.value) || '').trim();
6762 if (!id || !path) {
6763 if (msg) {
6764 msg.textContent = 'Enter vault id and folder path.';
6765 msg.className = 'settings-msg err';
6766 }
6767 return;
6768 }
6769 const entry = { id, path };
6770 if (label) entry.label = label;
6771 const idx = vaults.findIndex((x) => x && String(x.id) === id);
6772 if (idx >= 0) {
6773 vaults[idx] = Object.assign({}, vaults[idx], entry);
6774 } else {
6775 if (idInp && idInp.readOnly) {
6776 if (msg) {
6777 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
6778 msg.className = 'settings-msg err';
6779 }
6780 return;
6781 }
6782 vaults.push(entry);
6783 }
6784 if (ta) ta.value = JSON.stringify(vaults, null, 2);
6785 populateVaultListExistingSelect(vaults);
6786 const sel = el('vault-list-form-existing');
6787 if (sel) sel.value = '';
6788 fillVaultListFormFromExisting();
6789 const lc = el('vaults-list-container');
6790 if (lc && !isHostedHubFromSettings()) {
6791 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6792 }
6793 if (msg) {
6794 msg.textContent = 'Updated. Click Save vault list to persist.';
6795 msg.className = 'settings-msg ok';
6796 }
6797 };
6798 }
6799
6800 async function loadVaultsPanel() {
6801 const listContainer = el('vaults-list-container');
6802 const serverView = el('vaults-server-view');
6803 const vaultsJson = el('vaults-json');
6804 const accessText = el('vault-access-json');
6805 const scopeText = el('scope-json');
6806 const helpHostedBlock = el('vaults-help-hosted-block');
6807 const helpSelfBlock = el('vaults-help-self-block');
6808 const selfHostedEditors = el('vaults-self-hosted-editors');
6809 const yamlOnly = el('vaults-hub-yaml-only');
6810 const hostedCreate = el('vaults-hosted-create');
6811 const workspacePanel = el('vaults-hosted-workspace');
6812 const workspaceInput = el('workspace-owner-input');
6813 const workspaceMsg = el('workspace-save-msg');
6814 if (listContainer) listContainer.textContent = 'Loading…';
6815 if (serverView) serverView.textContent = 'Loading…';
6816 try {
6817 const settingsRes = await api('/api/v1/settings');
6818 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6819 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6820 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6821 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6822 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6823 const ownerFromSettings =
6824 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6825 ? String(settingsRes.workspace_owner_id).trim()
6826 : '';
6827 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
6828 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
6829 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
6830 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
6831 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
6832 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
6833 const hostedCreateMsg = el('vaults-hosted-create-msg');
6834 if (hostedCreateMsg && isHosted) {
6835 hostedCreateMsg.textContent = '';
6836 hostedCreateMsg.className = 'settings-msg';
6837 }
6838 if (workspaceMsg) {
6839 workspaceMsg.textContent = '';
6840 workspaceMsg.className = 'settings-msg';
6841 }
6842
6843 /** @type {{ vaults?: unknown[] }} */
6844 let vRes = { vaults: [] };
6845 try {
6846 vRes = await api('/api/v1/vaults');
6847 } catch (_) {
6848 vRes = { vaults: [] };
6849 }
6850 /** @type {{ access?: Record<string, unknown> }} */
6851 let aRes = { access: {} };
6852 try {
6853 aRes = await api('/api/v1/vault-access');
6854 } catch (_) {
6855 aRes = { access: {} };
6856 }
6857 /** @type {{ scope?: Record<string, unknown> }} */
6858 let sRes = { scope: {} };
6859 try {
6860 sRes = await api('/api/v1/scope');
6861 } catch (_) {
6862 sRes = { scope: {} };
6863 }
6864
6865 if (isHosted && workspaceInput) {
6866 try {
6867 const w = await api('/api/v1/workspace');
6868 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
6869 } catch (e) {
6870 workspaceInput.value = '';
6871 if (workspaceMsg) {
6872 workspaceMsg.textContent =
6873 (e && e.message) ||
6874 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
6875 workspaceMsg.className = 'settings-msg err';
6876 }
6877 }
6878 } else if (workspaceInput && !isHosted) {
6879 workspaceInput.value = '';
6880 }
6881 const vaults = vRes.vaults || [];
6882 if (serverView) {
6883 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
6884 const allowed = settingsRes.allowed_vault_ids;
6885 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
6886 if (isHosted) {
6887 serverView.innerHTML =
6888 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6889 escapeHtml(uid) +
6890 '</code> · <strong>Vaults:</strong> <code>' +
6891 escapeHtml(allowedStr) +
6892 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
6893 } else {
6894 const dataDir =
6895 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
6896 serverView.innerHTML =
6897 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
6898 escapeHtml(uid) +
6899 '</code> · <strong>Allowed vaults:</strong> <code>' +
6900 escapeHtml(allowedStr) +
6901 '</code> · <strong>Data:</strong> <code>' +
6902 dataDir +
6903 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
6904 }
6905 }
6906 if (listContainer) {
6907 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
6908 }
6909 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
6910 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
6911 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
6912
6913 const vaultListJsonDetails = el('vault-list-json-details');
6914 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
6915 const vaultAccessDetails = el('vault-access-json-details');
6916 if (vaultAccessDetails) vaultAccessDetails.open = false;
6917 const scopeJsonDetails = el('scope-json-details');
6918 if (scopeJsonDetails) scopeJsonDetails.open = false;
6919
6920 let roleIds = [];
6921 try {
6922 const ro = await api('/api/v1/roles');
6923 roleIds = Object.keys(ro.roles || {});
6924 } catch (_) {
6925 roleIds = [];
6926 }
6927 populateHostedTeamUserSelect(
6928 el('access-form-user-select'),
6929 roleIds,
6930 settingsRes.user_id,
6931 '— Choose a person —',
6932 );
6933 populateHostedTeamUserSelect(
6934 el('scope-form-user-select'),
6935 roleIds,
6936 settingsRes.user_id,
6937 '— Choose or type User ID below —',
6938 );
6939 const asel = el('access-form-user-select');
6940 if (asel) asel.value = '';
6941 const ssel = el('scope-form-user-select');
6942 if (ssel) ssel.value = '';
6943 accessFormToggleOtherInput();
6944 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
6945 renderAccessVaultCheckboxes(vaultIdsForForm);
6946 accessFormSyncCheckboxesFromAccessJson();
6947 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
6948
6949 const scopeVaultSelect = el('scope-form-vault-id');
6950 if (scopeVaultSelect) {
6951 scopeVaultSelect.innerHTML =
6952 vaults.length === 0
6953 ? '<option value="default">default</option>'
6954 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
6955 }
6956
6957 if (!isHosted) {
6958 populateVaultListExistingSelect(vaults);
6959 const vSel = el('vault-list-form-existing');
6960 if (vSel) vSel.value = '';
6961 fillVaultListFormFromExisting();
6962 }
6963 } catch (e) {
6964 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
6965 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
6966 }
6967 }
6968
6969 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
6970 function sanitizeNewHostedVaultId(raw) {
6971 const t = String(raw || '').trim();
6972 if (!t) return { error: 'Enter a vault id.' };
6973 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
6974 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
6975 s = s.slice(0, 64);
6976 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
6977 if (s === 'default') {
6978 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
6979 }
6980 return { id: s };
6981 }
6982
6983 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
6984 if (btnHostedVaultCreate) {
6985 btnHostedVaultCreate.onclick = async () => {
6986 const msgEl = el('vaults-hosted-create-msg');
6987 const inp = el('vaults-hosted-new-id');
6988 const setCreateVaultMsg = (text, isErr) => {
6989 if (!msgEl) return;
6990 msgEl.textContent = text;
6991 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
6992 };
6993 if (!isHostedHubFromSettings()) {
6994 setCreateVaultMsg('This action is only available on hosted Hub.', true);
6995 return;
6996 }
6997 if (!hubUserCanWriteNotes()) {
6998 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
6999 return;
7000 }
7001 const ws = lastBackupSettingsPayload;
7002 const ownerId =
7003 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
7004 ? String(ws.workspace_owner_id).trim()
7005 : '';
7006 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
7007 if (ownerId && me && me !== ownerId) {
7008 setCreateVaultMsg(
7009 '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.',
7010 true,
7011 );
7012 return;
7013 }
7014 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
7015 if (parsed.error) {
7016 setCreateVaultMsg(parsed.error, true);
7017 return;
7018 }
7019 const { id } = parsed;
7020 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
7021 setCreateVaultMsg('');
7022 try {
7023 const fresh = await api('/api/v1/settings');
7024 const allowed = fresh.allowed_vault_ids || [];
7025 if (Array.isArray(allowed) && allowed.includes(id)) {
7026 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the left rail to switch to it.', true);
7027 return;
7028 }
7029 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
7030 await api('/api/v1/notes', {
7031 method: 'POST',
7032 headers: { 'X-Vault-Id': id },
7033 body: JSON.stringify({
7034 path,
7035 body:
7036 'This note was created when you added the "' +
7037 id +
7038 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
7039 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
7040 }),
7041 });
7042 hubMarkSemanticIndexStaleForVault(id);
7043 const s = await api('/api/v1/settings');
7044 lastBackupSettingsPayload = s;
7045 if (s.role) window.__hubUserRole = String(s.role);
7046 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7047 applyHostedUiFromSettings(s);
7048 setCurrentVaultId(id);
7049 const sel = el('vault-switcher');
7050 if (sel) sel.value = id;
7051 loadFacets();
7052 loadNotes();
7053 loadProposals();
7054 await loadVaultsPanel();
7055 if (inp) inp.value = '';
7056 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the left rail to switch.', false);
7057 } catch (e) {
7058 setCreateVaultMsg(e.message || 'Could not create vault', true);
7059 }
7060 });
7061 };
7062 }
7063
7064 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
7065 if (btnSettingsDeleteVault) {
7066 btnSettingsDeleteVault.onclick = async () => {
7067 const msgEl = el('settings-delete-vault-msg');
7068 const setVaultDelMsg = (text, isErr) => {
7069 if (!msgEl) return;
7070 msgEl.textContent = text;
7071 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
7072 };
7073 if (!hubUserMayDeleteVault()) {
7074 setVaultDelMsg('You are not allowed to delete vaults.', true);
7075 return;
7076 }
7077 const sel = el('settings-delete-vault-select');
7078 const vaultId = (sel && sel.value) || '';
7079 const vaultIdTrim = String(vaultId).trim();
7080 if (!vaultIdTrim) {
7081 setVaultDelMsg('Choose a vault to delete.', true);
7082 return;
7083 }
7084 if (vaultIdTrim === 'default') {
7085 setVaultDelMsg('The default vault cannot be deleted.', true);
7086 return;
7087 }
7088 const confirmEl = el('settings-delete-vault-confirm');
7089 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
7090 if (confirmVal !== 'DELETE VAULT') {
7091 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
7092 return;
7093 }
7094 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
7095 setVaultDelMsg('', false);
7096 try {
7097 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
7098 method: 'DELETE',
7099 headers: { 'X-Vault-Id': vaultIdTrim },
7100 });
7101 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
7102 if (wasCurrent) {
7103 setCurrentVaultId('default');
7104 const vSel = el('vault-switcher');
7105 if (vSel) vSel.value = 'default';
7106 }
7107 const s = await api('/api/v1/settings');
7108 lastBackupSettingsPayload = s;
7109 if (s.role) window.__hubUserRole = String(s.role);
7110 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7111 applyHostedUiFromSettings(s);
7112 refreshDeleteProjectPanelVisibility();
7113 loadFacets();
7114 loadNotes();
7115 loadProposals();
7116 await loadVaultsPanel();
7117 if (confirmEl) confirmEl.value = '';
7118 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
7119 } catch (e) {
7120 setVaultDelMsg(e.message || 'Could not delete vault', true);
7121 }
7122 });
7123 };
7124 }
7125
7126 const btnScopeFormApply = el('btn-scope-form-apply');
7127 if (btnScopeFormApply) {
7128 btnScopeFormApply.onclick = () => {
7129 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
7130 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
7131 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
7132 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
7133 const msg = el('scope-form-msg');
7134 if (!userId) {
7135 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
7136 return;
7137 }
7138 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
7139 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
7140 const scopeText = el('scope-json');
7141 let scope = {};
7142 if (scopeText && scopeText.value) {
7143 try {
7144 scope = JSON.parse(scopeText.value);
7145 if (typeof scope !== 'object' || scope === null) scope = {};
7146 } catch (_) { scope = {}; }
7147 }
7148 if (!scope[userId]) scope[userId] = {};
7149 scope[userId][vaultId] = { projects, folders };
7150 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
7151 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
7152 };
7153 }
7154
7155 function isHostedHubFromSettings() {
7156 const s = lastBackupSettingsPayload;
7157 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
7158 }
7159
7160 const BULK_PRESET_EMPTY = '';
7161 const BULK_PRESET_CUSTOM = '__custom__';
7162
7163 function fillBulkPresetSelect(sel, items, includeCustom) {
7164 if (!sel) return;
7165 const preserve = sel.value;
7166 sel.innerHTML = '';
7167 const head = document.createElement('option');
7168 head.value = BULK_PRESET_EMPTY;
7169 head.textContent = '— Select or type below —';
7170 sel.appendChild(head);
7171 for (const item of items) {
7172 if (item == null || item === '') continue;
7173 const o = document.createElement('option');
7174 o.value = item;
7175 o.textContent = item;
7176 sel.appendChild(o);
7177 }
7178 if (includeCustom) {
7179 const c = document.createElement('option');
7180 c.value = BULK_PRESET_CUSTOM;
7181 c.textContent = 'Custom (type below)';
7182 sel.appendChild(c);
7183 }
7184 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7185 else sel.value = BULK_PRESET_EMPTY;
7186 }
7187
7188 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
7189 if (!selectEl || !inputEl) return;
7190 const p = (inputEl.value || '').trim();
7191 if (!p) {
7192 selectEl.value = BULK_PRESET_EMPTY;
7193 return;
7194 }
7195 let best = BULK_PRESET_CUSTOM;
7196 let bestLen = -1;
7197 for (const opt of selectEl.options) {
7198 const v = opt.value;
7199 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
7200 if (p === v || p.startsWith(v + '/')) {
7201 if (v.length > bestLen) {
7202 best = v;
7203 bestLen = v.length;
7204 }
7205 }
7206 }
7207 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
7208 }
7209
7210 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
7211 if (!selectEl || !inputEl) return;
7212 const p = (inputEl.value || '').trim();
7213 if (!p) {
7214 selectEl.value = BULK_PRESET_EMPTY;
7215 return;
7216 }
7217 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
7218 else selectEl.value = BULK_PRESET_CUSTOM;
7219 }
7220
7221 function wireBulkPathPresetPair(selectEl, inputEl) {
7222 if (!selectEl || !inputEl) return;
7223 selectEl.addEventListener('change', () => {
7224 const v = selectEl.value;
7225 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7226 });
7227 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
7228 }
7229
7230 function wireBulkSlugPresetPair(selectEl, inputEl) {
7231 if (!selectEl || !inputEl) return;
7232 selectEl.addEventListener('change', () => {
7233 const v = selectEl.value;
7234 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7235 });
7236 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
7237 }
7238
7239 let bulkPresetDropdownsToken = 0;
7240 async function refreshBulkDeletePresetDropdowns() {
7241 if (!token) return;
7242 const pathSelect = el('settings-bulk-path-prefix-preset');
7243 const delProjSelect = el('settings-bulk-delete-project-preset');
7244 const renameFromSelect = el('settings-bulk-rename-from-preset');
7245 const pathInput = el('settings-delete-prefix');
7246 const delProjInput = el('settings-delete-project-slug');
7247 const renameFromInput = el('settings-rename-project-from');
7248 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
7249 const my = ++bulkPresetDropdownsToken;
7250 let diskFolders = [];
7251 let facets = { projects: [], folders: [] };
7252 try {
7253 const [vf, fc] = await Promise.all([
7254 api('/api/v1/vault/folders'),
7255 api('/api/v1/notes/facets'),
7256 ]);
7257 if (my !== bulkPresetDropdownsToken) return;
7258 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
7259 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
7260 } catch (_) {
7261 if (my !== bulkPresetDropdownsToken) return;
7262 }
7263 const pathSet = new Set();
7264 for (const f of diskFolders) {
7265 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7266 }
7267 for (const f of facets.folders || []) {
7268 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7269 }
7270 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
7271 const pathPrefixes = ['inbox', ...rest];
7272 const projects = [
7273 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
7274 ].sort((a, b) => a.localeCompare(b));
7275
7276 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
7277 fillBulkPresetSelect(delProjSelect, projects, true);
7278 fillBulkPresetSelect(renameFromSelect, projects, true);
7279
7280 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
7281 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
7282 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
7283 }
7284
7285 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
7286 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
7287 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
7288
7289 const btnDeletePrefix = el('btn-settings-delete-prefix');
7290 if (btnDeletePrefix) {
7291 btnDeletePrefix.onclick = async () => {
7292 const msg = el('settings-delete-prefix-msg');
7293 const prefixEl = el('settings-delete-prefix');
7294 const confirmEl = el('settings-delete-confirm');
7295 if (!hubUserCanWriteNotes()) {
7296 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7297 return;
7298 }
7299 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
7300 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7301 if (!raw) {
7302 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
7303 return;
7304 }
7305 if (conf !== 'DELETE') {
7306 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7307 return;
7308 }
7309 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
7310 try {
7311 const out = await api('/api/v1/notes/delete-by-prefix', {
7312 method: 'POST',
7313 headers: { 'Content-Type': 'application/json' },
7314 body: JSON.stringify({ path_prefix: raw }),
7315 });
7316 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7317 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7318 if (confirmEl) confirmEl.value = '';
7319 if (msg) {
7320 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7321 msg.className = 'settings-msg ok';
7322 }
7323 if (typeof showToast === 'function') {
7324 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
7325 }
7326 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7327 loadNotes();
7328 loadFacets();
7329 if (typeof loadProposals === 'function') loadProposals();
7330 void refreshBulkDeletePresetDropdowns();
7331 } catch (e) {
7332 const m = e && e.message ? String(e.message) : String(e);
7333 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7334 }
7335 });
7336 };
7337 }
7338
7339 const btnDeleteByProject = el('btn-settings-delete-by-project');
7340 if (btnDeleteByProject) {
7341 btnDeleteByProject.onclick = async () => {
7342 const msg = el('settings-delete-by-project-msg');
7343 const slugEl = el('settings-delete-project-slug');
7344 const confirmEl = el('settings-delete-project-confirm');
7345 if (!hubUserCanWriteNotes()) {
7346 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7347 return;
7348 }
7349 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
7350 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7351 if (!slug) {
7352 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
7353 return;
7354 }
7355 if (conf !== 'DELETE') {
7356 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7357 return;
7358 }
7359 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
7360 try {
7361 const out = await api('/api/v1/notes/delete-by-project', {
7362 method: 'POST',
7363 headers: { 'Content-Type': 'application/json' },
7364 body: JSON.stringify({ project: slug }),
7365 });
7366 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7367 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7368 if (confirmEl) confirmEl.value = '';
7369 if (msg) {
7370 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7371 msg.className = 'settings-msg ok';
7372 }
7373 if (typeof showToast === 'function') {
7374 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
7375 }
7376 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7377 loadNotes();
7378 loadFacets();
7379 if (typeof loadProposals === 'function') loadProposals();
7380 void refreshBulkDeletePresetDropdowns();
7381 } catch (e) {
7382 const m = e && e.message ? String(e.message) : String(e);
7383 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7384 }
7385 });
7386 };
7387 }
7388
7389 const btnRenameProject = el('btn-settings-rename-project');
7390 if (btnRenameProject) {
7391 btnRenameProject.onclick = async () => {
7392 const msg = el('settings-rename-project-msg');
7393 const fromEl = el('settings-rename-project-from');
7394 const toEl = el('settings-rename-project-to');
7395 const confirmEl = el('settings-rename-project-confirm');
7396 if (!hubUserCanWriteNotes()) {
7397 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
7398 return;
7399 }
7400 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
7401 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
7402 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7403 if (!from || !to) {
7404 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
7405 return;
7406 }
7407 if (conf !== 'RENAME') {
7408 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
7409 return;
7410 }
7411 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
7412 try {
7413 const out = await api('/api/v1/notes/rename-project', {
7414 method: 'POST',
7415 headers: { 'Content-Type': 'application/json' },
7416 body: JSON.stringify({ from, to }),
7417 });
7418 const n = out && typeof out.updated === 'number' ? out.updated : 0;
7419 if (confirmEl) confirmEl.value = '';
7420 if (msg) {
7421 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
7422 msg.className = 'settings-msg ok';
7423 }
7424 if (typeof showToast === 'function') {
7425 showToast('Renamed project on ' + n + ' note(s).', false);
7426 }
7427 if (n > 0) hubMarkSemanticIndexStale();
7428 loadNotes();
7429 loadFacets();
7430 void refreshBulkDeletePresetDropdowns();
7431 } catch (e) {
7432 const m = e && e.message ? String(e.message) : String(e);
7433 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7434 }
7435 });
7436 };
7437 }
7438
7439 const btnVaultsSave = el('btn-vaults-save');
7440 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
7441 const msg = el('vaults-save-msg');
7442 if (isHostedHubFromSettings()) {
7443 if (msg) {
7444 msg.textContent =
7445 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
7446 msg.className = 'settings-msg err';
7447 }
7448 return;
7449 }
7450 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
7451 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
7452 try {
7453 const vaults = JSON.parse(raw);
7454 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
7455 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
7456 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7457 try {
7458 const s = await api('/api/v1/settings');
7459 applySettingsPayloadToHubChrome(s);
7460 } catch (_) {}
7461 loadVaultsPanel();
7462 } catch (e) {
7463 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7464 }
7465 });
7466 };
7467 function validateVaultAccess(access) {
7468 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
7469 for (const [uid, arr] of Object.entries(access)) {
7470 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
7471 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
7472 }
7473 return null;
7474 }
7475 function validateScope(scope) {
7476 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
7477 for (const [userId, perVault] of Object.entries(scope)) {
7478 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
7479 for (const [vaultId, entry] of Object.entries(perVault)) {
7480 if (typeof entry !== 'object' || entry === null) continue;
7481 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
7482 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
7483 }
7484 }
7485 return null;
7486 }
7487 const btnVaultAccessSave = el('btn-vault-access-save');
7488 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
7489 const msg = el('vault-access-save-msg');
7490 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
7491 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
7492 try {
7493 const access = JSON.parse(raw);
7494 const err = validateVaultAccess(access);
7495 if (err) throw new Error(err);
7496 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
7497 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7498 try {
7499 const s = await api('/api/v1/settings');
7500 applySettingsPayloadToHubChrome(s);
7501 } catch (_) {}
7502 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
7503 } catch (e) {
7504 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7505 }
7506 });
7507 };
7508
7509 const btnAccessFormApply = el('btn-access-form-apply');
7510 if (btnAccessFormApply) {
7511 btnAccessFormApply.onclick = () => {
7512 const msg = el('access-form-msg');
7513 const uid = getAccessFormResolvedUserId();
7514 if (!uid) {
7515 if (msg) {
7516 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
7517 msg.className = 'settings-msg err';
7518 }
7519 return;
7520 }
7521 const checked = Array.from(
7522 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
7523 ).map((c) => c.value);
7524 if (checked.length === 0) {
7525 if (msg) {
7526 msg.textContent = 'Tick at least one vault.';
7527 msg.className = 'settings-msg err';
7528 }
7529 return;
7530 }
7531 const access = parseVaultAccessFromTextarea();
7532 access[uid] = checked;
7533 const ta = el('vault-access-json');
7534 if (ta) ta.value = JSON.stringify(access, null, 2);
7535 refreshAccessRulesSummary(access);
7536 if (msg) {
7537 msg.textContent =
7538 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
7539 msg.className = 'settings-msg ok';
7540 }
7541 };
7542 }
7543
7544 const btnAccessFormRemove = el('btn-access-form-remove-user');
7545 if (btnAccessFormRemove) {
7546 btnAccessFormRemove.onclick = () => {
7547 const msg = el('access-form-msg');
7548 const uid = getAccessFormResolvedUserId();
7549 if (!uid) {
7550 if (msg) {
7551 msg.textContent = 'Choose a person to remove.';
7552 msg.className = 'settings-msg err';
7553 }
7554 return;
7555 }
7556 const access = parseVaultAccessFromTextarea();
7557 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
7558 if (msg) {
7559 msg.textContent = 'No rule for that user.';
7560 msg.className = 'settings-msg err';
7561 }
7562 return;
7563 }
7564 delete access[uid];
7565 const ta = el('vault-access-json');
7566 if (ta) ta.value = JSON.stringify(access, null, 2);
7567 refreshAccessRulesSummary(access);
7568 accessFormSyncCheckboxesFromAccessJson();
7569 if (msg) {
7570 msg.textContent =
7571 'Removed from draft rules only. Click Save vault access below to persist (required).';
7572 msg.className = 'settings-msg ok';
7573 }
7574 };
7575 }
7576
7577 const btnScopeSave = el('btn-scope-save');
7578 if (btnScopeSave) btnScopeSave.onclick = async () => {
7579 const msg = el('scope-save-msg');
7580 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
7581 const raw = (el('scope-json') && el('scope-json').value) || '{}';
7582 try {
7583 const scope = JSON.parse(raw);
7584 const err = validateScope(scope);
7585 if (err) throw new Error(err);
7586 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
7587 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7588 } catch (e) {
7589 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7590 }
7591 });
7592 };
7593
7594 const btnWorkspaceUseMe = el('btn-workspace-use-me');
7595 if (btnWorkspaceUseMe) {
7596 btnWorkspaceUseMe.onclick = async () => {
7597 const input = el('workspace-owner-input');
7598 const msg = el('workspace-save-msg');
7599 let uid =
7600 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7601 ? String(lastBackupSettingsPayload.user_id)
7602 : '';
7603 if (!uid) {
7604 try {
7605 const s = await api('/api/v1/settings');
7606 lastBackupSettingsPayload = s;
7607 uid = s.user_id != null ? String(s.user_id) : '';
7608 } catch (e) {
7609 if (msg) {
7610 msg.textContent = e.message || 'Could not load your User ID.';
7611 msg.className = 'settings-msg err';
7612 }
7613 return;
7614 }
7615 }
7616 if (input) input.value = uid;
7617 if (msg) {
7618 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
7619 msg.className = 'settings-msg ok';
7620 }
7621 };
7622 }
7623
7624 const btnWorkspaceSave = el('btn-workspace-save');
7625 if (btnWorkspaceSave) {
7626 btnWorkspaceSave.onclick = async () => {
7627 const msg = el('workspace-save-msg');
7628 const input = el('workspace-owner-input');
7629 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
7630 try {
7631 const raw = (input && input.value) || '';
7632 const trimmed = raw.trim();
7633 const owner_user_id = trimmed === '' ? null : trimmed;
7634 await api('/api/v1/workspace', {
7635 method: 'POST',
7636 body: JSON.stringify({ owner_user_id }),
7637 });
7638 if (msg) {
7639 msg.textContent = 'Saved.';
7640 msg.className = 'settings-msg ok';
7641 }
7642 } catch (e) {
7643 if (msg) {
7644 msg.textContent = e.message || 'Save failed';
7645 msg.className = 'settings-msg err';
7646 }
7647 }
7648 });
7649 };
7650 }
7651
7652 const btnWorkspaceClear = el('btn-workspace-clear');
7653 if (btnWorkspaceClear) {
7654 btnWorkspaceClear.onclick = async () => {
7655 const msg = el('workspace-save-msg');
7656 const input = el('workspace-owner-input');
7657 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
7658 try {
7659 await api('/api/v1/workspace', {
7660 method: 'POST',
7661 body: JSON.stringify({ owner_user_id: null }),
7662 });
7663 if (input) input.value = '';
7664 if (msg) {
7665 msg.textContent = 'Cleared — each person uses their own cloud space.';
7666 msg.className = 'settings-msg ok';
7667 }
7668 } catch (e) {
7669 if (msg) {
7670 msg.textContent = e.message || 'Clear failed';
7671 msg.className = 'settings-msg err';
7672 }
7673 }
7674 });
7675 };
7676 }
7677
7678 async function loadInvitesList() {
7679 const listEl = el('invites-pending-list');
7680 if (!listEl) return;
7681 listEl.textContent = 'Loading…';
7682 try {
7683 const out = await api('/api/v1/invites');
7684 const invites = out.invites || [];
7685 if (invites.length === 0) {
7686 listEl.textContent = 'No pending invites. Create a link above.';
7687 } else {
7688 listEl.innerHTML = invites.map((inv) => {
7689 const tokenShort = inv.token.slice(0, 12) + '…';
7690 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
7691 return '<div class="team-role-row invite-row">' +
7692 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
7693 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
7694 '</div>';
7695 }).join('');
7696 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
7697 btn.onclick = async () => {
7698 const t = btn.dataset.token;
7699 if (!t) return;
7700 try {
7701 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
7702 loadInvitesList();
7703 } catch (e) {
7704 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
7705 }
7706 };
7707 });
7708 }
7709 } catch (e) {
7710 listEl.textContent = 'Could not load: ' + (e.message || '');
7711 }
7712 }
7713
7714 const btnInviteCreate = el('btn-invite-create');
7715 const inviteLinkBlock = el('invite-link-block');
7716 const inviteLinkUrl = el('invite-link-url');
7717 const inviteCreateMsg = el('invite-create-msg');
7718 if (btnInviteCreate) {
7719 btnInviteCreate.onclick = async () => {
7720 const roleSelect = el('invite-role');
7721 const role = (roleSelect && roleSelect.value) || 'editor';
7722 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
7723 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
7724 try {
7725 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
7726 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
7727 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
7728 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
7729 loadInvitesList();
7730 } catch (e) {
7731 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
7732 }
7733 });
7734 };
7735 }
7736 const btnInviteCopy = el('btn-invite-copy');
7737 if (btnInviteCopy && inviteLinkUrl) {
7738 btnInviteCopy.onclick = () => {
7739 inviteLinkUrl.select();
7740 if (navigator.clipboard && navigator.clipboard.writeText) {
7741 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
7742 if (typeof showToast === 'function') showToast('Link copied.');
7743 }).catch(() => {});
7744 }
7745 };
7746 }
7747
7748 function syncTeamAddEvaluatorMayApproveVisibility() {
7749 const wrap = el('team-add-evaluator-may-approve-wrap');
7750 const sel = el('team-role');
7751 if (!wrap || !sel) return;
7752 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
7753 }
7754 const teamRoleSelect = el('team-role');
7755 if (teamRoleSelect) {
7756 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
7757 syncTeamAddEvaluatorMayApproveVisibility();
7758 }
7759
7760 async function loadTeamRolesList() {
7761 const listEl = el('team-roles-list');
7762 if (!listEl) return;
7763 listEl.textContent = 'Loading…';
7764 try {
7765 const out = await api('/api/v1/roles');
7766 const roles = out.roles || {};
7767 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
7768 const entries = Object.entries(roles);
7769 listEl.innerHTML = '';
7770 if (entries.length === 0) {
7771 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
7772 return;
7773 }
7774 for (const [uid, role] of entries) {
7775 const row = document.createElement('div');
7776 row.className = 'team-role-row team-role-row-flex';
7777 const label = document.createElement('span');
7778 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
7779 row.appendChild(label);
7780 if (role === 'evaluator') {
7781 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
7782 const chk = document.createElement('input');
7783 chk.type = 'checkbox';
7784 chk.title = 'May approve proposals';
7785 chk.checked = Boolean(mayMap[uid]);
7786 chk.addEventListener('change', async () => {
7787 chk.disabled = true;
7788 try {
7789 await api('/api/v1/roles/evaluator-may-approve', {
7790 method: 'POST',
7791 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7792 });
7793 } catch (err) {
7794 chk.checked = !chk.checked;
7795 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7796 } finally {
7797 chk.disabled = false;
7798 }
7799 });
7800 const lab = document.createElement('label');
7801 lab.className = 'team-evaluator-approve-inline';
7802 lab.appendChild(chk);
7803 const sp = document.createElement('span');
7804 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7805 lab.appendChild(sp);
7806 row.appendChild(lab);
7807 }
7808 listEl.appendChild(row);
7809 }
7810 } catch (e) {
7811 listEl.textContent = 'Could not load: ' + (e.message || '');
7812 }
7813 }
7814
7815 const btnTeamUserUseMe = el('btn-team-user-use-me');
7816 if (btnTeamUserUseMe) {
7817 btnTeamUserUseMe.onclick = async () => {
7818 const userIdInput = el('team-user-id');
7819 const msgEl = el('team-save-msg');
7820 let uid =
7821 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7822 ? String(lastBackupSettingsPayload.user_id)
7823 : '';
7824 if (!uid) {
7825 try {
7826 const s = await api('/api/v1/settings');
7827 lastBackupSettingsPayload = s;
7828 uid = s.user_id != null ? String(s.user_id) : '';
7829 } catch (e) {
7830 if (msgEl) {
7831 msgEl.textContent = e.message || 'Could not load your User ID.';
7832 msgEl.className = 'settings-msg err';
7833 }
7834 return;
7835 }
7836 }
7837 if (userIdInput) userIdInput.value = uid;
7838 if (msgEl) {
7839 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
7840 msgEl.className = 'settings-msg';
7841 }
7842 };
7843 }
7844
7845 const btnTeamSave = el('btn-team-save');
7846 if (btnTeamSave) {
7847 btnTeamSave.onclick = async () => {
7848 const userIdInput = el('team-user-id');
7849 const roleSelect = el('team-role');
7850 const msgEl = el('team-save-msg');
7851 const userId = (userIdInput && userIdInput.value || '').trim();
7852 const role = (roleSelect && roleSelect.value) || 'editor';
7853 if (!userId) {
7854 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
7855 return;
7856 }
7857 if (msgEl) msgEl.textContent = '';
7858 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
7859 try {
7860 const body = { user_id: userId, role };
7861 if (role === 'evaluator') {
7862 const cb = el('team-add-evaluator-may-approve');
7863 body.evaluator_may_approve = Boolean(cb && cb.checked);
7864 }
7865 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
7866 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
7867 userIdInput.value = '';
7868 loadTeamRolesList();
7869 } catch (e) {
7870 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
7871 }
7872 });
7873 };
7874 }
7875
7876 const currentAccent = () => {
7877 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
7878 if (inline) return inline;
7879 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
7880 return fromCss || DEFAULT_ACCENT;
7881 };
7882 function accentStringToHex6(str) {
7883 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
7884 const t = str.trim();
7885 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
7886 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
7887 const a = t.slice(1);
7888 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
7889 }
7890 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
7891 if (m) {
7892 return (
7893 '#' +
7894 [1, 2, 3]
7895 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
7896 .join('')
7897 ).toLowerCase();
7898 }
7899 return DEFAULT_ACCENT;
7900 }
7901 function updateAccentCustomHexLabel(hex6) {
7902 const out = el('accent-custom-hex');
7903 if (out && hex6) out.textContent = String(hex6).toUpperCase();
7904 }
7905 function setAccentRuntimeOnly(hex) {
7906 if (!hex) return;
7907 document.documentElement.style.setProperty('--accent', hex);
7908 updateAccentCustomHexLabel(accentStringToHex6(hex));
7909 }
7910 let accentIroPicker = null;
7911 let accentIroSuppressChange = false;
7912 function ensureAccentIroPicker() {
7913 if (accentIroPicker) return accentIroPicker;
7914 const mount = el('accent-iro-root');
7915 const Iro = typeof window !== 'undefined' && window.iro;
7916 if (!mount || !Iro || !Iro.ColorPicker) return null;
7917 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
7918 const br = (brRaw && brRaw.trim()) || '';
7919 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
7920 accentIroPicker = new Iro.ColorPicker(mount, {
7921 width: 280,
7922 color: accentStringToHex6(currentAccent()),
7923 borderWidth: 1,
7924 borderColor,
7925 layout: [
7926 { component: Iro.ui.Box, options: {} },
7927 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
7928 ],
7929 });
7930 accentIroPicker.on('color:change', (color) => {
7931 if (accentIroSuppressChange) return;
7932 setAccentRuntimeOnly(color.hexString);
7933 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
7934 });
7935 accentIroPicker.on('input:end', () => {
7936 if (accentIroSuppressChange) return;
7937 const h = accentIroPicker.color.hexString;
7938 if (h) applyAccent(h);
7939 });
7940 return accentIroPicker;
7941 }
7942 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
7943 function setAccentPickerColor(picker, hexNorm) {
7944 if (!picker || !hexNorm) return;
7945 const col = picker.color;
7946 if (col && typeof col.set === 'function') {
7947 col.set(hexNorm);
7948 return;
7949 }
7950 if (typeof picker.setColor === 'function') {
7951 try {
7952 picker.setColor(hexNorm, { silent: true });
7953 } catch (_) {
7954 picker.setColor(hexNorm);
7955 }
7956 }
7957 }
7958 function paintAccentSwatches() {
7959 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7960 const hex = btn.dataset.accent;
7961 if (hex) btn.style.backgroundColor = hex;
7962 });
7963 }
7964 paintAccentSwatches();
7965 document.querySelectorAll('.accent-swatch').forEach((btn) => {
7966 btn.addEventListener('click', () => {
7967 const hex = btn.dataset.accent;
7968 if (hex) {
7969 applyAccent(hex);
7970 const norm = accentStringToHex6(hex);
7971 document.querySelectorAll('.accent-swatch').forEach((b) => {
7972 const bh = b.dataset.accent;
7973 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7974 });
7975 ensureAccentIroPicker();
7976 if (accentIroPicker) {
7977 accentIroSuppressChange = true;
7978 try {
7979 setAccentPickerColor(accentIroPicker, norm);
7980 } finally {
7981 accentIroSuppressChange = false;
7982 }
7983 }
7984 updateAccentCustomHexLabel(norm);
7985 }
7986 });
7987 });
7988 ensureAccentIroPicker();
7989 function syncAccentUI() {
7990 const norm = accentStringToHex6(currentAccent());
7991 document.querySelectorAll('.accent-swatch').forEach((b) => {
7992 const bh = b.dataset.accent;
7993 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
7994 });
7995 ensureAccentIroPicker();
7996 if (accentIroPicker) {
7997 accentIroSuppressChange = true;
7998 try {
7999 setAccentPickerColor(accentIroPicker, norm);
8000 } finally {
8001 accentIroSuppressChange = false;
8002 }
8003 }
8004 updateAccentCustomHexLabel(norm);
8005 }
8006 function currentTheme() {
8007 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
8008 }
8009 function syncThemeUI() {
8010 const theme = currentTheme();
8011 document.querySelectorAll('.theme-btn').forEach((btn) => {
8012 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
8013 });
8014 }
8015 function syncColorPaletteUI() {
8016 const p = currentColorPalette();
8017 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
8018 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
8019 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
8020 });
8021 }
8022 const dashboardThemeGrid = el('dashboard-theme-grid');
8023 if (dashboardThemeGrid) {
8024 dashboardThemeGrid.addEventListener('click', (ev) => {
8025 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
8026 if (!card || !dashboardThemeGrid.contains(card)) return;
8027 const pid = card.dataset.palette;
8028 if (pid == null) return;
8029 applyColorPalette(pid);
8030 syncColorPaletteUI();
8031 });
8032 }
8033 document.querySelectorAll('.theme-btn').forEach((btn) => {
8034 btn.addEventListener('click', () => {
8035 const theme = btn.dataset.theme;
8036 if (theme) {
8037 applyTheme(theme);
8038 syncThemeUI();
8039 }
8040 });
8041 });
8042 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
8043 if (scrollDashColorsBtn) {
8044 scrollDashColorsBtn.addEventListener('click', () => {
8045 const target = el('settings-dashboard-color-theme');
8046 if (target && target.scrollIntoView) {
8047 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
8048 }
8049 });
8050 }
8051
8052 el('btn-settings-sync').onclick = async () => {
8053 const syncBtn = el('btn-settings-sync');
8054 const msg = el('settings-sync-msg');
8055 msg.textContent = 'Syncing…';
8056 msg.className = 'settings-msg';
8057 const s = lastBackupSettingsPayload;
8058 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
8059 const hostedPath = isHosted && s.github_connect_available;
8060 let opts = { method: 'POST' };
8061 if (hostedPath) {
8062 const slug =
8063 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
8064 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
8065 normalizeGithubRepoSlug(s.repo);
8066 if (!slug) {
8067 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
8068 msg.className = 'settings-msg err';
8069 return;
8070 }
8071 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
8072 opts.body = JSON.stringify({ repo: slug });
8073 }
8074 setButtonBusy(syncBtn, true, 'Backing up…');
8075 try {
8076 const result = await api('/api/v1/vault/sync', opts);
8077 msg.textContent = result.message || 'Done.';
8078 const initBtnOk = el('btn-vault-git-init');
8079 if (initBtnOk) initBtnOk.classList.add('hidden');
8080 if (hostedPath && s) {
8081 const refreshed = await api('/api/v1/settings');
8082 lastBackupSettingsPayload = refreshed;
8083 const vg = refreshed.vault_git || {};
8084 let gitText = 'Not configured';
8085 if (vg.enabled && vg.has_remote) {
8086 gitText = 'Configured';
8087 if (vg.auto_commit) gitText += ' (auto-commit on)';
8088 if (vg.auto_push) gitText += ', auto-push on';
8089 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8090 el('settings-git-status').textContent = gitText;
8091 const step4 = document.getElementById('setup-step-4');
8092 if (step4) {
8093 const done = !!(vg.enabled && vg.has_remote);
8094 step4.classList.toggle('setup-step-done', done);
8095 const icon = step4.querySelector('.setup-step-icon');
8096 if (icon) icon.textContent = done ? '✓' : '';
8097 }
8098 }
8099 } catch (e) {
8100 msg.textContent = e.message || 'Sync failed';
8101 msg.className = 'settings-msg err';
8102 const initBtn = el('btn-vault-git-init');
8103 if (initBtn) {
8104 const st = lastBackupSettingsPayload;
8105 const hosted =
8106 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
8107 const needInit =
8108 e.code === 'GIT_NOT_INITIALIZED' ||
8109 /not a Git repository/i.test(e.message || '');
8110 initBtn.classList.toggle('hidden', hosted || !needInit);
8111 }
8112 } finally {
8113 setButtonBusy(syncBtn, false);
8114 const st = lastBackupSettingsPayload;
8115 if (syncBtn && st) {
8116 const vg = st.vault_git || {};
8117 const vd = st.vault_path_display || '';
8118 const ih = (vd + '').toLowerCase() === 'canister';
8119 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
8120 }
8121 }
8122 };
8123 const btnVaultGitInit = el('btn-vault-git-init');
8124 if (btnVaultGitInit) {
8125 btnVaultGitInit.onclick = async () => {
8126 const msg = el('settings-sync-msg');
8127 msg.textContent = 'Initializing Git…';
8128 msg.className = 'settings-msg';
8129 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
8130 try {
8131 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
8132 msg.textContent = out.message || 'Git initialized. Try Back up now.';
8133 msg.className = 'settings-msg ok';
8134 btnVaultGitInit.classList.add('hidden');
8135 } catch (e) {
8136 msg.textContent = e.message || 'Git init failed';
8137 msg.className = 'settings-msg err';
8138 }
8139 });
8140 };
8141 }
8142 const saveSetupBtn = el('btn-settings-save');
8143 if (saveSetupBtn) {
8144 saveSetupBtn.onclick = async () => {
8145 const msg = el('settings-save-msg');
8146 if (msg) {
8147 msg.textContent = 'Saving…';
8148 msg.className = 'settings-msg';
8149 }
8150 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
8151 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
8152 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
8153 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
8154 try {
8155 await api('/api/v1/setup', {
8156 method: 'POST',
8157 body: JSON.stringify({
8158 vault_path: vault_path || undefined,
8159 vault_git: { enabled, remote: remote || undefined },
8160 }),
8161 });
8162 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.' : '');
8163 if (msg) {
8164 msg.textContent = successText;
8165 msg.className = 'settings-msg ok';
8166 }
8167 if (typeof showToast === 'function') showToast('Setup saved.');
8168 api('/api/v1/settings').then((s) => {
8169 const vd = s.vault_path_display || '—';
8170 const isHostedNow = (vd + '').toLowerCase() === 'canister';
8171 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
8172 el('settings-vault-display').textContent = vd;
8173 const configureSection = el('settings-configure-backup-section');
8174 const configureHr = el('settings-hr-configure');
8175 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
8176 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
8177 const vg = s.vault_git || {};
8178 let gitText = 'Not configured';
8179 if (vg.enabled && vg.has_remote) {
8180 gitText = 'Configured';
8181 if (vg.auto_commit) gitText += ' (auto-commit on)';
8182 if (vg.auto_push) gitText += ', auto-push on';
8183 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8184 el('settings-git-status').textContent = gitText;
8185 const syncBtn = el('btn-settings-sync');
8186 const isAdmin = s.role === 'admin';
8187 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
8188 if (msg) {
8189 msg.textContent = successText;
8190 msg.className = 'settings-msg ok';
8191 }
8192 }).catch(() => {});
8193 } catch (e) {
8194 const errMsg = e.message || 'Save failed';
8195 if (msg) {
8196 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
8197 ? 'Only admins can save setup. Your role is shown under Status above.'
8198 : errMsg;
8199 msg.className = 'settings-msg err';
8200 }
8201 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
8202 }
8203 });
8204 };
8205 }
8206
8207 function defaultFullPath() {
8208 const sel = el('full-path-folder');
8209 const folder =
8210 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8211 return folder + '/note-' + Date.now() + '.md';
8212 }
8213
8214 let fullPathFolderLoadToken = 0;
8215 async function refreshFullPathFolderSelect() {
8216 const sel = el('full-path-folder');
8217 if (!sel || !token) return;
8218 const my = ++fullPathFolderLoadToken;
8219 let folders = ['inbox'];
8220 try {
8221 const data = await api('/api/v1/vault/folders');
8222 if (my !== fullPathFolderLoadToken) return;
8223 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8224 } catch (_) {
8225 if (my !== fullPathFolderLoadToken) return;
8226 }
8227 lastVaultFoldersForCreate = folders.slice();
8228 const preserve = sel.value;
8229 sel.innerHTML = '';
8230 for (const f of folders) {
8231 const o = document.createElement('option');
8232 o.value = f;
8233 o.textContent = f;
8234 sel.appendChild(o);
8235 }
8236 const custom = document.createElement('option');
8237 custom.value = '__custom__';
8238 custom.textContent = 'Custom (type path below)';
8239 sel.appendChild(custom);
8240 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8241 else sel.value = folders[0] || 'inbox';
8242 refreshFullCreateSubrootSelect();
8243 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
8244 }
8245
8246 let importVaultFolderLoadToken = 0;
8247 async function refreshImportVaultFolderSelect() {
8248 const sel = el('import-vault-folder');
8249 if (!sel || !token) return;
8250 const my = ++importVaultFolderLoadToken;
8251 let folders = ['inbox'];
8252 try {
8253 const data = await api('/api/v1/vault/folders');
8254 if (my !== importVaultFolderLoadToken) return;
8255 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8256 } catch (_) {
8257 if (my !== importVaultFolderLoadToken) return;
8258 }
8259 lastVaultFoldersForCreate = folders.slice();
8260 const preserve = sel.value;
8261 sel.innerHTML = '';
8262 for (const f of folders) {
8263 const o = document.createElement('option');
8264 o.value = f;
8265 o.textContent = f;
8266 sel.appendChild(o);
8267 }
8268 const custom = document.createElement('option');
8269 custom.value = '__custom__';
8270 custom.textContent = 'Custom (type path below)';
8271 sel.appendChild(custom);
8272 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8273 else sel.value = folders[0] || 'inbox';
8274 refreshImportCreateSubrootSelect();
8275 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
8276 }
8277
8278 function syncFolderSelectToPathInput() {
8279 const pathInput = el('full-path');
8280 const sel = el('full-path-folder');
8281 if (!pathInput || !sel) return;
8282 const p = pathInput.value.trim();
8283 if (!p) return;
8284 let best = '__custom__';
8285 let bestLen = -1;
8286 for (const opt of sel.options) {
8287 const v = opt.value;
8288 if (v === '__custom__') continue;
8289 if (p === v || p.startsWith(v + '/')) {
8290 if (v.length > bestLen) {
8291 best = v;
8292 bestLen = v.length;
8293 }
8294 }
8295 }
8296 sel.value = bestLen >= 0 ? best : '__custom__';
8297 }
8298
8299 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
8300 function syncFullProjectFromPath() {
8301 const pi = el('full-path');
8302 const fp = el('full-project');
8303 if (!pi || !fp) return;
8304 const slug = projectSlugFromProjectsPath(pi.value.trim());
8305 if (slug) {
8306 fp.value = slug;
8307 fp.readOnly = true;
8308 fp.title = 'Derived from vault path projects/' + slug + '/';
8309 } else {
8310 fp.readOnly = false;
8311 fp.removeAttribute('title');
8312 }
8313 updateFullPathProjectTypoHint();
8314 }
8315
8316 function updateFullPathProjectTypoHint() {
8317 const pi = el('full-path');
8318 const hint = el('full-path-project-typo-hint');
8319 const fixBtn = el('btn-full-path-fix-typo');
8320 if (!pi || !hint) return;
8321 const raw = pi.value.trim();
8322 const sug = projectsPathTypoSuggestion(raw);
8323 if (sug) {
8324 hint.textContent =
8325 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
8326 hint.className = 'muted small detail-project-hint warn';
8327 hint.classList.remove('hidden');
8328 if (fixBtn) {
8329 fixBtn.classList.remove('hidden');
8330 fixBtn.onclick = () => {
8331 pi.value = sug;
8332 syncFolderSelectToPathInput();
8333 syncFullCreatePickersFromPath();
8334 syncFullProjectFromPath();
8335 scheduleFullCreateSimilarHint();
8336 };
8337 }
8338 } else {
8339 hint.textContent = '';
8340 hint.className = 'muted small detail-project-hint hidden';
8341 hint.classList.add('hidden');
8342 if (fixBtn) {
8343 fixBtn.classList.add('hidden');
8344 fixBtn.onclick = null;
8345 }
8346 }
8347 }
8348
8349 const fullPathFolderEl = () => el('full-path-folder');
8350 const fullPathInputEl = () => el('full-path');
8351 if (fullPathFolderEl() && fullPathInputEl()) {
8352 fullPathFolderEl().addEventListener('change', () => {
8353 const sel = fullPathFolderEl();
8354 if (!sel || sel.value === '__custom__') return;
8355 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
8356 syncFullCreatePickersFromPath();
8357 syncFullProjectFromPath();
8358 updateFullPathProjectTypoHint();
8359 scheduleFullCreateSimilarHint();
8360 });
8361 fullPathInputEl().addEventListener('input', () => {
8362 syncFolderSelectToPathInput();
8363 syncFullCreatePickersFromPath();
8364 syncFullProjectFromPath();
8365 updateFullPathProjectTypoHint();
8366 scheduleFullCreateSimilarHint();
8367 });
8368 fullPathInputEl().addEventListener('change', () => {
8369 syncFullCreatePickersFromPath();
8370 updateFullCreateSimilarInlineHint();
8371 });
8372 }
8373
8374 const fullCreateProjectSlugEl = el('full-create-project-slug');
8375 const fullCreateProjectSubEl = el('full-create-project-subroot');
8376 if (fullCreateProjectSlugEl) {
8377 fullCreateProjectSlugEl.addEventListener('change', () => {
8378 refreshFullCreateSubrootSelect();
8379 updateFullCreatePathLayoutVisibility();
8380 const v = fullCreateProjectSlugEl.value;
8381 const pi = el('full-path');
8382 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
8383 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
8384 syncFolderSelectToPathInput();
8385 syncFullProjectFromPath();
8386 updateFullPathProjectTypoHint();
8387 scheduleFullCreateSimilarHint();
8388 });
8389 }
8390 if (fullCreateProjectSubEl) {
8391 fullCreateProjectSubEl.addEventListener('change', () => {
8392 composeFullPathFromCreatePickers();
8393 syncFolderSelectToPathInput();
8394 syncFullProjectFromPath();
8395 updateFullPathProjectTypoHint();
8396 scheduleFullCreateSimilarHint();
8397 });
8398 }
8399
8400 const importCreateProjectSlugEl = el('import-create-project-slug');
8401 const importCreateProjectSubEl = el('import-create-project-subroot');
8402 const importVaultFolderEl = el('import-vault-folder');
8403 const importOutputDirEl = el('import-output-dir');
8404 if (importVaultFolderEl) {
8405 importVaultFolderEl.addEventListener('change', () => {
8406 const sel = importVaultFolderEl;
8407 const out = el('import-output-dir');
8408 if (!sel || !out || sel.value === '__custom__') return;
8409 out.value = sel.value;
8410 syncImportPickersFromOutputDir();
8411 });
8412 }
8413 if (importOutputDirEl) {
8414 importOutputDirEl.addEventListener('input', () => {
8415 syncImportFolderSelectToOutputDir();
8416 syncImportPickersFromOutputDir();
8417 });
8418 }
8419 if (importCreateProjectSlugEl) {
8420 importCreateProjectSlugEl.addEventListener('change', () => {
8421 refreshImportCreateSubrootSelect();
8422 updateImportPathLayoutVisibility();
8423 const v = importCreateProjectSlugEl.value;
8424 const out = el('import-output-dir');
8425 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
8426 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
8427 const sel = el('import-vault-folder');
8428 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8429 }
8430 syncImportFolderSelectToOutputDir();
8431 syncImportPickersFromOutputDir();
8432 });
8433 }
8434 if (importCreateProjectSubEl) {
8435 importCreateProjectSubEl.addEventListener('change', () => {
8436 composeImportOutputDirFromPickers();
8437 syncImportFolderSelectToOutputDir();
8438 syncImportPickersFromOutputDir();
8439 });
8440 }
8441
8442 document.querySelectorAll('.modal-tab').forEach((t) => {
8443 t.onclick = () => {
8444 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
8445 t.classList.add('active');
8446 const tab = t.dataset.createTab;
8447 el('create-quick').classList.toggle('hidden', tab !== 'quick');
8448 el('create-full').classList.toggle('hidden', tab !== 'full');
8449 if (tab === 'full') {
8450 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
8451 void (async () => {
8452 await refreshFullPathFolderSelect();
8453 if (!lastHubFacets) {
8454 try {
8455 lastHubFacets = await fetchFacetsResolved();
8456 } catch (_) {}
8457 }
8458 hydrateFullCreateProjectSlugSelect(lastHubFacets);
8459 const pi = el('full-path');
8460 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
8461 else syncFolderSelectToPathInput();
8462 syncFullCreatePickersFromPath();
8463 syncFullProjectFromPath();
8464 updateFullPathProjectTypoHint();
8465 updateFullCreateSimilarInlineHint();
8466 })();
8467 }
8468 };
8469 });
8470
8471 el('btn-quick-save').onclick = async () => {
8472 const quickBtn = el('btn-quick-save');
8473 const body = el('quick-body').value.trim();
8474 const msg = el('create-msg-quick');
8475 if (!body) {
8476 msg.textContent = 'Enter some text.';
8477 msg.className = 'create-msg err';
8478 return;
8479 }
8480 const projectRaw = el('quick-project').value.trim();
8481 const pslug = normSlug(projectRaw);
8482 const today = ymd(new Date());
8483 const slug = 'hub_' + Date.now();
8484 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
8485 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
8486 await withButtonBusy(quickBtn, 'Saving…', async () => {
8487 try {
8488 await api('/api/v1/notes', {
8489 method: 'POST',
8490 body: stringifyNotePostPayload(path, body, {
8491 source: 'hub',
8492 date: today,
8493 title,
8494 ...(pslug && { project: pslug }),
8495 }),
8496 });
8497 hubMarkSemanticIndexStale();
8498 msg.textContent = 'Saved: ' + path;
8499 msg.className = 'create-msg ok';
8500 el('quick-body').value = '';
8501 loadFacets();
8502 loadNotes();
8503 closeCreateModal();
8504 } catch (e) {
8505 msg.textContent = e.message;
8506 msg.className = 'create-msg err';
8507 }
8508 });
8509 };
8510
8511 async function submitFullCreateNote() {
8512 const fullBtn = el('btn-full-save');
8513 const notePath = el('full-path').value.trim();
8514 const pathProjFull = projectSlugFromProjectsPath(notePath);
8515 const msg = el('create-msg-full');
8516 if (!notePath) {
8517 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
8518 msg.className = 'create-msg err';
8519 return;
8520 }
8521 const pathTypoSug = projectsPathTypoSuggestion(notePath);
8522 if (pathTypoSug) {
8523 msg.textContent =
8524 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
8525 pathTypoSug;
8526 msg.className = 'create-msg err';
8527 return;
8528 }
8529 if (!notePath.endsWith('.md')) {
8530 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
8531 msg.className = 'create-msg err';
8532 return;
8533 }
8534 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
8535 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
8536 const dest = notePath.replace(/\\/g, '/');
8537 if (src === dest) {
8538 msg.textContent =
8539 'When duplicating, pick a different path than the original (same path would overwrite the original).';
8540 msg.className = 'create-msg err';
8541 return;
8542 }
8543 }
8544 const slugFromPath = projectSlugFromProjectsPath(notePath);
8545 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
8546 const similarGuess =
8547 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
8548 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
8549 : null;
8550 if (similarGuess) {
8551 openFullCreateSimilarModal(notePath, similarGuess);
8552 return;
8553 }
8554 fullCreateSimilarOverrideOnce = false;
8555 const title = el('full-title').value.trim();
8556 const body = el('full-body').value;
8557 const project = pathProjFull || el('full-project').value.trim();
8558 const tags = el('full-tags').value.trim();
8559 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
8560 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
8561 const entityRaw = el('full-entity') && el('full-entity').value.trim();
8562 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
8563 const episode = el('full-episode') && el('full-episode').value.trim();
8564 const followsRaw = el('full-follows') && el('full-follows').value.trim();
8565 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
8566 const fm = {
8567 date: dateVal,
8568 ...(title && { title }),
8569 ...(project && { project }),
8570 ...(tags && { tags }),
8571 ...(causalChain && { causal_chain_id: causalChain }),
8572 ...(entity && entity.length && { entity }),
8573 ...(episode && { episode_id: episode }),
8574 ...(follows && { follows }),
8575 };
8576 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
8577 await withButtonBusy(fullBtn, savingLabel, async () => {
8578 try {
8579 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
8580 hubMarkSemanticIndexStale();
8581 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
8582 msg.className = 'create-msg ok';
8583 const dupSrc = pendingDuplicateDeleteSource;
8584 const delChk = el('duplicate-delete-after-save');
8585 const shouldDeleteOriginal =
8586 dupSrc &&
8587 dupSrc.path &&
8588 delChk &&
8589 delChk.checked &&
8590 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
8591 if (shouldDeleteOriginal) {
8592 try {
8593 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
8594 if (typeof showToast === 'function') showToast('Original note deleted');
8595 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
8596 const bcb = el('btn-detail-copy-body');
8597 if (bcb) bcb.classList.add('hidden');
8598 } catch (delErr) {
8599 if (typeof showToast === 'function') {
8600 showToast(
8601 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
8602 true,
8603 );
8604 }
8605 }
8606 }
8607 void refreshFullPathFolderSelect().then(() => {
8608 el('full-path').value = defaultFullPath();
8609 syncFolderSelectToPathInput();
8610 syncFullCreatePickersFromPath();
8611 syncFullProjectFromPath();
8612 updateFullCreateSimilarInlineHint();
8613 });
8614 el('full-title').value = '';
8615 el('full-body').value = '';
8616 el('full-project').value = '';
8617 el('full-tags').value = '';
8618 if (el('full-date')) el('full-date').value = '';
8619 if (el('full-causal-chain')) el('full-causal-chain').value = '';
8620 if (el('full-entity')) el('full-entity').value = '';
8621 if (el('full-episode')) el('full-episode').value = '';
8622 if (el('full-follows')) el('full-follows').value = '';
8623 loadFacets();
8624 loadNotes();
8625 closeCreateModal();
8626 } catch (e) {
8627 msg.textContent = e.message;
8628 msg.className = 'create-msg err';
8629 }
8630 });
8631 }
8632
8633 el('btn-full-save').onclick = () => {
8634 void submitFullCreateNote();
8635 };
8636
8637 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
8638 const modalSimilarClose = el('modal-create-similar-project-close');
8639 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
8640 const btnSimilarKeep = el('btn-modal-create-similar-keep');
8641 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
8642 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
8643 if (btnSimilarUseExisting) {
8644 btnSimilarUseExisting.onclick = () => {
8645 const path = fullCreateSimilarModalPendingPath;
8646 const slug = fullCreateSimilarModalSuggestedSlug;
8647 closeFullCreateSimilarModal();
8648 if (path && slug) {
8649 const pi = el('full-path');
8650 if (pi) {
8651 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
8652 syncFolderSelectToPathInput();
8653 syncFullCreatePickersFromPath();
8654 syncFullProjectFromPath();
8655 updateFullPathProjectTypoHint();
8656 updateFullCreateSimilarInlineHint();
8657 }
8658 }
8659 fullCreateSimilarOverrideOnce = false;
8660 void submitFullCreateNote();
8661 };
8662 }
8663 if (btnSimilarKeep) {
8664 btnSimilarKeep.onclick = () => {
8665 closeFullCreateSimilarModal();
8666 fullCreateSimilarOverrideOnce = true;
8667 void submitFullCreateNote();
8668 };
8669 }
8670
8671 function formatDetailReadBody(body, fm) {
8672 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8673 const keys = Object.keys(o);
8674 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
8675 if (keys.length === 0 && hubUserCanWriteNotes()) {
8676 text +=
8677 '\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).';
8678 }
8679 return text;
8680 }
8681
8682 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
8683 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
8684
8685 function videoExtToMime(url) {
8686 try {
8687 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
8688 return VIDEO_MIME_MAP[ext] || 'video/mp4';
8689 } catch (_) {
8690 var clean = url.split('?')[0].split('#')[0];
8691 var ext2 = clean.split('.').pop().toLowerCase();
8692 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
8693 }
8694 }
8695
8696 /**
8697 * Ensure standalone video URL lines are surrounded by blank lines in the raw
8698 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
8699 * mode joins adjacent lines (e.g. a video URL followed immediately by image
8700 * markdown) into a single <p>, which prevents the video-URL regex from matching.
8701 */
8702 function isolateVideoUrlLines(md) {
8703 // Match any line whose entire content is a bare https video URL.
8704 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
8705 // and after so marked always puts the URL in its own paragraph.
8706 return md.replace(
8707 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
8708 '\n$1$2\n'
8709 );
8710 }
8711
8712 /**
8713 * Replace bare video URLs (on their own line) with <video> elements.
8714 * Handles two forms that marked produces for a bare URL on its own paragraph:
8715 * 1. GFM autolink: <p><a href="URL">URL</a></p>
8716 * 2. Plain text: <p>URL</p>
8717 * Runs before DOMPurify so the sanitiser validates the output.
8718 */
8719 function expandVideoUrls(html) {
8720 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
8721
8722 // GFM autolink form: <p><a href="URL">...</a></p>
8723 var result = html.replace(
8724 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
8725 function (match, url) {
8726 if (!VIDEO_EXT_PAT.test(url)) return match;
8727 var mime = videoExtToMime(url);
8728 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8729 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8730 'Your browser does not support embedded video.</video>';
8731 }
8732 );
8733
8734 // Plain text form: <p>URL</p>
8735 result = result.replace(
8736 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
8737 function (match, url) {
8738 if (!VIDEO_EXT_PAT.test(url)) return match;
8739 var mime = videoExtToMime(url);
8740 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8741 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8742 'Your browser does not support embedded video.</video>';
8743 }
8744 );
8745
8746 return result;
8747 }
8748
8749 var SANITIZE_OPTS_NOTE = {
8750 ADD_TAGS: ['details', 'summary', 'video', 'source'],
8751 ADD_ATTR: ['controls', 'preload', 'type'],
8752 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
8753 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
8754 };
8755
8756 /**
8757 * Render markdown text as sanitised HTML.
8758 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
8759 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
8760 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
8761 */
8762 var _imageProxyToken = null;
8763 var _imageProxyTokenExp = 0;
8764
8765 async function getImageProxyToken() {
8766 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
8767 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8768 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8769 if (!jwt) return '';
8770 try {
8771 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
8772 headers: { authorization: 'Bearer ' + jwt },
8773 });
8774 if (!res.ok) return '';
8775 var data = await res.json();
8776 _imageProxyToken = data.token || '';
8777 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
8778 return _imageProxyToken;
8779 } catch (_) { return ''; }
8780 }
8781
8782 /**
8783 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
8784 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
8785 * Falls back to no rewrite if no cached image token is available yet.
8786 */
8787 function rewriteGitHubImageUrls(html) {
8788 var tok = _imageProxyToken || '';
8789 if (!tok) {
8790 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8791 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8792 }
8793 if (!tok) return html;
8794 var encodedTok = encodeURIComponent(tok);
8795 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8796 return html.replace(
8797 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8798 function (match, pre, rest) {
8799 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8800 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8801 }
8802 );
8803 }
8804
8805 function renderNoteMarkdownHtml(md) {
8806 try {
8807 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8808 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8809 var withVideo = expandVideoUrls(raw);
8810 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8811 return rewriteGitHubImageUrls(sanitised);
8812 }
8813 } catch (_) { /* fall through */ }
8814 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8815 }
8816
8817 /**
8818 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8819 */
8820 function buildNoteReadHtml(body, fm) {
8821 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8822 const keys = Object.keys(o);
8823 const bodyHtml = renderNoteMarkdownHtml(body || '');
8824 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8825 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8826 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
8827 : '';
8828 return (
8829 bodyHtml +
8830 '<details class="note-meta-block">' +
8831 '<summary>Metadata</summary>' +
8832 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
8833 emptyNote +
8834 '</details>'
8835 );
8836 }
8837
8838 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
8839 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
8840 'absolute_path',
8841 'body',
8842 'body_length',
8843 'byte_offset',
8844 'byte_offsets',
8845 'frontmatter',
8846 'line_range',
8847 'line_ranges',
8848 'mcp_resource_uri',
8849 'provider_payload',
8850 'raw_canister_payload',
8851 'resource_uri',
8852 'section_body',
8853 'section_body_length',
8854 'snippet',
8855 'snippets',
8856 ]);
8857
8858 function normalizeSectionSourcePathForUi(path) {
8859 const value = String(path || '').trim();
8860 if (!value) return '';
8861 if (value.includes('\\') || value.includes('\0')) return '';
8862 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
8863 if (value.split('/').some((part) => part === '..')) return '';
8864 return value;
8865 }
8866
8867 function sectionSourceEndpointForPath(path) {
8868 return '/api/v1/section-source?path=' + encodeURIComponent(path);
8869 }
8870
8871 function sectionSourcePayloadHasForbiddenKeys(value) {
8872 if (!value || typeof value !== 'object') return false;
8873 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
8874 for (const [key, child] of Object.entries(value)) {
8875 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
8876 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
8877 }
8878 return false;
8879 }
8880
8881 function normalizeSectionSourceForRender(data) {
8882 if (!data || typeof data !== 'object' || Array.isArray(data)) {
8883 throw new Error('INVALID_SECTION_SOURCE');
8884 }
8885 if (sectionSourcePayloadHasForbiddenKeys(data)) {
8886 throw new Error('INVALID_SECTION_SOURCE');
8887 }
8888 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
8889 throw new Error('INVALID_SECTION_SOURCE');
8890 }
8891 return {
8892 schema: SECTION_SOURCE_SCHEMA,
8893 path: String(data.path || ''),
8894 title: String(data.title || ''),
8895 truncated: data.truncated === true,
8896 sections: data.sections.map((section) => {
8897 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
8898 const normalized = {
8899 section_id: String(item.section_id || ''),
8900 heading_id: String(item.heading_id || ''),
8901 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
8902 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
8903 heading_text: String(item.heading_text || ''),
8904 child_section_ids: Array.isArray(item.child_section_ids)
8905 ? item.child_section_ids.map((childId) => String(childId))
8906 : [],
8907 body_available: item.body_available === true,
8908 body_returned: item.body_returned === true,
8909 snippet_returned: item.snippet_returned === true,
8910 };
8911 if (normalized.body_returned || normalized.snippet_returned) {
8912 throw new Error('INVALID_SECTION_SOURCE');
8913 }
8914 return normalized;
8915 }),
8916 };
8917 }
8918
8919 function resetDetailSectionSourceState() {
8920 hubSectionSourceSeq += 1;
8921 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
8922 }
8923
8924 function setSectionSourcePanelState(panel, state, message) {
8925 panel.className = 'section-source-panel section-source-panel-' + state;
8926 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
8927 panel.setAttribute('aria-label', 'Body-free section list');
8928 panel.setAttribute('aria-live', 'polite');
8929 panel.replaceChildren();
8930 const text = document.createElement('p');
8931 text.className = 'section-source-state';
8932 text.textContent = message;
8933 panel.appendChild(text);
8934 }
8935
8936 function sectionSourceErrorMessage(error) {
8937 const code = error && error.code ? String(error.code) : '';
8938 const message = error && error.message ? String(error.message) : '';
8939 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
8940 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
8941 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
8942 if (message === 'Unauthorized') return 'Sign in to view sections.';
8943 return 'Sections are unavailable right now.';
8944 }
8945
8946 function appendSectionSourceDebugRow(list, labelText, valueText) {
8947 const label = document.createElement('dt');
8948 label.textContent = labelText;
8949 const value = document.createElement('dd');
8950 value.textContent = valueText;
8951 list.append(label, value);
8952 }
8953
8954 function renderSectionSourceData(panel, source) {
8955 panel.className = 'section-source-panel';
8956 panel.setAttribute('role', 'region');
8957 panel.setAttribute('aria-label', 'Body-free section list');
8958 panel.setAttribute('aria-live', 'polite');
8959 panel.replaceChildren();
8960
8961 const header = document.createElement('div');
8962 header.className = 'section-source-header';
8963 const title = document.createElement('h3');
8964 title.textContent = 'Sections';
8965 const meta = document.createElement('p');
8966 meta.className = 'muted small';
8967 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
8968 header.append(title, meta);
8969 panel.appendChild(header);
8970
8971 if (source.truncated) {
8972 const truncated = document.createElement('p');
8973 truncated.className = 'section-source-state section-source-truncated';
8974 truncated.textContent = 'Section list is capped for display.';
8975 panel.appendChild(truncated);
8976 }
8977
8978 if (source.sections.length === 0) {
8979 const empty = document.createElement('p');
8980 empty.className = 'section-source-state';
8981 empty.textContent = 'No headings are available for this note.';
8982 panel.appendChild(empty);
8983 return;
8984 }
8985
8986 const list = document.createElement('ol');
8987 list.className = 'section-source-list';
8988 for (const section of source.sections) {
8989 const item = document.createElement('li');
8990 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
8991
8992 const heading = document.createElement('p');
8993 heading.className = 'section-source-heading';
8994 const levelBadge = document.createElement('span');
8995 levelBadge.className = 'section-source-level-label';
8996 levelBadge.textContent = 'H' + section.level;
8997 const headingText = document.createElement('span');
8998 headingText.className = 'section-source-heading-text';
8999 headingText.textContent = section.heading_text || '(Untitled section)';
9000 heading.append(levelBadge, headingText);
9001 item.appendChild(heading);
9002
9003 const detail = document.createElement('p');
9004 detail.className = 'section-source-detail muted small';
9005 detail.textContent = 'Heading level: H' + section.level;
9006 item.appendChild(detail);
9007
9008 const pathLine = document.createElement('p');
9009 pathLine.className = 'section-source-path muted small';
9010 pathLine.textContent =
9011 'Heading path: ' +
9012 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
9013 item.appendChild(pathLine);
9014
9015 const childLine = document.createElement('p');
9016 childLine.className = 'section-source-children muted small';
9017 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
9018 item.appendChild(childLine);
9019
9020 const debugDetails = document.createElement('details');
9021 debugDetails.className = 'section-source-debug muted small';
9022 const debugSummary = document.createElement('summary');
9023 debugSummary.textContent = 'IDs';
9024 const debugList = document.createElement('dl');
9025 debugList.className = 'section-source-debug-list';
9026 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
9027 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
9028 appendSectionSourceDebugRow(
9029 debugList,
9030 'Child IDs',
9031 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
9032 );
9033 debugDetails.append(debugSummary, debugList);
9034 item.appendChild(debugDetails);
9035
9036 list.appendChild(item);
9037 }
9038 panel.appendChild(list);
9039 }
9040
9041 async function loadSectionSourceForCurrentNote(actionsEl, button) {
9042 let panel = actionsEl.querySelector('[data-section-source-panel]');
9043 if (!panel) {
9044 panel = document.createElement('div');
9045 panel.dataset.sectionSourcePanel = 'true';
9046 actionsEl.appendChild(panel);
9047 }
9048 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
9049 if (!path) {
9050 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
9051 return;
9052 }
9053 const seq = ++hubSectionSourceSeq;
9054 const openPath = currentOpenNote.path;
9055 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
9056 if (button) {
9057 button.disabled = true;
9058 button.setAttribute('aria-expanded', 'true');
9059 }
9060 try {
9061 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
9062 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9063 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
9064 } catch (error) {
9065 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9066 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
9067 } finally {
9068 if (button && currentOpenNote && currentOpenNote.path === openPath) {
9069 button.disabled = false;
9070 }
9071 }
9072 }
9073
9074 function toggleSectionSourcePanel(actionsEl, button) {
9075 const panel = actionsEl.querySelector('[data-section-source-panel]');
9076 if (panel) {
9077 hubSectionSourceSeq += 1;
9078 panel.remove();
9079 if (button) button.setAttribute('aria-expanded', 'false');
9080 return;
9081 }
9082 void loadSectionSourceForCurrentNote(actionsEl, button);
9083 }
9084
9085 function createSectionSourceButton(actionsEl) {
9086 const sectionBtn = document.createElement('button');
9087 sectionBtn.type = 'button';
9088 sectionBtn.textContent = 'Sections';
9089 sectionBtn.className = 'btn-section-source';
9090 sectionBtn.setAttribute('aria-expanded', 'false');
9091 sectionBtn.setAttribute('aria-controls', 'detail-actions');
9092 sectionBtn.title = 'Show body-free section headings for this note';
9093 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
9094 return sectionBtn;
9095 }
9096
9097 function switchNoteToReadMode() {
9098 if (!currentOpenNote) return;
9099 resetDetailSectionSourceState();
9100 teardownDetailEditBodyLayout();
9101 const bodyEl = el('detail-body');
9102 const actionsEl = el('detail-actions');
9103 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
9104 bodyEl.className = 'note-rendered-body';
9105 actionsEl.innerHTML = '';
9106 attachNoteDetailReadActions(actionsEl);
9107 const bcbRead = el('btn-detail-copy-body');
9108 if (bcbRead) bcbRead.classList.remove('hidden');
9109 }
9110
9111 async function deleteOpenNote() {
9112 if (!currentOpenNote) return;
9113 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
9114 const p = currentOpenNote.path;
9115 try {
9116 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
9117 if (typeof showToast === 'function') showToast('Note deleted');
9118 hubMarkSemanticIndexStale();
9119 currentOpenNote = null;
9120 currentNotePathForCopy = '';
9121 resetDetailSectionSourceState();
9122 teardownDetailEditBodyLayout();
9123 hideDetailPanelChrome();
9124 el('btn-copy-path').classList.add('hidden');
9125 const bcbDel = el('btn-detail-copy-body');
9126 if (bcbDel) bcbDel.classList.add('hidden');
9127 loadNotes();
9128 loadFacets();
9129 } catch (e) {
9130 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
9131 }
9132 }
9133
9134 function attachNoteDetailReadActions(actionsEl) {
9135 const exportBtn = document.createElement('button');
9136 exportBtn.type = 'button';
9137 exportBtn.textContent = 'Export';
9138 exportBtn.onclick = () => exportCurrentNote('md');
9139 const sectionBtn = createSectionSourceButton(actionsEl);
9140
9141 if (hubUserCanWriteNotes()) {
9142 const editBtn = document.createElement('button');
9143 editBtn.type = 'button';
9144 editBtn.textContent = 'Edit';
9145 editBtn.onclick = () => switchNoteToEditMode();
9146 const dupBtn = document.createElement('button');
9147 dupBtn.type = 'button';
9148 dupBtn.textContent = 'Duplicate…';
9149 dupBtn.title =
9150 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
9151 dupBtn.onclick = () => {
9152 void openDuplicateNoteModal();
9153 };
9154 const proposeBtn = document.createElement('button');
9155 proposeBtn.type = 'button';
9156 proposeBtn.textContent = 'Propose change';
9157 proposeBtn.onclick = () => {
9158 if (!currentOpenNote) return;
9159 openCreateProposalModal({
9160 path: currentOpenNote.path,
9161 body: currentOpenNote.body || '',
9162 fromNote: true,
9163 });
9164 };
9165 const delBtn = document.createElement('button');
9166 delBtn.type = 'button';
9167 delBtn.textContent = 'Delete';
9168 delBtn.onclick = () => deleteOpenNote();
9169 if (hubHasMultipleVaultsForCopy()) {
9170 const copyVaultBtn = document.createElement('button');
9171 copyVaultBtn.type = 'button';
9172 copyVaultBtn.textContent = 'Copy to vault…';
9173 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
9174 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
9175 } else {
9176 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
9177 }
9178 return;
9179 }
9180
9181 if (hubUserMayProposeFromNote()) {
9182 const proposeBtn = document.createElement('button');
9183 proposeBtn.type = 'button';
9184 proposeBtn.textContent = 'Propose change';
9185 proposeBtn.onclick = () => {
9186 if (!currentOpenNote) return;
9187 openCreateProposalModal({
9188 path: currentOpenNote.path,
9189 body: currentOpenNote.body || '',
9190 fromNote: true,
9191 });
9192 };
9193 actionsEl.appendChild(proposeBtn);
9194 }
9195 actionsEl.appendChild(sectionBtn);
9196 if (hubUserCanExportNote()) {
9197 actionsEl.appendChild(exportBtn);
9198 }
9199 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
9200 const hint = document.createElement('p');
9201 hint.className = 'muted small';
9202 hint.style.marginTop = '0.5rem';
9203 hint.textContent =
9204 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
9205 actionsEl.appendChild(hint);
9206 }
9207 }
9208
9209 function openCopyNoteToVaultModal() {
9210 if (!currentOpenNote || !token) return;
9211 if (!hubHasMultipleVaultsForCopy()) {
9212 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
9213 return;
9214 }
9215 const existing = document.getElementById('modal-copy-note-vault');
9216 if (existing) existing.remove();
9217 const s = lastBackupSettingsPayload;
9218 const allowed = new Set((s.allowed_vault_ids || []).map(String));
9219 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
9220 const fromId = String(getCurrentVaultId() || 'default');
9221 const targets = vaultList.filter((v) => String(v.id) !== fromId);
9222 if (targets.length === 0) {
9223 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
9224 return;
9225 }
9226 const wrap = document.createElement('div');
9227 wrap.id = 'modal-copy-note-vault';
9228 wrap.className = 'modal';
9229 wrap.setAttribute('role', 'dialog');
9230 wrap.setAttribute('aria-modal', 'true');
9231 wrap.setAttribute('aria-label', 'Copy note to another vault');
9232 const backdrop = document.createElement('div');
9233 backdrop.className = 'modal-backdrop';
9234 const card = document.createElement('div');
9235 card.className = 'modal-card';
9236 card.style.maxWidth = '480px';
9237 const header = document.createElement('div');
9238 header.className = 'modal-header';
9239 const h2 = document.createElement('h2');
9240 h2.textContent = 'Copy to vault';
9241 const btnClose = document.createElement('button');
9242 btnClose.type = 'button';
9243 btnClose.className = 'modal-close';
9244 btnClose.textContent = '×';
9245 btnClose.setAttribute('aria-label', 'Close');
9246 header.appendChild(h2);
9247 header.appendChild(btnClose);
9248 const body = document.createElement('div');
9249 body.style.padding = '1rem 1.25rem';
9250 const lbl = document.createElement('label');
9251 lbl.className = 'detail-field-label';
9252 lbl.textContent = 'Target vault';
9253 lbl.setAttribute('for', 'copy-note-vault-select');
9254 const sel = document.createElement('select');
9255 sel.id = 'copy-note-vault-select';
9256 sel.className = 'vault-switcher-select';
9257 sel.style.width = '100%';
9258 sel.style.marginTop = '0.35rem';
9259 for (const v of targets) {
9260 const id = String(v.id);
9261 const opt = document.createElement('option');
9262 opt.value = id;
9263 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
9264 sel.appendChild(opt);
9265 }
9266 const moveRow = document.createElement('label');
9267 moveRow.style.display = 'flex';
9268 moveRow.style.alignItems = 'center';
9269 moveRow.style.gap = '0.5rem';
9270 moveRow.style.marginTop = '1rem';
9271 moveRow.style.cursor = 'pointer';
9272 const moveChk = document.createElement('input');
9273 moveChk.type = 'checkbox';
9274 moveChk.id = 'copy-note-delete-source';
9275 const moveSpan = document.createElement('span');
9276 moveSpan.textContent = 'Delete from this vault (move)';
9277 moveRow.appendChild(moveChk);
9278 moveRow.appendChild(moveSpan);
9279 const hint = document.createElement('p');
9280 hint.className = 'muted small';
9281 hint.style.marginTop = '0.75rem';
9282 hint.style.fontSize = '0.85rem';
9283 hint.textContent =
9284 '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).';
9285 const actions = document.createElement('div');
9286 actions.style.display = 'flex';
9287 actions.style.justifyContent = 'flex-end';
9288 actions.style.gap = '0.5rem';
9289 actions.style.marginTop = '1.25rem';
9290 const btnCancel = document.createElement('button');
9291 btnCancel.type = 'button';
9292 btnCancel.className = 'btn-secondary';
9293 btnCancel.textContent = 'Cancel';
9294 const btnGo = document.createElement('button');
9295 btnGo.type = 'button';
9296 btnGo.className = 'btn-primary';
9297 btnGo.textContent = 'Copy';
9298 actions.appendChild(btnCancel);
9299 actions.appendChild(btnGo);
9300 body.appendChild(lbl);
9301 body.appendChild(sel);
9302 body.appendChild(moveRow);
9303 body.appendChild(hint);
9304 body.appendChild(actions);
9305 card.appendChild(header);
9306 card.appendChild(body);
9307 wrap.appendChild(backdrop);
9308 wrap.appendChild(card);
9309 function close() {
9310 wrap.remove();
9311 }
9312 backdrop.onclick = close;
9313 btnClose.onclick = close;
9314 btnCancel.onclick = close;
9315 btnGo.onclick = async () => {
9316 const toId = sel.value;
9317 if (!toId || !currentOpenNote) return;
9318 await withButtonBusy(btnGo, 'Copying…', async () => {
9319 try {
9320 const res = await api('/api/v1/notes/copy', {
9321 method: 'POST',
9322 body: JSON.stringify({
9323 from_vault_id: fromId,
9324 to_vault_id: toId,
9325 path: currentOpenNote.path,
9326 delete_source: moveChk.checked,
9327 }),
9328 });
9329 hubMarkSemanticIndexStaleForVault(toId);
9330 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
9331 close();
9332 if (typeof showToast === 'function') {
9333 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
9334 }
9335 if (res.moved) {
9336 currentOpenNote = null;
9337 currentNotePathForCopy = '';
9338 resetDetailSectionSourceState();
9339 hideDetailPanelChrome();
9340 const bcp = el('btn-copy-path');
9341 if (bcp) bcp.classList.add('hidden');
9342 loadNotes();
9343 loadFacets();
9344 }
9345 } catch (e) {
9346 if (typeof showToast === 'function') showToast(e.message || String(e), true);
9347 }
9348 });
9349 };
9350 document.body.appendChild(wrap);
9351 }
9352
9353 async function exportCurrentNote(format) {
9354 if (!currentOpenNote) return;
9355 try {
9356 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
9357 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
9358 const a = document.createElement('a');
9359 a.href = URL.createObjectURL(blob);
9360 a.download = res.filename || 'export.md';
9361 a.click();
9362 URL.revokeObjectURL(a.href);
9363 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
9364 } catch (e) {
9365 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
9366 }
9367 }
9368
9369 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
9370 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
9371 var MEDIA_URL_SAFE = /^https?:\/\//i;
9372
9373 function teardownDetailEditBodyLayout() {
9374 if (detailEditBodyLayoutAbort) {
9375 detailEditBodyLayoutAbort.abort();
9376 detailEditBodyLayoutAbort = null;
9377 }
9378 }
9379
9380 function detailEditBodyMaxTextareaPx() {
9381 var wrap = el('detail-edit-body-wrap');
9382 var ta = el('detail-edit-body');
9383 if (!wrap || !ta) return 400;
9384 var toolbar = el('media-toolbar');
9385 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9386 var tb = toolbar ? toolbar.offsetHeight : 0;
9387 var gh = grip ? grip.offsetHeight : 0;
9388 var slack = 10;
9389 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
9390 var fallback = Math.round(window.innerHeight * 0.28);
9391 var wr = wrap.getBoundingClientRect();
9392 var next = wrap.nextElementSibling;
9393 var slice = 0;
9394 if (next && next.nodeType === 1) {
9395 var nr = next.getBoundingClientRect();
9396 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
9397 } else {
9398 var body = el('detail-body');
9399 if (body) {
9400 var br = body.getBoundingClientRect();
9401 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
9402 }
9403 }
9404 if (!Number.isFinite(slice) || slice < 120) {
9405 slice = fallback;
9406 }
9407 return Math.max(160, Math.min(hard, slice));
9408 }
9409
9410 function sizeDetailEditBodyToFill() {
9411 var ta = el('detail-edit-body');
9412 if (!ta) return;
9413 ta.style.removeProperty('height');
9414 }
9415
9416 function wireDetailEditBodyLayout() {
9417 teardownDetailEditBodyLayout();
9418 var ta = el('detail-edit-body');
9419 var wrap = el('detail-edit-body-wrap');
9420 if (!ta || !wrap) return;
9421 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9422 if (!grip) {
9423 grip = document.createElement('div');
9424 grip.className = 'detail-edit-body-resize-handle';
9425 grip.setAttribute('role', 'separator');
9426 grip.setAttribute('aria-orientation', 'horizontal');
9427 grip.setAttribute('aria-label', 'Resize editor height');
9428 var next = ta.nextSibling;
9429 if (next && next.id === 'media-toolbar') {
9430 wrap.insertBefore(grip, next);
9431 } else {
9432 wrap.appendChild(grip);
9433 }
9434 }
9435 if (grip.dataset.wired !== '1') {
9436 grip.dataset.wired = '1';
9437 function startDrag(clientY) {
9438 var startY = clientY;
9439 var startH = ta.offsetHeight;
9440 document.body.style.userSelect = 'none';
9441 function onMove(e2) {
9442 if (e2.touches && e2.cancelable) e2.preventDefault();
9443 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
9444 var dy = y - startY;
9445 var cap = detailEditBodyMaxTextareaPx();
9446 var nh = Math.max(160, Math.min(cap, startH + dy));
9447 ta.style.height = nh + 'px';
9448 }
9449 function onUp() {
9450 document.body.style.userSelect = '';
9451 document.removeEventListener('mousemove', onMove);
9452 document.removeEventListener('mouseup', onUp);
9453 document.removeEventListener('touchmove', onMove);
9454 document.removeEventListener('touchend', onUp);
9455 }
9456 document.addEventListener('mousemove', onMove);
9457 document.addEventListener('mouseup', onUp);
9458 document.addEventListener('touchmove', onMove, { passive: false });
9459 document.addEventListener('touchend', onUp);
9460 }
9461 grip.addEventListener('mousedown', function (e) {
9462 e.preventDefault();
9463 startDrag(e.clientY);
9464 });
9465 grip.addEventListener('touchstart', function (e) {
9466 if (!e.touches || !e.touches[0]) return;
9467 e.preventDefault();
9468 startDrag(e.touches[0].clientY);
9469 }, { passive: false });
9470 }
9471 window.requestAnimationFrame(function () {
9472 sizeDetailEditBodyToFill();
9473 });
9474 detailEditBodyLayoutAbort = new AbortController();
9475 window.addEventListener(
9476 'resize',
9477 function () {
9478 if (!el('detail-edit-body-wrap')) return;
9479 sizeDetailEditBodyToFill();
9480 },
9481 { signal: detailEditBodyLayoutAbort.signal }
9482 );
9483 }
9484
9485 function attachMediaToolbar() {
9486 var textarea = el('detail-edit-body');
9487 if (!textarea) return;
9488 var existing = document.getElementById('media-toolbar');
9489 if (existing) existing.remove();
9490
9491 var toolbar = document.createElement('div');
9492 toolbar.id = 'media-toolbar';
9493 toolbar.className = 'media-toolbar';
9494
9495 var insertBtn = document.createElement('button');
9496 insertBtn.type = 'button';
9497 insertBtn.textContent = 'Insert Media URL';
9498 insertBtn.className = 'btn-small';
9499 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.';
9500 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
9501 toolbar.appendChild(insertBtn);
9502
9503 var s = lastBackupSettingsPayload;
9504 if (s && s.github_connected && hubUserCanWriteNotes()) {
9505 var uploadBtn = document.createElement('button');
9506 uploadBtn.type = 'button';
9507 uploadBtn.textContent = 'Upload Image';
9508 uploadBtn.className = 'btn-small';
9509 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.';
9510 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
9511 toolbar.appendChild(uploadBtn);
9512 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
9513 var connectHint = document.createElement('span');
9514 connectHint.className = 'media-toolbar-hint';
9515 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
9516 connectHint.textContent = 'Connect GitHub to upload images';
9517 toolbar.appendChild(connectHint);
9518 }
9519
9520 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
9521 }
9522
9523 function toggleMediaUrlDialog(toolbar, textarea) {
9524 var existing = document.getElementById('media-url-dialog');
9525 if (existing) { existing.remove(); return; }
9526
9527 var dialog = document.createElement('div');
9528 dialog.id = 'media-url-dialog';
9529 dialog.className = 'media-url-dialog';
9530
9531 var input = document.createElement('input');
9532 input.type = 'text';
9533 input.placeholder = 'Paste image or video URL (https://...)';
9534 input.className = 'media-url-input';
9535
9536 var preview = document.createElement('div');
9537 preview.className = 'media-preview';
9538
9539 var actions = document.createElement('div');
9540 actions.className = 'media-url-actions';
9541
9542 var doInsert = document.createElement('button');
9543 doInsert.type = 'button';
9544 doInsert.textContent = 'Insert';
9545 doInsert.className = 'btn-primary btn-small';
9546 doInsert.disabled = true;
9547
9548 var doCancel = document.createElement('button');
9549 doCancel.type = 'button';
9550 doCancel.textContent = 'Cancel';
9551 doCancel.className = 'btn-small';
9552 doCancel.onclick = function () { dialog.remove(); };
9553
9554 actions.appendChild(doInsert);
9555 actions.appendChild(doCancel);
9556
9557 var detectedType = null;
9558
9559 function onUrlChange() {
9560 var url = input.value.trim();
9561 preview.innerHTML = '';
9562 doInsert.disabled = true;
9563 detectedType = null;
9564 if (!url || !MEDIA_URL_SAFE.test(url)) return;
9565 if (MEDIA_IMAGE_EXTS.test(url)) {
9566 detectedType = 'image';
9567 var img = document.createElement('img');
9568 img.src = url;
9569 img.style.maxHeight = '200px';
9570 img.style.maxWidth = '100%';
9571 img.crossOrigin = 'anonymous';
9572 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9573 preview.appendChild(img);
9574 doInsert.disabled = false;
9575 } else if (MEDIA_VIDEO_EXTS.test(url)) {
9576 detectedType = 'video';
9577 var vid = document.createElement('video');
9578 vid.controls = true;
9579 vid.preload = 'metadata';
9580 vid.style.maxHeight = '200px';
9581 vid.style.maxWidth = '100%';
9582 vid.src = url;
9583 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9584 preview.appendChild(vid);
9585 doInsert.disabled = false;
9586 } else {
9587 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>';
9588 }
9589 }
9590
9591 input.addEventListener('input', onUrlChange);
9592 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
9593
9594 doInsert.onclick = function () {
9595 var url = input.value.trim();
9596 if (!url) return;
9597 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
9598 insertAtCursor(textarea, insertion);
9599 dialog.remove();
9600 };
9601
9602 dialog.appendChild(input);
9603 dialog.appendChild(preview);
9604 dialog.appendChild(actions);
9605 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
9606 input.focus();
9607 }
9608
9609 function insertAtCursor(textarea, text) {
9610 var start = textarea.selectionStart;
9611 var end = textarea.selectionEnd;
9612 var val = textarea.value;
9613 var before = val.substring(0, start);
9614 var needsNewline = before.length > 0 && !before.endsWith('\n');
9615 var insertion = (needsNewline ? '\n' : '') + text + '\n';
9616 textarea.value = before + insertion + val.substring(end);
9617 var newPos = start + insertion.length;
9618 textarea.setSelectionRange(newPos, newPos);
9619 textarea.focus();
9620 }
9621
9622 /**
9623 * Compress an image File/Blob using the Canvas API so it fits within the
9624 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
9625 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
9626 * Falls back to the original file if Canvas is unavailable or the image is
9627 * already small enough.
9628 */
9629 function compressImageIfNeeded(file) {
9630 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
9631 var MAX_DIM = 2048;
9632 return new Promise(function (resolve) {
9633 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
9634 return resolve(file);
9635 }
9636 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
9637 return resolve(file);
9638 }
9639 var img = new window.Image();
9640 var objectUrl = URL.createObjectURL(file);
9641 img.onload = function () {
9642 URL.revokeObjectURL(objectUrl);
9643 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
9644 var w = Math.round(img.width * ratio);
9645 var h = Math.round(img.height * ratio);
9646 var canvas = document.createElement('canvas');
9647 canvas.width = w;
9648 canvas.height = h;
9649 var ctx = canvas.getContext('2d');
9650 ctx.drawImage(img, 0, 0, w, h);
9651 var tryQuality = function (quality, attempt) {
9652 canvas.toBlob(function (blob) {
9653 if (!blob) return resolve(file); // Canvas failed — upload original
9654 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
9655 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
9656 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
9657 } else {
9658 tryQuality(quality - 0.2, attempt + 1);
9659 }
9660 }, 'image/jpeg', quality);
9661 };
9662 tryQuality(0.82, 0);
9663 };
9664 img.onerror = function () {
9665 URL.revokeObjectURL(objectUrl);
9666 resolve(file);
9667 };
9668 img.src = objectUrl;
9669 });
9670 }
9671
9672 function triggerImageUpload(textarea) {
9673 var fileInput = document.createElement('input');
9674 fileInput.type = 'file';
9675 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
9676 fileInput.onchange = async function () {
9677 var file = fileInput.files && fileInput.files[0];
9678 if (!file || !currentOpenNote) return;
9679 try {
9680 if (typeof showToast === 'function') showToast('Uploading image…');
9681 // Compress before uploading to stay within the Netlify Lambda 6 MB
9682 // payload limit (~4.5 MB binary after base64 overhead).
9683 var uploadFile = await compressImageIfNeeded(file);
9684 var form = new FormData();
9685 form.append('image', uploadFile);
9686 var notePath = encodeURIComponent(currentOpenNote.path);
9687 var vaultIdParam = '';
9688 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
9689 // Build auth headers from the shared helper (omit Content-Type so the
9690 // browser sets the correct multipart/form-data boundary automatically).
9691 var uploadHeaders = headers();
9692 delete uploadHeaders['Content-Type'];
9693 // Use apiBase so this request reaches the gateway when the frontend is served
9694 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
9695 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9696 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
9697 method: 'POST',
9698 headers: uploadHeaders,
9699 body: form,
9700 });
9701 if (!res.ok) {
9702 var errData = await res.json().catch(function () { return {}; });
9703 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
9704 }
9705 var data = await res.json();
9706 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
9707 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
9708 } catch (e) {
9709 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
9710 }
9711 };
9712 fileInput.click();
9713 }
9714
9715 function switchNoteToEditMode() {
9716 if (!currentOpenNote) return;
9717 closeCreateModal();
9718 resetDetailSectionSourceState();
9719 const bcbEdit = el('btn-detail-copy-body');
9720 if (bcbEdit) bcbEdit.classList.add('hidden');
9721 const bodyEl = el('detail-body');
9722 const actionsEl = el('detail-actions');
9723 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
9724 bodyEl.className = 'detail-edit-container create-panel';
9725 bodyEl.innerHTML =
9726 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
9727 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
9728 '<label for="detail-edit-title">Title</label>' +
9729 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
9730 '<label for="detail-edit-body">Body (Markdown)</label>' +
9731 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
9732 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
9733 '</div>' +
9734 '<label for="detail-edit-date">Date</label>' +
9735 '<input type="date" id="detail-edit-date" />' +
9736 '<label for="detail-edit-project">Project (slug)</label>' +
9737 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
9738 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
9739 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
9740 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
9741 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
9742 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
9743 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
9744 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
9745 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
9746 '<label for="detail-edit-episode">Episode ID</label>' +
9747 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
9748 '<label for="detail-edit-follows">Follows (vault path)</label>' +
9749 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
9750 const pathDisp = el('detail-edit-path-display');
9751 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
9752 fillDetailEditFieldsFromFrontmatter(fm);
9753 attachMediaToolbar();
9754 wireDetailEditBodyLayout();
9755 actionsEl.innerHTML = '';
9756 const saveBtn = document.createElement('button');
9757 saveBtn.textContent = 'Save';
9758 saveBtn.className = 'btn-primary';
9759 saveBtn.onclick = async () => {
9760 closeCreateModal();
9761 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
9762 const frontmatter = mergedFrontmatterForDetailSave();
9763 await withButtonBusy(saveBtn, 'Saving…', async () => {
9764 try {
9765 await api('/api/v1/notes', {
9766 method: 'POST',
9767 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
9768 });
9769 hubMarkSemanticIndexStale();
9770 if (typeof showToast === 'function') showToast('Note saved');
9771 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
9772 const nfm = materializeFrontmatter(refreshed.frontmatter);
9773 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
9774 switchNoteToReadMode();
9775 if (typeof loadNotes === 'function') loadNotes();
9776 if (typeof loadFacets === 'function') loadFacets();
9777 } catch (e) {
9778 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
9779 }
9780 });
9781 };
9782 const cancelBtn = document.createElement('button');
9783 cancelBtn.textContent = 'Cancel';
9784 cancelBtn.onclick = () => switchNoteToReadMode();
9785 const delBtn = document.createElement('button');
9786 delBtn.type = 'button';
9787 delBtn.textContent = 'Delete';
9788 delBtn.onclick = () => deleteOpenNote();
9789 actionsEl.append(saveBtn, delBtn, cancelBtn);
9790 }
9791
9792 function openNote(path) {
9793 const seq = ++hubOpenNoteSeq;
9794 resetDetailSectionSourceState();
9795 teardownDetailEditBodyLayout();
9796 closeCreateModal();
9797 clearReviewSplitPosition();
9798 currentNotePathForCopy = path;
9799 currentOpenNote = null;
9800 const panel = el('detail-panel');
9801 panel.classList.remove('detail-panel-proposal-wide');
9802 // Reset any prior resize so notes open at the CSS half-page default.
9803 panel.style.width = '';
9804 const title = el('detail-title');
9805 const bodyEl = el('detail-body');
9806 const actionsEl = el('detail-actions');
9807 const btnCopy = el('btn-copy-path');
9808 const btnCopyBody = el('btn-detail-copy-body');
9809 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9810 title.textContent = path;
9811 bodyEl.textContent = 'Loading…';
9812 bodyEl.className = '';
9813 actionsEl.innerHTML = '';
9814 btnCopy.classList.remove('hidden');
9815 panel.classList.remove('hidden');
9816 api('/api/v1/notes/' + encodeURIComponent(path))
9817 .then((note) => {
9818 if (seq !== hubOpenNoteSeq) return;
9819 const fm = materializeFrontmatter(note.frontmatter);
9820 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9821 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9822 bodyEl.className = 'note-rendered-body';
9823 actionsEl.innerHTML = '';
9824 attachNoteDetailReadActions(actionsEl);
9825 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9826 })
9827 .catch((e) => {
9828 if (seq !== hubOpenNoteSeq) return;
9829 bodyEl.textContent = 'Error: ' + e.message;
9830 bodyEl.className = '';
9831 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9832 });
9833 }
9834
9835 el('btn-copy-path').onclick = () => {
9836 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
9837 };
9838
9839 const btnDetailCopyBody = el('btn-detail-copy-body');
9840 if (btnDetailCopyBody) {
9841 btnDetailCopyBody.onclick = () => {
9842 if (!currentOpenNote) {
9843 if (typeof showToast === 'function') showToast('Open a note first.', true);
9844 return;
9845 }
9846 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
9847 if (navigator.clipboard && navigator.clipboard.writeText) {
9848 navigator.clipboard.writeText(text).then(
9849 () => {
9850 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
9851 },
9852 () => {
9853 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
9854 },
9855 );
9856 } else if (typeof showToast === 'function') {
9857 showToast('Clipboard not available in this browser.', true);
9858 }
9859 };
9860 }
9861
9862 const btnCopyUserId = el('btn-copy-user-id');
9863 if (btnCopyUserId) {
9864 btnCopyUserId.onclick = () => {
9865 const idEl = el('settings-user-id');
9866 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
9867 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9868 navigator.clipboard.writeText(text).then(() => {
9869 if (typeof showToast === 'function') showToast('User ID copied.');
9870 }).catch(() => {});
9871 }
9872 };
9873 }
9874 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
9875 if (btnCopyAgentceptionEnv) {
9876 btnCopyAgentceptionEnv.onclick = () => {
9877 const envEl = el('integrations-agentception-env');
9878 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
9879 if (text && navigator.clipboard && navigator.clipboard.writeText) {
9880 navigator.clipboard.writeText(text).then(() => {
9881 if (typeof showToast === 'function') showToast('Env snippet copied.');
9882 }).catch(() => {});
9883 }
9884 };
9885 }
9886 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
9887 if (btnIntegrationsHowToAgentception) {
9888 btnIntegrationsHowToAgentception.onclick = () => {
9889 closeSettings();
9890 openHowToUse('setup');
9891 };
9892 }
9893 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
9894 if (btnHowToFlexibleNetwork) {
9895 btnHowToFlexibleNetwork.onclick = () => {
9896 closeSettings();
9897 openHowToUse('setup', 'how-to-flexible-network');
9898 };
9899 }
9900
9901 function renderProposalMarkdownHtml(md) {
9902 try {
9903 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
9904 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
9905 var withVideo = expandVideoUrls(raw);
9906 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
9907 return rewriteGitHubImageUrls(sanitised);
9908 }
9909 } catch (_) {
9910 /* fall through */
9911 }
9912 return escapeHtml(md || '');
9913 }
9914
9915 /** Canister stores checklist as JSON text; Node may return an array. */
9916 function parseProposalEvaluationChecklist(raw) {
9917 if (Array.isArray(raw)) return raw;
9918 if (raw == null || raw === '') return [];
9919 const s = String(raw).trim();
9920 if (!s) return [];
9921 try {
9922 const j = JSON.parse(s);
9923 return Array.isArray(j) ? j : [];
9924 } catch (_) {
9925 return [];
9926 }
9927 }
9928
9929 /**
9930 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
9931 */
9932 function buildProposalEvaluationRecordHtml(p, rubricItems) {
9933 const st = p.status;
9934 if (st !== 'approved' && st !== 'discarded') return '';
9935 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
9936 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
9937 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
9938 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
9939 const meaningfulStatus = es && es !== 'none';
9940 let waiverText = '';
9941 const w = p.evaluation_waiver;
9942 if (w != null && w !== '') {
9943 try {
9944 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
9945 if (o && typeof o === 'object') {
9946 const r1 = o.reason != null ? String(o.reason).trim() : '';
9947 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
9948 waiverText = r1 || r2;
9949 }
9950 } catch (_) {
9951 /* ignore */
9952 }
9953 }
9954 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
9955 const rubricById = new Map(
9956 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
9957 String(it.id || '').trim(),
9958 String(it.label || it.id || '').trim(),
9959 ]),
9960 );
9961 const rows = checklist
9962 .map((c) => {
9963 const rid = c && c.id != null ? String(c.id) : '';
9964 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
9965 const pass = c && c.passed === true;
9966 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
9967 })
9968 .join('');
9969 return (
9970 '<div class="proposal-eval proposal-eval-readonly">' +
9971 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
9972 '<p class="small">' +
9973 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
9974 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
9975 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
9976 (p.evaluated_at
9977 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
9978 : '') +
9979 '</p>' +
9980 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
9981 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
9982 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
9983 '</div>'
9984 );
9985 }
9986
9987 function openProposal(id) {
9988 resetDetailSectionSourceState();
9989 currentNotePathForCopy = '';
9990 currentOpenNote = null;
9991 el('btn-copy-path').classList.add('hidden');
9992 const bcbProp = el('btn-detail-copy-body');
9993 if (bcbProp) bcbProp.classList.add('hidden');
9994 const panel = el('detail-panel');
9995 panel.classList.add('detail-panel-proposal-wide');
9996 const title = el('detail-title');
9997 const body = el('detail-body');
9998 const actions = el('detail-actions');
9999 body.className = 'detail-body-proposal';
10000 panel.classList.remove('hidden');
10001 body.innerHTML = '<p class="muted">Loading…</p>';
10002 actions.innerHTML = '';
10003 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
10004 api('/api/v1/proposals/' + encodeURIComponent(id))
10005 .then((p) =>
10006 api('/api/v1/notes/' + pathEnc(p.path)).then(
10007 (note) => ({ p, note }),
10008 () => ({ p, note: null }),
10009 ),
10010 )
10011 .then(({ p, note }) => {
10012 title.textContent = p.path + ' (' + p.status + ')';
10013 const pFm = materializeFrontmatter(p.frontmatter);
10014 const currentBlock = note
10015 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
10016 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
10017 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
10018 const mdHtml = renderProposalMarkdownHtml(p.body || '');
10019 const chips = [];
10020 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
10021 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
10022 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
10023 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
10024 });
10025 if (p.external_ref) {
10026 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
10027 }
10028 const role = window.__hubUserRole || 'member';
10029 const isAdmin = role === 'admin';
10030 const isEvaluator = role === 'evaluator';
10031 const canEvaluate = isAdmin || isEvaluator;
10032 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
10033 const canDiscard = isAdmin;
10034 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
10035 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
10036 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
10037 function prevEvalPassed(rid) {
10038 const row = prevChecklist.find((c) => c && c.id === rid);
10039 return Boolean(row && row.passed === true);
10040 }
10041 let evalHtml = '';
10042 let waiverHtml = '';
10043 if (canEvaluate && p.status === 'proposed') {
10044 const es = p.evaluation_status || 'none';
10045 let evalIntro = '';
10046 if (es && es !== 'none' && es !== 'pending') {
10047 evalIntro =
10048 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
10049 escapeHtml(es) +
10050 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
10051 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
10052 (p.evaluation_comment
10053 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
10054 : '') +
10055 '</div>';
10056 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
10057 evalIntro =
10058 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
10059 }
10060 const checks = rubricItems.length
10061 ? rubricItems
10062 .map((it) => {
10063 const rid = String(it.id || '').trim();
10064 if (!rid) return '';
10065 const lab = String(it.label || rid);
10066 const ck = prevEvalPassed(rid) ? ' checked' : '';
10067 return (
10068 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
10069 escapeHtml(rid) +
10070 '"' +
10071 ck +
10072 ' /> ' +
10073 escapeHtml(lab) +
10074 '</label>'
10075 );
10076 })
10077 .join('')
10078 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
10079 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
10080 evalHtml =
10081 '<div class="proposal-eval">' +
10082 '<h4 class="proposal-md-heading">Evaluation</h4>' +
10083 evalIntro +
10084 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
10085 '<option value="pass">Pass</option>' +
10086 '<option value="fail">Fail</option>' +
10087 '<option value="needs_changes">Needs changes</option>' +
10088 '</select></label>' +
10089 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
10090 gradeVal +
10091 '" placeholder="e.g. A or 4" /></label>' +
10092 '<div class="proposal-eval-checklist">' +
10093 checks +
10094 '</div>' +
10095 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
10096 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
10097 '</textarea></label>' +
10098 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
10099 '</div>';
10100 }
10101 if (canApprove && p.status === 'proposed') {
10102 waiverHtml =
10103 '<div class="proposal-eval-waiver">' +
10104 '<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>' +
10105 '</div>';
10106 }
10107 let autoFlagHtml = '';
10108 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
10109 autoFlagHtml =
10110 '<p class="small muted">Auto-flagged: ' +
10111 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
10112 '</p>';
10113 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
10114 try {
10115 const ar = JSON.parse(String(p.auto_flag_reasons_json));
10116 if (Array.isArray(ar) && ar.length) {
10117 autoFlagHtml =
10118 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
10119 }
10120 } catch (_) {
10121 /* ignore */
10122 }
10123 }
10124 let hintsHtml = '';
10125 if (p.review_hints) {
10126 hintsHtml =
10127 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
10128 (p.review_hints_model
10129 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
10130 : '') +
10131 (p.review_hints_at
10132 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
10133 : '') +
10134 '<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>' +
10135 '<pre class="proposal-pre">' +
10136 escapeHtml(String(p.review_hints)) +
10137 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
10138 }
10139 let assistantHtml = '';
10140 if (p.assistant_notes) {
10141 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
10142 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
10143 .join('');
10144 assistantHtml =
10145 '<div class="proposal-assistant"><strong>Assistant</strong>' +
10146 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
10147 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
10148 '<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>' +
10149 '<p>' +
10150 escapeHtml(String(p.assistant_notes)) +
10151 '</p>' +
10152 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
10153 '</div>';
10154 }
10155 let suggestedFmHtml = '';
10156 {
10157 let fm = p.assistant_suggested_frontmatter;
10158 if (typeof fm === 'string') {
10159 try {
10160 fm = JSON.parse(fm);
10161 } catch {
10162 fm = null;
10163 }
10164 }
10165 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
10166 const keys = Object.keys(fm).filter((k) => {
10167 const v = fm[k];
10168 return v !== undefined && v !== null && v !== '';
10169 });
10170 if (keys.length) {
10171 const rows = keys
10172 .map((k) => {
10173 const v = fm[k];
10174 let cell;
10175 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
10176 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
10177 else cell = String(v);
10178 return (
10179 '<tr><th scope="row">' +
10180 escapeHtml(k) +
10181 '</th><td>' +
10182 escapeHtml(cell) +
10183 '</td></tr>'
10184 );
10185 })
10186 .join('');
10187 suggestedFmHtml =
10188 '<div class="proposal-suggested-fm">' +
10189 '<strong>Suggested frontmatter</strong> ' +
10190 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
10191 '<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>' +
10192 '<table class="proposal-suggested-fm-table"><tbody>' +
10193 rows +
10194 '</tbody></table></div>';
10195 }
10196 }
10197 }
10198 const openVaultNoteLine = note
10199 ? '<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>'
10200 : '<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>';
10201 const primaryEvalBlock =
10202 evalHtml || waiverHtml
10203 ? '<div class="proposal-primary-eval">' + evalHtml + waiverHtml + '</div>'
10204 : '';
10205 body.innerHTML =
10206 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
10207 autoFlagHtml +
10208 '<p class="small muted">Intent: ' +
10209 escapeHtml(p.intent || '—') +
10210 ' · base_state_id: ' +
10211 escapeHtml(p.base_state_id || '—') +
10212 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
10213 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
10214 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
10215 '</p>' +
10216 openVaultNoteLine +
10217 primaryEvalBlock +
10218 '<div class="proposal-diff-grid">' +
10219 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
10220 escapeHtml(currentBlock) +
10221 '</pre></div>' +
10222 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
10223 escapeHtml(proposedBlock) +
10224 '</pre></div>' +
10225 '</div>' +
10226 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
10227 '<div class="proposal-md">' +
10228 mdHtml +
10229 '</div>' +
10230 evalRecordHtml +
10231 assistantHtml +
10232 suggestedFmHtml +
10233 hintsHtml;
10234 actions.innerHTML = '';
10235 {
10236 const idx = proposalListIds.indexOf(String(id));
10237 if (idx >= 0 && proposalListIds.length > 0) {
10238 proposalListSelectedIndex = idx;
10239 setReviewSplitPosition(idx + 1, proposalListIds.length);
10240 const c = getActiveProposalListContainer();
10241 if (c) updateProposalListSelection(c);
10242 } else {
10243 clearReviewSplitPosition();
10244 }
10245 }
10246 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
10247 if (openNoteBtn && note && p.path) {
10248 openNoteBtn.onclick = () => openNote(String(p.path));
10249 }
10250 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
10251 if (copyFmBtn) {
10252 let fmForCopy = p.assistant_suggested_frontmatter;
10253 if (typeof fmForCopy === 'string') {
10254 try {
10255 fmForCopy = JSON.parse(fmForCopy);
10256 } catch {
10257 fmForCopy = null;
10258 }
10259 }
10260 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
10261 copyFmBtn.onclick = async () => {
10262 try {
10263 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
10264 showToast('Copied suggested frontmatter JSON.');
10265 } catch (err) {
10266 showToast(err.message || 'Copy failed', true);
10267 }
10268 };
10269 }
10270 }
10271 const saveEvalBtn = body.querySelector('#proposal-eval-save');
10272 if (saveEvalBtn) {
10273 saveEvalBtn.onclick = async () => {
10274 const outcomeEl = body.querySelector('#proposal-eval-outcome');
10275 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
10276 const gradeEl = body.querySelector('#proposal-eval-grade');
10277 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
10278 const commentEl = body.querySelector('#proposal-eval-comment');
10279 const comment = commentEl ? String(commentEl.value || '').trim() : '';
10280 const checklist = [];
10281 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
10282 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
10283 });
10284 try {
10285 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
10286 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
10287 method: 'POST',
10288 body: JSON.stringify({
10289 outcome,
10290 grade: grade || undefined,
10291 comment: comment || undefined,
10292 checklist,
10293 }),
10294 });
10295 });
10296 showToast('Evaluation saved.');
10297 openProposal(id);
10298 loadProposals();
10299 } catch (err) {
10300 showToast(err.message || 'Evaluation failed', true);
10301 }
10302 };
10303 }
10304 if (p.status === 'proposed') {
10305 if (canApprove) {
10306 const approveBtn = document.createElement('button');
10307 approveBtn.textContent = 'Approve';
10308 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
10309 actions.append(approveBtn);
10310 }
10311 if (canDiscard) {
10312 const discardBtn = document.createElement('button');
10313 discardBtn.textContent = 'Discard';
10314 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
10315 actions.append(discardBtn);
10316 }
10317 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
10318 const enrichBtn = document.createElement('button');
10319 enrichBtn.type = 'button';
10320 enrichBtn.className = 'btn-secondary';
10321 enrichBtn.textContent = 'Enrich (AI)';
10322 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
10323 actions.append(enrichBtn);
10324 }
10325 if (isEvaluator && !canApprove) {
10326 const hintEv = document.createElement('p');
10327 hintEv.className = 'muted small';
10328 hintEv.textContent =
10329 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
10330 actions.append(hintEv);
10331 } else if (!canEvaluate) {
10332 const hint = document.createElement('p');
10333 hint.className = 'muted small';
10334 hint.textContent =
10335 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
10336 actions.append(hint);
10337 }
10338 }
10339 })
10340 .catch((e) => {
10341 body.className = 'detail-body-proposal';
10342 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
10343 });
10344 }
10345
10346 async function enrichProposal(id, panel, btn) {
10347 try {
10348 await withButtonBusy(btn, 'Enriching…', async () => {
10349 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
10350 });
10351 showToast('Proposal enriched.');
10352 openProposal(id);
10353 loadProposals();
10354 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
10355 // is visible instead of the browser staying at whatever scroll position it was at.
10356 const scrollHost = el('detail-body');
10357 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
10358 // Also highlight the matching row in the Review/Activity list so the user can see which
10359 // proposal was enriched.
10360 requestAnimationFrame(() => {
10361 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
10362 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
10363 });
10364 } catch (e) {
10365 showToast(e.message || 'Enrich failed', true);
10366 }
10367 }
10368
10369 async function approveProposal(id, panel, btn) {
10370 try {
10371 const db = el('detail-body');
10372 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
10373 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
10374 const approveBody = {};
10375 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
10376 let approveOut = null;
10377 await withButtonBusy(btn, 'Approving…', async () => {
10378 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
10379 method: 'POST',
10380 body: JSON.stringify(approveBody),
10381 });
10382 });
10383 if (approveOut && approveOut.approval_log_written === false) {
10384 showToast(
10385 approveOut.approval_log_error
10386 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
10387 : 'Approved, but approval log was not written. Check server logs and re-index.',
10388 true,
10389 );
10390 }
10391 hideDetailPanelChrome();
10392 hubMarkSemanticIndexStale();
10393 loadProposals();
10394 loadNotes();
10395 loadActivity();
10396 } catch (e) {
10397 const msg = e.message || String(e);
10398 showToast('Approve failed: ' + msg, true);
10399 }
10400 }
10401
10402 async function discardProposal(id, panel, btn) {
10403 try {
10404 await withButtonBusy(btn, 'Discarding…', async () => {
10405 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
10406 });
10407 hideDetailPanelChrome();
10408 loadProposals();
10409 loadActivity();
10410 } catch (e) {
10411 const msg = e.message || String(e);
10412 showToast('Discard failed: ' + msg, true);
10413 }
10414 }
10415
10416 el('detail-close').onclick = () => closeDetailPanel();
10417 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
10418 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
10419 (function wireDetailPanelFooterClose() {
10420 const panel = el('detail-panel');
10421 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
10422 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
10423 })();
10424
10425 (function initHubHeaderOffsetSync() {
10426 syncHubHeaderOffset();
10427 window.addEventListener('resize', () => syncHubHeaderOffset());
10428 if (typeof ResizeObserver !== 'undefined') {
10429 const header = document.querySelector('.hub-header');
10430 if (header) {
10431 const ro = new ResizeObserver(() => syncHubHeaderOffset());
10432 ro.observe(header);
10433 }
10434 }
10435 })();
10436
10437 // Resizable detail panel — drag the left edge to widen/narrow.
10438 (function initDetailPanelResize() {
10439 const panel = el('detail-panel');
10440 if (!panel) return;
10441 const handle = document.createElement('div');
10442 handle.className = 'detail-resize-handle';
10443 handle.title = 'Drag to resize panel';
10444 panel.prepend(handle);
10445 const MIN_W = 280;
10446 const MAX_W = Math.round(window.innerWidth * 0.92);
10447 let startX = 0, startW = 0, dragging = false;
10448 const onMove = (e) => {
10449 if (!dragging) return;
10450 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
10451 const delta = startX - clientX;
10452 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
10453 panel.style.width = newW + 'px';
10454 };
10455 const onUp = () => {
10456 if (!dragging) return;
10457 dragging = false;
10458 handle.classList.remove('dragging');
10459 document.removeEventListener('mousemove', onMove);
10460 document.removeEventListener('mouseup', onUp);
10461 document.removeEventListener('touchmove', onMove);
10462 document.removeEventListener('touchend', onUp);
10463 document.body.style.userSelect = '';
10464 };
10465 handle.addEventListener('mousedown', (e) => {
10466 e.preventDefault();
10467 dragging = true;
10468 startX = e.clientX;
10469 startW = panel.offsetWidth;
10470 handle.classList.add('dragging');
10471 document.body.style.userSelect = 'none';
10472 document.addEventListener('mousemove', onMove);
10473 document.addEventListener('mouseup', onUp);
10474 });
10475 handle.addEventListener('touchstart', (e) => {
10476 dragging = true;
10477 startX = e.touches[0].clientX;
10478 startW = panel.offsetWidth;
10479 handle.classList.add('dragging');
10480 document.addEventListener('touchmove', onMove, { passive: true });
10481 document.addEventListener('touchend', onUp);
10482 });
10483 })();
10484
10485 document.addEventListener('keydown', (e) => {
10486 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
10487 if (e.key === 'Escape') {
10488 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
10489 closeDetailPanel();
10490 e.preventDefault();
10491 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
10492 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
10493 closeHowToUse();
10494 e.preventDefault();
10495 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
10496 closeIntegGuideModal();
10497 e.preventDefault();
10498 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
10499 closeSettings();
10500 e.preventDefault();
10501 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
10502 closeProjectsHelpModal();
10503 e.preventDefault();
10504 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
10505 closeOnboardingWizardResume();
10506 e.preventDefault();
10507 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
10508 closeImportModal();
10509 e.preventDefault();
10510 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
10511 closeFullCreateSimilarModal();
10512 e.preventDefault();
10513 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
10514 closeCreateProposalModal();
10515 e.preventDefault();
10516 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
10517 closeCreateModal();
10518 e.preventDefault();
10519 } else if (el('search-key-help')?.open) {
10520 el('search-key-help').open = false;
10521 e.preventDefault();
10522 }
10523 return;
10524 }
10525 if (inInput && e.key !== 'Escape') return;
10526 const searchSec = el('hub-search-section') || document.querySelector('.search-section');
10527 const noteSearchVisible = searchSec && !searchSec.classList.contains('hidden');
10528 if (e.key === '/' && noteSearchVisible) {
10529 searchQuery.focus();
10530 e.preventDefault();
10531 return;
10532 }
10533 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
10534 // run semantic search instead of opening the selected row (avoids "second search does nothing").
10535 if (e.key === 'Enter' && noteSearchVisible) {
10536 const q = (searchQuery.value || '').trim();
10537 if (q) {
10538 e.preventDefault();
10539 void runVaultSearch();
10540 return;
10541 }
10542 }
10543 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
10544 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
10545 const items = notesList.querySelectorAll('.list-item');
10546 if (notesTabActive && listViewVisible && items.length > 0) {
10547 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
10548 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
10549 updateListSelection();
10550 e.preventDefault();
10551 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
10552 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
10553 updateListSelection();
10554 e.preventDefault();
10555 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
10556 const node = items[listSelectedIndex];
10557 if (node.dataset.path) openNote(node.dataset.path);
10558 else if (node.dataset.id) openProposal(node.dataset.id);
10559 e.preventDefault();
10560 }
10561 return;
10562 }
10563 const propContainer = getActiveProposalListContainer();
10564 if (propContainer) {
10565 const propItems = propContainer.querySelectorAll('.list-item[data-id]');
10566 if (propItems.length > 0) {
10567 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
10568 proposalListSelectedIndex = Math.min(proposalListSelectedIndex + 1, propItems.length - 1);
10569 updateProposalListSelection(propContainer);
10570 e.preventDefault();
10571 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
10572 proposalListSelectedIndex = Math.max(proposalListSelectedIndex - 1, 0);
10573 updateProposalListSelection(propContainer);
10574 e.preventDefault();
10575 } else if (e.key === 'Enter' && propItems[proposalListSelectedIndex]) {
10576 const node = propItems[proposalListSelectedIndex];
10577 setReviewSplitPosition(proposalListSelectedIndex + 1, propItems.length);
10578 openProposal(node.dataset.id);
10579 e.preventDefault();
10580 }
10581 }
10582 }
10583 });
10584
10585 document.addEventListener('click', (e) => {
10586 const keyHelp = el('search-key-help');
10587 if (!keyHelp || !keyHelp.open) return;
10588 if (keyHelp.contains(e.target)) return;
10589 keyHelp.open = false;
10590 });
10591
10592 document.querySelectorAll('[data-tab].tab').forEach((tab) => {
10593 tab.onclick = () => {
10594 switchHubMainTab(tab.dataset.tab);
10595 };
10596 });
10597 const hubRailHistory = el('hub-rail-history');
10598 if (hubRailHistory) {
10599 hubRailHistory.addEventListener('click', () => openHistoryMode());
10600 }
10601 const hubBottomHistory = el('hub-bottom-history');
10602 if (hubBottomHistory) {
10603 hubBottomHistory.addEventListener('click', () => {
10604 closeHubMoreSheet();
10605 openHistoryMode();
10606 });
10607 }
10608 const hubBottomMore = el('hub-bottom-more');
10609 if (hubBottomMore) {
10610 hubBottomMore.addEventListener('click', () => {
10611 const sheet = el('hub-more-sheet');
10612 const open = sheet && !sheet.classList.contains('hidden');
10613 setHubMoreSheetOpen(!open);
10614 });
10615 }
10616 document.querySelectorAll('[data-hub-more-close]').forEach((node) => {
10617 node.addEventListener('click', () => closeHubMoreSheet());
10618 });
10619 document.querySelectorAll('[data-hub-more-action]').forEach((btn) => {
10620 btn.addEventListener('click', () => {
10621 const action = btn.getAttribute('data-hub-more-action');
10622 closeHubMoreSheet();
10623 runHubSecondaryAction(action);
10624 });
10625 });
10626 document.addEventListener('keydown', (e) => {
10627 if (e.key !== 'Escape') return;
10628 const sheet = el('hub-more-sheet');
10629 if (sheet && !sheet.classList.contains('hidden')) {
10630 closeHubMoreSheet();
10631 e.preventDefault();
10632 }
10633 });
10634 const hubRailInsights = el('hub-rail-insights');
10635 if (hubRailInsights) {
10636 hubRailInsights.addEventListener('click', () => runHubSecondaryAction('insights'));
10637 }
10638 const hubRailImport = el('hub-rail-import');
10639 if (hubRailImport) {
10640 hubRailImport.addEventListener('click', () => runHubSecondaryAction('import'));
10641 }
10642 const hubRailConnect = el('hub-rail-connect');
10643 if (hubRailConnect) {
10644 hubRailConnect.addEventListener('click', () => runHubSecondaryAction('connect'));
10645 }
10646 const hubRailSettings = el('hub-rail-settings');
10647 if (hubRailSettings) {
10648 hubRailSettings.addEventListener('click', () => runHubSecondaryAction('settings'));
10649 }
10650 const hubRailHelp = el('hub-rail-help');
10651 if (hubRailHelp) {
10652 hubRailHelp.addEventListener('click', () => runHubSecondaryAction('help'));
10653 }
10654 const needsYouOpen = el('hub-needs-you-open');
10655 if (needsYouOpen) {
10656 needsYouOpen.addEventListener('click', () => switchHubMainTab('suggested'));
10657 }
10658 const needsYouDismiss = el('hub-needs-you-dismiss');
10659 if (needsYouDismiss) {
10660 needsYouDismiss.addEventListener('click', () => {
10661 hubNeedsYouDismissed = true;
10662 try {
10663 sessionStorage.setItem('hub_needs_you_dismissed', '1');
10664 } catch (_) {}
10665 updateNeedsYouBanner(hubReviewBadgePrevCount);
10666 });
10667 }
10668 if (btnHeaderSuggested) {
10669 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
10670 }
10671
10672 function escapeHtml(s) {
10673 const div = document.createElement('div');
10674 div.textContent = s == null ? '' : String(s);
10675 return div.innerHTML;
10676 }
10677
10678 // ── Consolidation UI (Stream 2) ───────────────────────────────
10679
10680 function consolModeFromSettings(s) {
10681 if (!s || !s.daemon) return 'off';
10682 if (s.daemon.enabled) return 'daemon';
10683 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
10684 return 'off';
10685 }
10686
10687 function populateConsolSettingsForm(s) {
10688 if (!s || !s.daemon) return;
10689 const d = s.daemon;
10690 const mode = consolModeFromSettings(s);
10691 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
10692 applyConsolModeVisibility(mode);
10693 const iv = el('consol-interval');
10694 if (iv) iv.value = d.interval_minutes ?? 120;
10695 const idle = el('consol-idle-only');
10696 if (idle) idle.checked = d.idle_only !== false;
10697 const idleTh = el('consol-idle-threshold');
10698 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
10699 const ros = el('consol-run-on-start');
10700 if (ros) ros.checked = Boolean(d.run_on_start);
10701 const pc = el('pass-consolidate');
10702 if (pc) pc.checked = d.passes?.consolidate !== false;
10703 const pv = el('pass-verify');
10704 if (pv) pv.checked = d.passes?.verify !== false;
10705 const pd = el('pass-discover');
10706 if (pd) pd.checked = Boolean(d.passes?.discover);
10707 const lp = el('consol-llm-provider');
10708 if (lp) lp.value = d.llm?.provider || '';
10709 const lm = el('consol-llm-model');
10710 if (lm) lm.value = d.llm?.model || '';
10711 const lb = el('consol-llm-base-url');
10712 if (lb) lb.value = d.llm?.base_url || '';
10713 const lbh = el('consol-lookback-hours');
10714 if (lbh) lbh.value = d.lookback_hours ?? 24;
10715 const me = el('consol-max-events');
10716 if (me) me.value = d.max_events_per_pass ?? 200;
10717 const mt = el('consol-max-topics');
10718 if (mt) mt.value = d.max_topics_per_pass ?? 10;
10719 const lmt = el('consol-llm-max-tokens');
10720 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
10721 const cc = el('consol-cost-cap');
10722 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
10723 const chi = el('consol-hosted-interval');
10724 if (chi && d.interval_minutes != null) {
10725 const v = String(d.interval_minutes);
10726 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
10727 chi.value = allowed.includes(v) ? v : '120';
10728 }
10729 }
10730
10731 function buildConsolSettingsPayload() {
10732 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
10733 const mode = modeRadio ? modeRadio.value : 'off';
10734 const hostedSel = el('consol-hosted-interval');
10735 const intervalRaw =
10736 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
10737 const llm = {
10738 provider: el('consol-llm-provider')?.value || '',
10739 model: el('consol-llm-model')?.value || '',
10740 base_url: el('consol-llm-base-url')?.value || '',
10741 };
10742 if (mode === 'daemon') {
10743 llm.max_tokens = Math.max(
10744 64,
10745 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
10746 );
10747 }
10748 const payload = {
10749 mode,
10750 enabled: mode === 'daemon',
10751 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
10752 idle_only: Boolean(el('consol-idle-only')?.checked),
10753 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
10754 run_on_start: Boolean(el('consol-run-on-start')?.checked),
10755 passes: {
10756 consolidate: Boolean(el('pass-consolidate')?.checked),
10757 verify: Boolean(el('pass-verify')?.checked),
10758 discover: Boolean(el('pass-discover')?.checked),
10759 },
10760 llm,
10761 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
10762 };
10763 if (mode === 'daemon') {
10764 payload.lookback_hours = Math.max(
10765 1,
10766 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
10767 );
10768 payload.max_events_per_pass = Math.max(
10769 1,
10770 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
10771 );
10772 payload.max_topics_per_pass = Math.max(
10773 1,
10774 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
10775 );
10776 }
10777 return payload;
10778 }
10779
10780 function applyConsolModeVisibility(mode) {
10781 const daemonSection = el('consol-daemon-settings');
10782 const hostedSection = el('consol-hosted-settings');
10783 const llmSection = el('consol-llm-settings');
10784 const costGuard = el('consol-cost-guard');
10785 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
10786 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
10787 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
10788 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
10789 }
10790
10791 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
10792 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
10793 });
10794
10795 let lastChatKeyAvailable = {};
10796
10797 function chatProviderKeyHintText(provider, keyAvail) {
10798 const ka = keyAvail || {};
10799 switch (provider) {
10800 case '':
10801 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
10802 case 'ollama':
10803 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
10804 case 'openrouter':
10805 return ka.openrouter
10806 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
10807 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
10808 case 'openai':
10809 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
10810 case 'anthropic':
10811 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
10812 case 'deepinfra':
10813 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
10814 default:
10815 return '';
10816 }
10817 }
10818
10819 function applyChatProviderSettings(s) {
10820 const chat = (s && s.chat) || {};
10821 const sel = el('chat-provider-select');
10822 const keyHint = el('chat-provider-key-hint');
10823 const envHint = el('chat-provider-env-hint');
10824 const adminHint = el('chat-provider-admin-hint');
10825 const saveBtn = el('btn-chat-provider-save');
10826 const msg = el('chat-provider-msg');
10827 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10828 if (!sel) return;
10829 lastChatKeyAvailable = chat.key_available || {};
10830 const isAdmin = String(s && s.role) === 'admin';
10831 const envLocked = Boolean(chat.env_locked);
10832 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
10833 sel.disabled = envLocked || !isAdmin;
10834 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
10835 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
10836 if (envHint) {
10837 if (envLocked) {
10838 envHint.textContent =
10839 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
10840 envHint.classList.remove('hidden');
10841 } else {
10842 envHint.classList.add('hidden');
10843 }
10844 }
10845 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10846
10847 if (!sel.dataset.knowtationBound) {
10848 sel.dataset.knowtationBound = '1';
10849 sel.addEventListener('change', () => {
10850 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
10851 });
10852 }
10853 const btn = el('btn-chat-provider-save');
10854 if (btn && !btn.dataset.knowtationBound) {
10855 btn.dataset.knowtationBound = '1';
10856 btn.addEventListener('click', async () => {
10857 const m = el('chat-provider-msg');
10858 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
10859 try {
10860 const res = await api('/api/v1/settings/chat', {
10861 method: 'POST',
10862 body: JSON.stringify({ provider: sel.value }),
10863 });
10864 if (res && res.chat) sel.value = res.chat.provider || '';
10865 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
10866 } catch (e) {
10867 if (m) {
10868 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
10869 m.className = 'settings-msg err';
10870 }
10871 }
10872 });
10873 }
10874 }
10875
10876 async function loadConsolidationSettings() {
10877 const msg = el('consol-save-status');
10878 if (msg) msg.textContent = '';
10879 try {
10880 const s = await api('/api/v1/settings');
10881 populateConsolSettingsForm(s);
10882 } catch (e) {
10883 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
10884 }
10885 }
10886
10887 const btnConsolSave = el('btn-consol-save');
10888 if (btnConsolSave) {
10889 btnConsolSave.addEventListener('click', async () => {
10890 const msg = el('consol-save-status');
10891 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
10892 const payload = buildConsolSettingsPayload();
10893 if (payload.enabled && payload.interval_minutes < 30) {
10894 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
10895 return;
10896 }
10897 setButtonBusy(btnConsolSave, true, 'Saving…');
10898 try {
10899 await api('/api/v1/settings/consolidation', {
10900 method: 'POST',
10901 body: JSON.stringify(payload),
10902 });
10903 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
10904 } catch (e) {
10905 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
10906 }
10907 setButtonBusy(btnConsolSave, false);
10908 });
10909 }
10910
10911 const linkConsolHelp = el('link-consol-help');
10912 if (linkConsolHelp) {
10913 linkConsolHelp.addEventListener('click', (e) => {
10914 e.preventDefault();
10915 closeSettings();
10916 openHowToUse('consolidation');
10917 });
10918 }
10919
10920 // ── Consolidation Dashboard Card ──────────────────────────────
10921
10922 function formatCostMeter(costUsd, capUsd) {
10923 const cost = Math.max(0, Number(costUsd) || 0);
10924 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
10925 const display = '$' + cost.toFixed(3) + ' today';
10926 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
10927 const pct = Math.min(100, (cost / cap) * 100);
10928 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
10929 }
10930
10931 function renderConsolidationHistory(events, container) {
10932 if (!container) return;
10933 if (!events || events.length === 0) {
10934 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
10935 return;
10936 }
10937 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>';
10938 events.forEach((ev) => {
10939 const ts = ev.ts || ev.timestamp || ev.created_at;
10940 const date = ts ? new Date(ts).toLocaleString() : '—';
10941 const rawTopics = ev.data?.topics_count;
10942 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
10943 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
10944 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
10945 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
10946 });
10947 html += '</tbody></table>';
10948 container.innerHTML = html;
10949 }
10950
10951 async function refreshConsolidationCard() {
10952 const card = el('consolidation-card');
10953 const badge = el('consol-status-badge');
10954 const lastPass = el('consol-last-pass');
10955 const nextPass = el('consol-next-pass');
10956 const quotaMeter = el('consol-quota-meter');
10957 const quotaLabel = el('consol-quota-label');
10958 const quotaFill = el('consol-quota-fill');
10959 const btnNow = el('btn-consol-now');
10960 if (!card) return;
10961
10962 try {
10963 const s = await api('/api/v1/settings');
10964 const mode = consolModeFromSettings(s);
10965 if (mode === 'off') {
10966 card.style.display = 'none';
10967 return;
10968 }
10969 card.style.display = '';
10970
10971 if (mode === 'hosted') {
10972 try {
10973 const st = await api('/api/v1/memory/consolidate/status');
10974 if (badge) {
10975 badge.textContent = '● Active (hosted)';
10976 badge.className = 'consol-badge consol-badge-success';
10977 }
10978 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
10979 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
10980
10981 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
10982 const passUsed = st.pass_count_month ?? 0;
10983 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
10984 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
10985 if (quotaMeter) {
10986 if (passLimit === null) {
10987 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
10988 if (quotaFill) quotaFill.style.width = '0%';
10989 } else if (passLimit > 0) {
10990 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
10991 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
10992 if (quotaFill) quotaFill.style.width = pct + '%';
10993 }
10994 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
10995 }
10996
10997 // Disable "Consolidate Now" during cooldown; show time remaining.
10998 const cooldown = st.cooldown_minutes ?? 0;
10999 if (btnNow && cooldown > 0) {
11000 btnNow.disabled = true;
11001 btnNow.textContent = 'Available in ' + cooldown + ' min';
11002 } else if (btnNow) {
11003 btnNow.disabled = false;
11004 btnNow.textContent = 'Consolidate Now';
11005 }
11006 } catch (_) {
11007 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
11008 }
11009 } else {
11010 if (badge) {
11011 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
11012 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
11013 }
11014 if (lastPass) lastPass.textContent = 'Last pass: —';
11015 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
11016 if (quotaMeter) quotaMeter.style.display = 'none';
11017 }
11018 } catch (_) {
11019 card.style.display = 'none';
11020 }
11021 }
11022
11023 const btnConsolNow = el('btn-consol-now');
11024 if (btnConsolNow) {
11025 btnConsolNow.addEventListener('click', async () => {
11026 setButtonBusy(btnConsolNow, true, 'Previewing…');
11027 try {
11028 const preview = await api('/api/v1/memory/consolidate', {
11029 method: 'POST',
11030 body: JSON.stringify({ dry_run: true }),
11031 });
11032 setButtonBusy(btnConsolNow, false);
11033 const topicsRaw = preview.topics;
11034 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
11035 const events = preview.total_events ?? 0;
11036 // Fetch current quota to show remaining passes in the preview dialog.
11037 let quotaLine = '';
11038 try {
11039 const st = await api('/api/v1/memory/consolidate/status');
11040 const passUsed = st.pass_count_month ?? 0;
11041 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
11042 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
11043 if (passLimit === null) {
11044 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
11045 } else if (passLimit > 0) {
11046 const remaining = Math.max(0, passLimit - passUsed);
11047 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
11048 }
11049 } catch (_) {}
11050 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
11051 if (!ok) return;
11052 setButtonBusy(btnConsolNow, true, 'Consolidating…');
11053 await api('/api/v1/memory/consolidate', {
11054 method: 'POST',
11055 body: JSON.stringify({ dry_run: false }),
11056 });
11057 if (typeof showToast === 'function') showToast('Consolidation complete.');
11058 refreshConsolidationCard();
11059 } catch (e) {
11060 const msg = e?.message || 'Consolidation failed';
11061 if (typeof showToast === 'function') showToast(msg, true);
11062 // Re-check cooldown after a rate-limit response so the button state updates.
11063 refreshConsolidationCard();
11064 }
11065 setButtonBusy(btnConsolNow, false);
11066 });
11067 }
11068
11069 const btnConsolHistory = el('btn-consol-history');
11070 if (btnConsolHistory) {
11071 btnConsolHistory.addEventListener('click', async () => {
11072 setButtonBusy(btnConsolHistory, true, 'Loading…');
11073 try {
11074 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
11075 const events = res.events || res.history || [];
11076 setButtonBusy(btnConsolHistory, false);
11077 const modal = document.createElement('div');
11078 modal.className = 'modal';
11079 modal.setAttribute('aria-modal', 'true');
11080 modal.innerHTML =
11081 '<div class="modal-backdrop"></div>' +
11082 '<div class="modal-card consol-history-modal">' +
11083 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
11084 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
11085 document.body.appendChild(modal);
11086 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
11087 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
11088 modal.querySelector('.modal-close').onclick = () => modal.remove();
11089 } catch (e) {
11090 setButtonBusy(btnConsolHistory, false);
11091 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
11092 }
11093 });
11094 }
11095
11096 function openSettingsConsolidationTab() {
11097 openSettings();
11098 document.querySelectorAll('.settings-tab').forEach((t) => {
11099 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
11100 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
11101 });
11102 document.querySelectorAll('.settings-panel').forEach((p) => {
11103 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
11104 });
11105 loadConsolidationSettings();
11106 }
11107
11108 const btnConsolSettings = el('btn-consol-settings');
11109 if (btnConsolSettings) {
11110 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
11111 }
11112
11113 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
11114 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
11115 // Billing consolidation row is populated inline in loadBillingPanel's try block.
11116 // We add to the existing billing flow by hooking the billing API response.
11117
11118 // Refresh consolidation card when dashboard renders
11119 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
11120 })();
File History 8 commits
sha256:9eaa2a7ec9aaf5866cd03d0deadf843cca97778299d1857b5452f71d92a1880a feat(f24): flip DOCS_NOTION_HUB_KEY_AUTHORIZED=true (Tier 3) Human minor 17 days ago
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 41 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 58 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 75 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 77 days ago
sha256:f4def6a1a567d25eac87e879d96235c0588804a6c9b770d3958e74b22231db59 fix(test): align hub.js cache-bust contract with hub integr… Human 97 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 97 days ago