hub.js javascript
11,293 lines 455.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Knowtation Hub UI — list, calendar, overview, quick add, presets. Phase 11C.
3 */
4
5 (function () {
6 const params = new URLSearchParams(location.search);
7 // Build-time or deployment config: set window.HUB_API_BASE_URL (e.g. from config.js). Empty string = same origin (when static host proxies /api to the gateway).
8 const apiBase = (function resolveApiBase() {
9 if (typeof window === 'undefined') return 'http://localhost:3333';
10 const paramApi = params.get('api');
11 if (paramApi != null && String(paramApi).trim()) {
12 return String(paramApi).trim().replace(/\/$/, '');
13 }
14 const hostname = location.hostname || '';
15 const isLocalDev =
16 hostname === 'localhost' ||
17 hostname === '127.0.0.1' ||
18 hostname === '[::1]' ||
19 hostname === '::1';
20 // Self-hosted dev: always call the same origin as the page (npm run hub). Stale localStorage
21 // hub_api_url often points at a hosted gateway and causes HTML 404 for Node-only routes.
22 if (isLocalDev) {
23 return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
24 }
25 if (Object.prototype.hasOwnProperty.call(window, 'HUB_API_BASE_URL')) {
26 const v = window.HUB_API_BASE_URL;
27 if (v == null) {
28 return (
29 localStorage.getItem('hub_api_url') ||
30 location.origin ||
31 'http://localhost:3333'
32 ).replace(/\/$/, '');
33 }
34 const s = String(v).trim();
35 if (s === '') return (location.origin || 'http://localhost:3333').replace(/\/$/, '');
36 return s.replace(/\/$/, '');
37 }
38 return (localStorage.getItem('hub_api_url') || location.origin || 'http://localhost:3333').replace(/\/$/, '');
39 })();
40 /** Public MCP endpoint (https://…/mcp) when operator sets window.HUB_MCP_PUBLIC_URL in web/hub/config.js; else ''. */
41 const mcpPublicUrl = (function resolveMcpPublicUrl() {
42 if (typeof window === 'undefined') return '';
43 if (!Object.prototype.hasOwnProperty.call(window, 'HUB_MCP_PUBLIC_URL')) return '';
44 const v = window.HUB_MCP_PUBLIC_URL;
45 if (v == null) return '';
46 const s = String(v).trim();
47 if (s === '') return '';
48 return s.replace(/\/$/, '');
49 })();
50 /** Canonical doc: where Hub token, REST, remote MCP, and local CLI differ (copy blocks point here). */
51 const INTEGRATION_DOC_URL = 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md';
52 const hashParams = new URLSearchParams(location.hash.replace(/^#/, ''));
53 /** Used to defer onboarding until invite consume has run (see scheduleMaybeShowOnboardingWizard). */
54 const pageLoadHadInviteQuery = Boolean(params.get('invite'));
55 let token = hashParams.get('token') || params.get('token') || localStorage.getItem('hub_token');
56 if (token) {
57 localStorage.setItem('hub_token', token);
58 if (hashParams.has('token')) {
59 history.replaceState({}, '', location.pathname + location.search);
60 } else if (params.has('token')) {
61 const u = new URL(location.href);
62 u.searchParams.delete('token');
63 history.replaceState({}, '', u.toString());
64 }
65 }
66
67 /** Latest GET /api/v1/settings used for Backup tab (hosted repo field + sync body). */
68 let lastBackupSettingsPayload = null;
69
70 const PRESETS_KEY = 'hub_view_presets';
71 const el = (id) => document.getElementById(id);
72 const app = el('app');
73 const main = el('main');
74 const loginRequired = el('login-required');
75 const btnLoginGoogle = el('btn-login-google');
76 const btnLoginGithub = el('btn-login-github');
77 const btnLogout = el('btn-logout');
78 const btnNewNote = el('btn-new-note');
79 const btnImport = el('btn-import');
80 const btnHeaderSuggested = el('btn-header-suggested');
81 const btnHowToUse = el('btn-how-to-use');
82 const btnSettings = el('btn-settings');
83 const browseToolbar = el('browse-toolbar');
84 /** @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 filterContentClass = el('filter-content-class');
378 const filterNetwork = el('filter-network');
379 const filterWallet = el('filter-wallet');
380 const searchMode = el('search-mode');
381 const btnSearch = el('btn-search');
382 const btnClearSearch = el('btn-clear-search');
383 const btnApplyFilters = el('btn-apply-filters');
384 const btnReindex = el('btn-reindex');
385 const notesList = el('notes-list');
386 const notesTotal = el('notes-total');
387 /** True when the last unfiltered browse list (loadNotes, no list filters) returned zero notes. */
388 let hubBrowseListEmptyUnfiltered = false;
389 /** Last facets from {@link fetchFacetsResolved} (Hub create panel project pickers + similarity guard). */
390 let lastHubFacets = null;
391 /** Latest `/api/v1/vault/folders` list for subfolder derivation under `projects/<slug>/`. */
392 let lastVaultFoldersForCreate = [];
393 /** After “Keep my path” on similar-project modal, allow one create without re-prompting. */
394 let fullCreateSimilarOverrideOnce = false;
395 let fullCreateSimilarModalSuggestedSlug = '';
396 let fullCreateSimilarModalPendingPath = '';
397 let fullPathSimilarDebounceTimer = 0;
398 const filterChipsEl = el('filter-chips');
399 const presetsListEl = el('presets-list');
400 const presetNameInput = el('preset-name');
401 const hubBetaNote = el('hub-beta-note');
402 if (hubBetaNote && window.location.hostname !== 'knowtation.store' && window.location.hostname !== 'www.knowtation.store') hubBetaNote.classList.add('hidden');
403
404 let providers = null;
405 let calendarMonth = new Date();
406 let currentNotePathForCopy = '';
407 /** @type {{ path: string, body: string, frontmatter: Record<string, string> } | null} */
408 let currentOpenNote = null;
409 /** Increments when the SectionSource panel is reset so stale body-free reads do not render. */
410 let hubSectionSourceSeq = 0;
411 /** When set, full-create save may delete this path after posting the duplicate (optional checkbox). */
412 /** @type {{ path: string } | null} */
413 let pendingDuplicateDeleteSource = null;
414 /** AbortController for window resize while note edit body layout is active. */
415 let detailEditBodyLayoutAbort = null;
416
417 /** Hide the detail drawer (does not clear currentOpenNote). */
418 function hideDetailPanelChrome() {
419 const dp = el('detail-panel');
420 if (dp) {
421 dp.classList.add('hidden');
422 dp.classList.remove('detail-panel-proposal-wide');
423 }
424 clearReviewSplitPosition();
425 }
426
427 /** User dismisses the drawer (Escape, Close): clear open-note state. */
428 function closeDetailPanel() {
429 currentOpenNote = null;
430 currentNotePathForCopy = '';
431 resetDetailSectionSourceState();
432 teardownDetailEditBodyLayout();
433 hideDetailPanelChrome();
434 const bcbClose = el('btn-detail-copy-body');
435 if (bcbClose) bcbClose.classList.add('hidden');
436 const bcp = el('btn-copy-path');
437 if (bcp) bcp.classList.add('hidden');
438 }
439
440 let listSelectedIndex = 0;
441 /** Increments on each `openNote` call so stale fetch completions do not append duplicate actions or overwrite UI. */
442 let hubOpenNoteSeq = 0;
443 /** @type {import('chart.js').Chart[]} */
444 let chartInstances = [];
445
446 const FILTER_CHIPS_EXPANDED_KEY = 'hub_filter_chips_expanded';
447 let filterChipsExpanded = false;
448 try {
449 filterChipsExpanded = localStorage.getItem(FILTER_CHIPS_EXPANDED_KEY) === '1';
450 } catch (_) {
451 filterChipsExpanded = false;
452 }
453
454 const ACCENT_STORAGE_KEY = 'hub_accent_color';
455 const THEME_STORAGE_KEY = 'hub_theme';
456 const COLOR_PALETTE_STORAGE_KEY = 'hub_color_palette';
457 const DEFAULT_ACCENT = '#89cff0';
458 const DEFAULT_THEME = 'dark';
459 const DEFAULT_COLOR_PALETTE = 'default';
460 const VALID_COLOR_PALETTES = new Set([
461 'default',
462 'ocean',
463 'forest',
464 'sunset',
465 'lavender',
466 'ember',
467 'arctic',
468 'slate',
469 'midnight',
470 'sakura',
471 'sand',
472 'mint',
473 ]);
474 const loadingHtml = '<div class="loading-state" aria-live="polite">Loading…</div>';
475 function applyAccent(hex) {
476 if (hex) {
477 document.documentElement.style.setProperty('--accent', hex);
478 try {
479 localStorage.setItem(ACCENT_STORAGE_KEY, hex);
480 } catch (_) {}
481 }
482 }
483 function applyTheme(theme) {
484 const value = theme === 'light' ? 'light' : 'dark';
485 document.documentElement.setAttribute('data-theme', value === 'dark' ? '' : value);
486 try {
487 localStorage.setItem(THEME_STORAGE_KEY, value);
488 } catch (_) {}
489 }
490 function applyColorPalette(id) {
491 const p =
492 id && VALID_COLOR_PALETTES.has(String(id)) ? String(id) : DEFAULT_COLOR_PALETTE;
493 if (p === DEFAULT_COLOR_PALETTE) {
494 document.documentElement.removeAttribute('data-palette');
495 } else {
496 document.documentElement.setAttribute('data-palette', p);
497 }
498 try {
499 localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, p);
500 } catch (_) {}
501 }
502 function currentColorPalette() {
503 const a = document.documentElement.getAttribute('data-palette');
504 if (a && VALID_COLOR_PALETTES.has(a) && a !== DEFAULT_COLOR_PALETTE) return a;
505 return DEFAULT_COLOR_PALETTE;
506 }
507 (function initThemeAndAccent() {
508 try {
509 const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
510 if (savedTheme === 'light') applyTheme('light');
511 const savedAccent = localStorage.getItem(ACCENT_STORAGE_KEY);
512 if (savedAccent) applyAccent(savedAccent);
513 const savedPalette = localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
514 if (savedPalette) applyColorPalette(savedPalette);
515 } catch (_) {}
516 })();
517
518 function headers() {
519 const h = { 'Content-Type': 'application/json' };
520 if (token) h['Authorization'] = 'Bearer ' + token;
521 const vid = getCurrentVaultId();
522 if (vid) h['X-Vault-Id'] = vid;
523 return h;
524 }
525
526 // Persistent sessions: when the short-lived access token expires, silently exchange the
527 // HttpOnly refresh cookie for a new one instead of dropping the user to the login screen.
528 // Single-flight so a burst of 401s triggers exactly one refresh.
529 let refreshInFlight = null;
530 async function refreshAccessToken() {
531 if (refreshInFlight) return refreshInFlight;
532 refreshInFlight = (async () => {
533 try {
534 const res = await fetch(apiBase + '/api/v1/auth/refresh', {
535 method: 'POST',
536 credentials: 'include', // send the HttpOnly refresh cookie
537 cache: 'no-store',
538 headers: { 'Content-Type': 'application/json' },
539 });
540 if (!res.ok) return false;
541 const data = await res.json().catch(() => null);
542 if (data && typeof data.access_token === 'string' && data.access_token) {
543 token = data.access_token;
544 try { localStorage.setItem('hub_token', token); } catch (_) {}
545 return true;
546 }
547 return false;
548 } catch (_) {
549 return false;
550 }
551 })();
552 try {
553 return await refreshInFlight;
554 } finally {
555 refreshInFlight = null;
556 }
557 }
558
559 async function api(path, opts = {}) {
560 const method = (opts.method || 'GET').toUpperCase();
561 // GET/HEAD: retry up to 2×. POST/PATCH/DELETE: retry once only on pure network failures
562 // (before any HTTP response), which means the server never received the request so retrying
563 // is safe. Never retry on HTTP error responses (4xx/5xx) — those were received and processed.
564 //
565 // `opts.noRetry: true` opts out of retries entirely. Used by `POST /api/v1/index`: a 30s
566 // gateway timeout (Netlify Function cap) drops the client connection, which the browser
567 // surfaces as `Failed to fetch`. With retry on, the bridge then receives a SECOND index
568 // request while the first is still running, double-billing DeepInfra and worsening contention.
569 const maxNetworkRetries = opts.noRetry === true
570 ? 0
571 : (method === 'GET' || method === 'HEAD') ? 2 : 1;
572 // Strip non-fetch keys before forwarding to fetch() so they don't pollute the request init.
573 const { noRetry: _noRetry, ...fetchOpts } = opts;
574 // Internal one-shot control flag for the 401 silent-refresh retry; never forward to fetch().
575 delete fetchOpts._retriedAfterRefresh;
576 let res;
577 let networkRetries = maxNetworkRetries;
578 for (;;) {
579 try {
580 res = await fetch(apiBase + path, {
581 ...fetchOpts,
582 cache: fetchOpts.cache != null ? fetchOpts.cache : 'no-store',
583 headers: { ...headers(), ...fetchOpts.headers },
584 });
585 break;
586 } catch (e) {
587 const m = e && e.message ? String(e.message) : String(e);
588 if ((m === 'Failed to fetch' || m.includes('NetworkError')) && networkRetries > 0) {
589 networkRetries--;
590 await new Promise(resolve => setTimeout(resolve, (maxNetworkRetries - networkRetries) * 2000));
591 continue;
592 }
593 if (m === 'Failed to fetch' || m.includes('NetworkError')) {
594 throw new Error(
595 'Could not reach the API (' +
596 apiBase +
597 '). Check gateway status, CORS (HUB_CORS_ORIGIN), ad blockers, and Netlify limits.',
598 );
599 }
600 throw e instanceof Error ? e : new Error(m);
601 }
602 }
603 if (res.status === 401) {
604 // Try a one-time silent refresh before forcing re-login. Never recurse on the auth
605 // endpoints themselves, and only retry once per original request.
606 if (
607 path !== '/api/v1/auth/refresh' &&
608 path !== '/api/v1/auth/logout' &&
609 !opts._retriedAfterRefresh
610 ) {
611 const refreshed = await refreshAccessToken();
612 if (refreshed) {
613 return api(path, { ...opts, _retriedAfterRefresh: true });
614 }
615 }
616 token = null;
617 localStorage.removeItem('hub_token');
618 if (app) app.classList.add('login-screen');
619 main.classList.add('hidden');
620 loginRequired.classList.remove('hidden');
621 browseToolbar.classList.add('hidden');
622 btnNewNote.classList.add('hidden');
623 if (btnImport) btnImport.classList.add('hidden');
624 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
625 if (btnHowToUse) btnHowToUse.classList.add('hidden');
626 if (btnSettings) btnSettings.classList.add('hidden');
627 showLoginChrome();
628 throw new Error('Unauthorized');
629 }
630 let text = await res.text();
631 if (text.length > 0 && text.charCodeAt(0) === 0xfeff) text = text.slice(1);
632 let data;
633 try {
634 data = text ? JSON.parse(text) : null;
635 } catch (_) {
636 const t = text.trim();
637 if (/^<!DOCTYPE/i.test(t) || /<html/i.test(t)) {
638 throw new Error(
639 `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.`,
640 );
641 }
642 throw new Error(
643 'Response was not valid JSON (' +
644 res.status +
645 '). Start of body: ' +
646 t.slice(0, 120) +
647 (t.length > 120 ? '...' : ''),
648 );
649 }
650 if (!res.ok) {
651 const label = data?.error || res.statusText;
652 const detail = data?.message != null && String(data.message).trim() ? String(data.message).trim() : '';
653 const combined = detail ? `${label}: ${detail}` : label;
654 const err = new Error(combined);
655 if (data && data.code) err.code = data.code;
656 throw err;
657 }
658 return data;
659 }
660
661 /** Busy state for buttons during slow API calls (clear feedback on hosted). */
662 function setButtonBusy(btn, busy, labelWhenBusy) {
663 if (!btn || btn.nodeType !== 1) return;
664 const busyText = labelWhenBusy || 'Working…';
665 if (busy) {
666 if (btn.dataset.knowtationBtnRestLabel == null) {
667 btn.dataset.knowtationBtnRestLabel = btn.textContent;
668 }
669 btn.textContent = busyText;
670 btn.disabled = true;
671 btn.classList.add('btn-busy');
672 btn.setAttribute('aria-busy', 'true');
673 } else {
674 if (btn.dataset.knowtationBtnRestLabel != null) {
675 btn.textContent = btn.dataset.knowtationBtnRestLabel;
676 delete btn.dataset.knowtationBtnRestLabel;
677 }
678 btn.classList.remove('btn-busy');
679 btn.removeAttribute('aria-busy');
680 btn.disabled = false;
681 }
682 }
683
684 async function withButtonBusy(btn, labelWhenBusy, fn) {
685 if (!btn) return fn();
686 setButtonBusy(btn, true, labelWhenBusy);
687 try {
688 return await fn();
689 } finally {
690 setButtonBusy(btn, false);
691 }
692 }
693
694 const HOSTED_BACKUP_REPO_LS = 'knowtation_hosted_backup_repo';
695 /** If set, `resolveApiBase` uses this instead of `location.origin` — can point local Hub UI at Netlify by mistake. */
696 const HUB_API_URL_LS = 'hub_api_url';
697
698 const VAULT_ID_LS = 'hub_vault_id';
699 /** @see `web/hub/hub-client-import-zip.mjs` — 4B sequential import cap. */
700 const HUB_IMPORT_MAX_SEQUENTIAL = 200;
701 const importFileEl = el('import-file');
702 const importFileFolderEl = el('import-file-folder');
703 const importFolderHintEl = el('import-folder-hint');
704 const importBatchCancelBtn = el('import-batch-cancel');
705 const importBatchAriaEl = el('import-batch-aria');
706 /** Dropped files/folder (4C) — when set, submit uses this instead of the file inputs. */
707 /** @type {File[] | null} */
708 let importPendingDropFiles = null;
709 const importDropZoneEl = el('import-drop-zone');
710 const importDropStatusEl = el('import-drop-status');
711 /** @type {AbortController | null} */
712 let importBatchAbort = null;
713 const btnImportChooseFolder = el('btn-import-choose-folder');
714
715 function wrapFileWithWebkitRel(file, relPath) {
716 const w = new File([file], file.name, { type: file.type, lastModified: file.lastModified });
717 const rel = String(relPath || file.name).replace(/^\//, '');
718 try {
719 Object.defineProperty(w, 'webkitRelativePath', { value: rel, enumerable: true, configurable: true });
720 } catch (_) {}
721 return w;
722 }
723
724 /**
725 * @param {FileSystemFileEntry} fe
726 * @param {string} pathPrefix
727 * @returns {Promise<File>}
728 */
729 function fileEntryToFileWithPath(fe, pathPrefix) {
730 return new Promise((resolve, reject) => {
731 fe.file(
732 (file) => {
733 const rel = (String(pathPrefix || '') + file.name).replace(/^\//, '');
734 resolve(wrapFileWithWebkitRel(file, rel));
735 },
736 reject,
737 );
738 });
739 }
740
741 /**
742 * @param {FileSystemDirectoryEntry} dirEntry
743 * @param {string} pathPrefix
744 * @returns {Promise<File[]>}
745 */
746 async function readAllFilesInDirectoryEntry(dirEntry, pathPrefix) {
747 const all = [];
748 const reader = dirEntry.createReader();
749 let batch;
750 do {
751 /** @type {FileSystemEntry[]} */
752 batch = await new Promise((res, rej) => reader.readEntries(res, rej));
753 for (const e of batch) {
754 if (e.isFile) {
755 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(e), pathPrefix));
756 } else if (e.isDirectory) {
757 all.push(
758 ...(await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(e), pathPrefix + e.name + '/')),
759 );
760 }
761 }
762 } while (batch.length > 0);
763 return all;
764 }
765
766 /**
767 * @param {DataTransfer} dataTransfer
768 * @returns {Promise<File[]>}
769 */
770 async function collectFilesFromDataTransfer(dataTransfer) {
771 if (!dataTransfer) return [];
772 const canEntry =
773 dataTransfer.items &&
774 dataTransfer.items.length > 0 &&
775 Array.from(dataTransfer.items).some((it) => it.kind === 'file' && 'webkitGetAsEntry' in it);
776 if (canEntry) {
777 const all = [];
778 for (const item of Array.from(dataTransfer.items)) {
779 if (item.kind !== 'file') continue;
780 if (item.webkitGetAsEntry) {
781 const entry = item.webkitGetAsEntry();
782 if (entry) {
783 if (entry.isFile) {
784 all.push(await fileEntryToFileWithPath(/** @type {FileSystemFileEntry} */(entry), ''));
785 } else if (entry.isDirectory) {
786 all.push(
787 ...(
788 await readAllFilesInDirectoryEntry(/** @type {FileSystemDirectoryEntry} */(entry), entry.name + '/')
789 ),
790 );
791 }
792 } else {
793 const f = item.getAsFile();
794 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
795 }
796 } else {
797 const f = item.getAsFile();
798 if (f) all.push(wrapFileWithWebkitRel(f, f.name));
799 }
800 }
801 return all;
802 }
803 if (dataTransfer.files && dataTransfer.files.length) {
804 return Array.from(dataTransfer.files).map((f) => wrapFileWithWebkitRel(f, f.name));
805 }
806 return [];
807 }
808
809 function updateImportDropStatusUi() {
810 if (!importDropStatusEl) return;
811 if (importPendingDropFiles && importPendingDropFiles.length > 0) {
812 importDropStatusEl.hidden = false;
813 importDropStatusEl.textContent =
814 importPendingDropFiles.length +
815 ' file(s) from drop. Click Import, or use the file picker above to replace.';
816 } else {
817 importDropStatusEl.hidden = true;
818 importDropStatusEl.textContent = '';
819 }
820 }
821
822 function clearImportDropPending() {
823 importPendingDropFiles = null;
824 if (importDropZoneEl) importDropZoneEl.classList.remove('import-drop-zone--over');
825 updateImportDropStatusUi();
826 }
827
828 function setImportBatchAria(s) {
829 if (importBatchAriaEl) importBatchAriaEl.textContent = s || '';
830 }
831
832 function normalizeUrlOrigin(base) {
833 try {
834 const s = String(base || '').trim().replace(/\/$/, '');
835 if (!s) return '';
836 const u = new URL(s.startsWith('http') ? s : 'https://' + s);
837 return u.origin;
838 } catch (_) {
839 return '';
840 }
841 }
842
843 function isLocalHubHostname() {
844 const h = location.hostname;
845 return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
846 }
847
848 /** Local Hub tab but `apiBase` targets another origin (e.g. Netlify) — causes “Could not reach the API … knowtation-gateway…”. */
849 function localApiBaseFootgunActive() {
850 if (!isLocalHubHostname()) return false;
851 const pageO = normalizeUrlOrigin(location.origin);
852 const apiO = normalizeUrlOrigin(apiBase);
853 if (!pageO || !apiO) return false;
854 return pageO !== apiO;
855 }
856
857 function refreshApiBaseFootgunBanner() {
858 const b = el('hub-api-base-footgun-banner');
859 if (!b) return;
860 if (!localApiBaseFootgunActive()) {
861 b.classList.add('hidden');
862 b.innerHTML = '';
863 return;
864 }
865 let lsHint = false;
866 try {
867 lsHint = Boolean(localStorage.getItem(HUB_API_URL_LS));
868 } catch (_) {}
869 const qsHint = Boolean(params.get('api'));
870 b.classList.remove('hidden');
871 const hint =
872 (lsHint ? ' <code>localStorage.' + HUB_API_URL_LS + '</code> is set.' : '') +
873 (qsHint ? ' This URL has an <code>?api=</code> override.' : '');
874 b.innerHTML =
875 '<p><strong>Wrong API for this tab.</strong> This page is on <code>' +
876 escapeHtml(location.origin) +
877 '</code> but the Hub calls <code>' +
878 escapeHtml(apiBase) +
879 '</code> for requests (settings, backup, notes).' +
880 hint +
881 ' For self-hosted <code>npm run hub</code>, clear the override so the API matches this origin, then reload.</p>' +
882 '<p><button type="button" class="btn-secondary" id="hub-api-footgun-clear">Clear API override &amp; reload</button></p>';
883 const clearBtn = el('hub-api-footgun-clear');
884 if (clearBtn) {
885 clearBtn.onclick = () => {
886 try {
887 localStorage.removeItem(HUB_API_URL_LS);
888 } catch (_) {}
889 const u = new URL(location.href);
890 u.searchParams.delete('api');
891 window.location.href = u.toString();
892 };
893 }
894 }
895
896 function getCurrentVaultId() {
897 try {
898 return localStorage.getItem(VAULT_ID_LS) || 'default';
899 } catch (_) {
900 return 'default';
901 }
902 }
903
904 function setCurrentVaultId(id) {
905 try {
906 localStorage.setItem(VAULT_ID_LS, id);
907 } catch (_) {}
908 }
909
910 /** Per-vault hint: Meaning (semantic) search may lag vault edits until Re-index runs successfully. */
911 const HUB_SEMANTIC_INDEX_STALE_PREFIX = 'hub_semantic_index_stale_v1:';
912
913 function hubSemanticIndexStaleLsKey(vaultId) {
914 const v = vaultId != null && String(vaultId).trim() !== '' ? String(vaultId).trim() : 'default';
915 return HUB_SEMANTIC_INDEX_STALE_PREFIX + v;
916 }
917
918 function hubRefreshIndexStaleBanner() {
919 const banner = el('hub-index-stale-banner');
920 if (!banner) return;
921 let flagged = false;
922 try {
923 flagged = Boolean(localStorage.getItem(hubSemanticIndexStaleLsKey(getCurrentVaultId())));
924 } catch (_) {
925 flagged = false;
926 }
927 if (!flagged) {
928 banner.classList.add('hidden');
929 return;
930 }
931 banner.classList.remove('hidden');
932 }
933
934 function hubMarkSemanticIndexStaleForVault(vaultId) {
935 try {
936 localStorage.setItem(hubSemanticIndexStaleLsKey(vaultId), String(Date.now()));
937 } catch (_) {}
938 hubRefreshIndexStaleBanner();
939 }
940
941 function hubMarkSemanticIndexStale() {
942 hubMarkSemanticIndexStaleForVault(getCurrentVaultId());
943 }
944
945 function hubClearSemanticIndexStaleForVault(vaultId) {
946 try {
947 localStorage.removeItem(hubSemanticIndexStaleLsKey(vaultId));
948 } catch (_) {}
949 hubRefreshIndexStaleBanner();
950 }
951
952 function hubClearSemanticIndexStale() {
953 hubClearSemanticIndexStaleForVault(getCurrentVaultId());
954 }
955
956 function updateVaultSwitcher(vaultList, allowedVaultIds) {
957 const wrap = el('vault-switcher-wrap');
958 const select = el('vault-switcher');
959 if (!wrap || !select) return;
960 const rows = Array.isArray(vaultList) ? vaultList : [];
961 const byId = new Map(rows.map((v) => [String(v.id), v]));
962 let allowed =
963 Array.isArray(allowedVaultIds) && allowedVaultIds.length
964 ? allowedVaultIds.map(String)
965 : rows.length
966 ? rows.map((v) => String(v.id))
967 : ['default'];
968 allowed = [...new Set(allowed)];
969 const options = allowed.map((id) => {
970 const v = byId.get(id);
971 return { id, label: v && (v.label || v.id) ? String(v.label || v.id) : id };
972 });
973 select.innerHTML = options
974 .map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label) + '</option>')
975 .join('');
976 select.value = getCurrentVaultId();
977 if (!allowed.includes(select.value)) select.value = allowed[0] || 'default';
978 setCurrentVaultId(select.value);
979 // Always surface the current vault once settings load (even with a single
980 // vault) so the control is discoverable under the left-rail Vault area.
981 wrap.classList.toggle('hidden', options.length < 1);
982 if (allowed.length >= 2 && options.length === 1) {
983 select.title =
984 '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.';
985 } else if (options.length === 1) {
986 select.title = 'Current vault. Add more under Settings → Vaults when your role allows.';
987 } else {
988 select.title = 'Switch the active vault for notes, search, and proposals.';
989 }
990 select.onchange = () => {
991 setCurrentVaultId(select.value);
992 loadFacets();
993 loadNotes();
994 loadProposals();
995 hubRefreshIndexStaleBanner();
996 };
997 }
998
999 function applyHostedUiFromSettings(s) {
1000 if (!s || typeof s !== 'object') return;
1001 const hosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1002 window.__hubIsHosted = hosted;
1003 const btn = el('btn-projects-help');
1004 if (btn) btn.classList.toggle('hidden', !hosted);
1005 }
1006
1007 function normalizeGithubRepoSlug(raw) {
1008 let t = (raw || '').trim();
1009 if (!t) return '';
1010 t = t.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
1011 const parts = t.split('/').filter(Boolean);
1012 if (parts.length >= 2) return parts[0] + '/' + parts[1];
1013 return t;
1014 }
1015
1016 /** Hosted (canister): any logged-in user may sync to their own GitHub; self-hosted still requires admin. */
1017 function settingsSyncDisabled(s, vg, isHosted) {
1018 const isAdmin = s.role === 'admin';
1019 const hostedGitBackup = isHosted && s.github_connect_available;
1020 if (hostedGitBackup) {
1021 const inputEl = el('settings-hosted-repo');
1022 const inputRepo = normalizeGithubRepoSlug(inputEl && inputEl.value);
1023 const slug = inputRepo || normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) || normalizeGithubRepoSlug(s.repo);
1024 return !s.github_connected || !slug;
1025 }
1026 return !vg.enabled || !vg.has_remote || !isAdmin;
1027 }
1028
1029 /** After Connect GitHub, blob read-after-write can lag; retry settings until github_connected or timeout. */
1030 async function fetchSettingsForBackupModal() {
1031 const pendingRaw = sessionStorage.getItem('knowtation_github_connect_pending');
1032 const pendingTs = pendingRaw ? parseInt(pendingRaw, 10) : NaN;
1033 const pendingFresh = Number.isFinite(pendingTs) && Date.now() - pendingTs < 120000;
1034 if (!pendingFresh) {
1035 if (pendingRaw) sessionStorage.removeItem('knowtation_github_connect_pending');
1036 return api('/api/v1/settings');
1037 }
1038 let s;
1039 for (let attempt = 0; attempt < 8; attempt++) {
1040 s = await api('/api/v1/settings');
1041 if (s.github_connected || !s.github_connect_available) break;
1042 if (attempt < 7) await new Promise((r) => setTimeout(r, 600));
1043 }
1044 sessionStorage.removeItem('knowtation_github_connect_pending');
1045 return s;
1046 }
1047
1048 /** Align with hub/server effectiveRole: viewer read-only; member maps to editor for writes. */
1049 function hubUserCanWriteNotes() {
1050 const r = window.__hubUserRole;
1051 return r === 'editor' || r === 'admin' || r === 'member';
1052 }
1053
1054 /** Same roles as POST /api/v1/proposals on Hub (evaluators propose; viewers do not). */
1055 function hubUserMayProposeFromNote() {
1056 const r = window.__hubUserRole;
1057 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1058 }
1059
1060 /** Download current note (POST /api/v1/export); allowed for any vault reader including viewer. */
1061 function hubUserCanExportNote() {
1062 const r = window.__hubUserRole || 'member';
1063 return (
1064 r === 'editor' || r === 'admin' || r === 'member' || r === 'viewer' || r === 'evaluator'
1065 );
1066 }
1067
1068 /** Proposal Enrich (AI): evaluators may run it without note-write roles; editors/admins/members still qualify. */
1069 function hubUserMayEnrichProposal() {
1070 const r = window.__hubUserRole;
1071 return r === 'editor' || r === 'admin' || r === 'member' || r === 'evaluator';
1072 }
1073
1074 /** Multi-vault copy/move in note detail (Settings must list ≥2 allowed vaults). */
1075 function hubHasMultipleVaultsForCopy() {
1076 const s = lastBackupSettingsPayload;
1077 if (!s || !Array.isArray(s.allowed_vault_ids)) return false;
1078 return s.allowed_vault_ids.filter(Boolean).length >= 2;
1079 }
1080
1081 function hubUserIsAdmin() {
1082 return window.__hubUserRole === 'admin';
1083 }
1084
1085 /** Delete vault: self-hosted admins only; hosted matches “create vault” (writer + workspace owner when set). */
1086 function hubUserMayDeleteVault() {
1087 if (!hubUserCanWriteNotes()) return false;
1088 if (isHostedHubFromSettings()) {
1089 const ws = lastBackupSettingsPayload;
1090 const ownerId =
1091 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
1092 ? String(ws.workspace_owner_id).trim()
1093 : '';
1094 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
1095 if (ownerId && me && me !== ownerId) return false;
1096 return true;
1097 }
1098 return hubUserIsAdmin();
1099 }
1100
1101 function populateSettingsDeleteVaultSelect(s) {
1102 const sel = el('settings-delete-vault-select');
1103 if (!sel) return;
1104 const vaultList = (s && Array.isArray(s.vault_list) && s.vault_list) || [];
1105 const allowedRaw = s && Array.isArray(s.allowed_vault_ids) ? s.allowed_vault_ids : null;
1106 const allowedSet = allowedRaw && allowedRaw.length > 0 ? new Set(allowedRaw.map(String)) : null;
1107 const opts = vaultList.filter((v) => {
1108 if (!v || v.id == null) return false;
1109 const id = String(v.id).trim();
1110 if (!id || id === 'default') return false;
1111 if (allowedSet && !allowedSet.has(id)) return false;
1112 return true;
1113 });
1114 sel.innerHTML =
1115 opts.length === 0
1116 ? '<option value="">(no extra vaults)</option>'
1117 : '<option value="">— Choose vault —</option>' +
1118 opts
1119 .map(
1120 (v) =>
1121 '<option value="' +
1122 escapeHtml(String(v.id)) +
1123 '">' +
1124 escapeHtml(String(v.label != null && v.label !== '' ? v.label : v.id)) +
1125 '</option>',
1126 )
1127 .join('');
1128 }
1129
1130 function refreshVaultDeleteSubsection() {
1131 const wrap = el('settings-danger-zone-vault');
1132 if (!wrap) return;
1133 const s = lastBackupSettingsPayload;
1134 if (!s || !hubUserMayDeleteVault()) {
1135 wrap.classList.add('hidden');
1136 return;
1137 }
1138 populateSettingsDeleteVaultSelect(s);
1139 const vaultList = (s.vault_list) || [];
1140 const extra = vaultList.filter((v) => v && String(v.id).trim() && String(v.id).trim() !== 'default');
1141 if (extra.length === 0) {
1142 wrap.classList.add('hidden');
1143 return;
1144 }
1145 wrap.classList.remove('hidden');
1146 }
1147
1148 function refreshDeleteProjectPanelVisibility() {
1149 const panel = el('settings-danger-zone-panel');
1150 if (panel) panel.classList.toggle('hidden', !hubUserCanWriteNotes());
1151 refreshVaultDeleteSubsection();
1152 }
1153
1154 /** Apply GET /api/v1/settings payload to header vault switcher, hosted flag, and cached backup modal state. */
1155 function applySettingsPayloadToHubChrome(s) {
1156 if (!s || typeof s !== 'object') return;
1157 lastBackupSettingsPayload = s;
1158 if (s.role) window.__hubUserRole = String(s.role);
1159 refreshDeleteProjectPanelVisibility();
1160 refreshNewProposalTabVisibility();
1161 const allowed = (s.allowed_vault_ids || []).map(String);
1162 const current = String(getCurrentVaultId());
1163 if (allowed.length && !allowed.includes(current)) {
1164 setCurrentVaultId(allowed[0] || 'default');
1165 }
1166 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
1167 if (typeof refreshAgentCredVaultSelect === 'function') refreshAgentCredVaultSelect();
1168 applyHostedUiFromSettings(s);
1169 window.__hubProposalEnrich = Boolean(s.proposal_enrich_enabled);
1170 window.__hubProposalEvaluationRequired = Boolean(s.proposal_evaluation_required);
1171 window.__hubProposalReviewHints = Boolean(s.proposal_review_hints_enabled);
1172 window.__hubEvaluatorMayApprove = Boolean(s.hub_evaluator_may_approve);
1173 window.__hubProposalRubricItems = Array.isArray(s.proposal_rubric?.items) ? s.proposal_rubric.items : [];
1174 syncPendingEvalQuickChip();
1175 const metaSelf = el('settings-bulk-metadata-self-only');
1176 if (metaSelf) metaSelf.classList.remove('hidden');
1177 applyMuseBridgePanel(s);
1178 }
1179
1180 /** Settings → Integrations: Muse thin bridge status + self-hosted admin URL field. */
1181 function applyMuseBridgePanel(s) {
1182 if (!s || typeof s !== 'object') return;
1183 const mb = s.muse_bridge;
1184 const statusEl = el('settings-muse-status');
1185 const envHint = el('settings-muse-env-hint');
1186 const input = el('settings-muse-url');
1187 const saveBtn = el('btn-settings-muse-save');
1188 const msg = el('settings-muse-msg');
1189 if (msg) {
1190 msg.textContent = '';
1191 msg.className = 'settings-msg';
1192 }
1193 if (!mb) {
1194 if (statusEl) statusEl.textContent = '—';
1195 if (input) {
1196 input.value = '';
1197 input.disabled = true;
1198 }
1199 if (saveBtn) saveBtn.classList.add('hidden');
1200 return;
1201 }
1202 const isHosted = String(s.vault_path_display || '').toLowerCase() === 'canister';
1203 const isAdmin = s.role === 'admin';
1204 if (statusEl) {
1205 statusEl.textContent =
1206 mb.enabled && mb.origin
1207 ? 'Server status: linked — ' + mb.origin
1208 : 'Server status: Muse link not configured for this Hub.';
1209 }
1210 if (envHint) {
1211 envHint.classList.toggle('hidden', !mb.env_override_active);
1212 envHint.textContent = mb.env_override_active
1213 ? '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.'
1214 : '';
1215 }
1216 if (input) {
1217 input.value = mb.yaml_url_for_edit != null ? String(mb.yaml_url_for_edit) : '';
1218 const canEdit = !isHosted && isAdmin && mb.url_editable === true;
1219 input.disabled = !canEdit;
1220 input.title = canEdit
1221 ? ''
1222 : isHosted
1223 ? 'Knowtation Cloud: the Muse base URL is set by the operator, not here.'
1224 : !isAdmin
1225 ? 'Only admins can save the Muse URL.'
1226 : 'Unset MUSE_URL in the Hub environment to allow saving from Settings.';
1227 }
1228 if (saveBtn) {
1229 const show = !isHosted && isAdmin && mb.url_editable === true;
1230 saveBtn.classList.toggle('hidden', !show);
1231 }
1232 }
1233
1234 function showLoginChrome() {
1235 btnLogout.classList.add('hidden');
1236 userName.textContent = '';
1237 if (!providers) return;
1238 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1239 if (providers.github) btnLoginGithub.classList.remove('hidden');
1240 if (!providers.google && !providers.github) {
1241 oauthNotConfigured.classList.remove('hidden');
1242 if (loginIntro) loginIntro.classList.add('hidden');
1243 }
1244 }
1245
1246 /** Onboarding wizard — logic module: ./onboarding-wizard.mjs */
1247 let onboardingModulePromise = null;
1248 function loadOnboardingModule() {
1249 if (!onboardingModulePromise) {
1250 onboardingModulePromise = import('./onboarding-wizard.mjs?v=20260424');
1251 }
1252 return onboardingModulePromise;
1253 }
1254
1255 function getOnboardingUserKey() {
1256 if (!token) return '';
1257 try {
1258 const payload = JSON.parse(atob(token.split('.')[1]));
1259 return String(payload.sub || payload.email || 'unknown');
1260 } catch (_) {
1261 return 'unknown';
1262 }
1263 }
1264
1265 /**
1266 * Choose the 9-step hosted wizard vs the short self-hosted wizard.
1267 * Canister vault from API = hosted. Production Hub hostname = hosted even if settings
1268 * have not hydrated yet (avoids showing disk-path steps on knowtation.store).
1269 */
1270 function wizardHostedFromContext(settingsPayload) {
1271 const s = settingsPayload !== undefined ? settingsPayload : lastBackupSettingsPayload;
1272 const vd = String(s && s.vault_path_display ? s.vault_path_display : '').toLowerCase();
1273 if (vd === 'canister') return true;
1274 try {
1275 const h = typeof location !== 'undefined' && location.hostname ? String(location.hostname).toLowerCase() : '';
1276 if (h === 'knowtation.store' || h === 'www.knowtation.store') return true;
1277 } catch (_) {}
1278 return false;
1279 }
1280
1281 function persistOnboardingProgress(mod, partial) {
1282 const userKey = getOnboardingUserKey();
1283 const isHosted = wizardHostedFromContext();
1284 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1285 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1286 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1287 st = mod.createFreshState(userKey, hostingPath);
1288 }
1289 Object.assign(st, partial);
1290 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(st));
1291 }
1292
1293 let onboardingWizardBindingsDone = false;
1294 let onboardingRenderStep = function () {};
1295
1296 function closeOnboardingWizardResume() {
1297 const modal = el('modal-onboarding');
1298 if (!modal || modal.classList.contains('hidden')) return;
1299 modal.classList.add('hidden');
1300 }
1301
1302 function closeOnboardingWizardDismiss() {
1303 loadOnboardingModule()
1304 .then((mod) => {
1305 persistOnboardingProgress(mod, { status: 'dismissed', dismissedAt: Date.now() });
1306 updateEmptyVaultStripVisibility();
1307 })
1308 .catch(function () {});
1309 const modal = el('modal-onboarding');
1310 if (modal) modal.classList.add('hidden');
1311 }
1312
1313 function bindOnboardingWizardOnce(mod) {
1314 if (onboardingWizardBindingsDone) return;
1315 onboardingWizardBindingsDone = true;
1316 const modal = el('modal-onboarding');
1317 const closeBtn = el('modal-onboarding-close');
1318 const backdrop = el('modal-onboarding-backdrop');
1319 const btnSkip = el('btn-onboarding-skip');
1320 const btnBack = el('btn-onboarding-back');
1321 const btnNext = el('btn-onboarding-next');
1322 const body = el('onboarding-step-body');
1323 const progress = el('onboarding-progress');
1324 const live = el('onboarding-live');
1325 const secondary = el('onboarding-secondary-actions');
1326
1327 function handleSecondaryAction(id) {
1328 /* 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. */
1329 if (id === 'projectsHelp') {
1330 openProjectsHelpModal();
1331 return;
1332 }
1333 if (id === 'howToKnowledge') {
1334 openHowToUse('knowledge-agents');
1335 return;
1336 }
1337 if (id === 'openSettingsBackup') {
1338 openSettings();
1339 return;
1340 }
1341 if (id === 'openSettingsIntegrations') {
1342 openSettingsIntegrationsTab();
1343 return;
1344 }
1345 if (id === 'howToSetup4') {
1346 openHowToUse('setup', 'how-to-step-selfhosted-index');
1347 return;
1348 }
1349 if (id === 'howToSetup3') {
1350 openHowToUse('setup', 'how-to-step-selfhosted-oauth');
1351 return;
1352 }
1353 if (id === 'openWhyTokenDoc') {
1354 window.open(
1355 'https://github.com/aaronrene/knowtation/blob/main/docs/TOKEN-SAVINGS.md',
1356 '_blank',
1357 'noopener,noreferrer',
1358 );
1359 return;
1360 }
1361 if (id === 'openImportModal') {
1362 closeOnboardingWizardResume();
1363 openImportModal();
1364 return;
1365 }
1366 if (id === 'openImportSourcesDoc') {
1367 window.open(
1368 'https://github.com/aaronrene/knowtation/blob/main/docs/IMPORT-SOURCES.md',
1369 '_blank',
1370 'noopener,noreferrer',
1371 );
1372 return;
1373 }
1374 if (id === 'openAgentDocProposals' || id === 'openAgentIntegrationDoc') {
1375 window.open(
1376 id === 'openAgentDocProposals'
1377 ? 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md#4-proposals-review-before-commit'
1378 : 'https://github.com/aaronrene/knowtation/blob/main/docs/AGENT-INTEGRATION.md',
1379 '_blank',
1380 'noopener,noreferrer',
1381 );
1382 return;
1383 }
1384 if (id === 'focusSuggestedTab') {
1385 closeOnboardingWizardResume();
1386 switchHubMainTab('suggested');
1387 return;
1388 }
1389 }
1390
1391 onboardingRenderStep = function renderOnboardingStep() {
1392 const userKey = getOnboardingUserKey();
1393 const isHosted = wizardHostedFromContext();
1394 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1395 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1396 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1397 st = mod.createFreshState(userKey, hostingPath);
1398 }
1399 const total = mod.getStepCount(isHosted);
1400 const idx = Math.min(Math.max(0, st.stepIndex), total - 1);
1401 const content = mod.getStepContent(isHosted, idx);
1402 if (body) body.innerHTML = content ? content.bodyHtml : '';
1403 if (content && content.id === 'h-imports' && body) {
1404 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1405 if (ta) ta.value = mod.LLM_SELF_HELP_EXPORT_PROMPT;
1406 }
1407
1408 if (progress) {
1409 progress.innerHTML = '';
1410 for (let i = 0; i < total; i++) {
1411 const d = document.createElement('span');
1412 d.className = 'onboarding-dot' + (i === idx ? ' onboarding-dot-active' : '');
1413 d.title = 'Step ' + (i + 1) + ' of ' + total;
1414 progress.appendChild(d);
1415 }
1416 }
1417 if (live && content) live.textContent = content.title + ', step ' + (idx + 1) + ' of ' + total;
1418
1419 if (btnBack) btnBack.disabled = idx <= 0;
1420 if (btnNext) btnNext.textContent = idx >= total - 1 ? 'Done' : 'Next';
1421
1422 if (secondary) {
1423 secondary.innerHTML = '';
1424 mod.getStepSecondaryActions(isHosted, idx).forEach((a) => {
1425 const b = document.createElement('button');
1426 b.type = 'button';
1427 b.className = 'btn-link btn-link-small';
1428 b.textContent = a.label;
1429 b.addEventListener('click', () => handleSecondaryAction(a.id));
1430 secondary.appendChild(b);
1431 });
1432 }
1433 };
1434
1435 if (btnBack) {
1436 btnBack.addEventListener('click', () => {
1437 const st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1438 if (!st || st.status !== 'in_progress') return;
1439 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: Math.max(0, st.stepIndex - 1) });
1440 onboardingRenderStep();
1441 });
1442 }
1443 if (btnNext) {
1444 btnNext.addEventListener('click', () => {
1445 const userKey = getOnboardingUserKey();
1446 const isHosted = wizardHostedFromContext();
1447 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1448 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY)) || mod.createFreshState(userKey, hostingPath);
1449 if (st.userKey !== userKey || st.hostingPath !== hostingPath) st = mod.createFreshState(userKey, hostingPath);
1450 const total = mod.getStepCount(isHosted);
1451 if (st.stepIndex >= total - 1) {
1452 persistOnboardingProgress(mod, { status: 'completed', completedAt: Date.now(), stepIndex: total - 1 });
1453 if (modal) modal.classList.add('hidden');
1454 return;
1455 }
1456 persistOnboardingProgress(mod, { status: 'in_progress', stepIndex: st.stepIndex + 1 });
1457 onboardingRenderStep();
1458 });
1459 }
1460 if (btnSkip) btnSkip.addEventListener('click', closeOnboardingWizardDismiss);
1461 if (closeBtn) closeBtn.addEventListener('click', closeOnboardingWizardResume);
1462 if (backdrop) backdrop.addEventListener('click', closeOnboardingWizardResume);
1463
1464 modal.addEventListener('click', (ev) => {
1465 const copyBtn = ev.target && ev.target.closest && ev.target.closest('.onboarding-copy-llm-btn');
1466 if (!copyBtn || !body) return;
1467 const ta = body.querySelector('[data-onboarding-llm-prompt]');
1468 const txt = ta && ta.value ? String(ta.value) : '';
1469 if (!txt || !navigator.clipboard || !navigator.clipboard.writeText) return;
1470 ev.preventDefault();
1471 void navigator.clipboard.writeText(txt).then(() => {
1472 if (typeof showToast === 'function') showToast('Copied export helper prompt');
1473 });
1474 });
1475 }
1476
1477 async function openOnboardingWizard(opts) {
1478 const restart = opts && opts.restart;
1479 const mod = await loadOnboardingModule();
1480 bindOnboardingWizardOnce(mod);
1481 const userKey = getOnboardingUserKey();
1482 const isHosted = wizardHostedFromContext();
1483 const hostingPath = isHosted ? 'hosted' : 'selfhosted';
1484 if (restart) {
1485 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1486 } else {
1487 let st = mod.parseOnboardingState(localStorage.getItem(mod.ONBOARDING_LS_KEY));
1488 if (!st || st.userKey !== userKey || st.hostingPath !== hostingPath) {
1489 localStorage.setItem(mod.ONBOARDING_LS_KEY, mod.serializeOnboardingState(mod.createFreshState(userKey, hostingPath)));
1490 }
1491 }
1492 const modal = el('modal-onboarding');
1493 if (modal) modal.classList.remove('hidden');
1494 onboardingRenderStep();
1495 const btnNext = el('btn-onboarding-next');
1496 if (btnNext) setTimeout(() => btnNext.focus(), 50);
1497 }
1498
1499 async function scheduleMaybeShowOnboardingWizard(_s) {
1500 // Auto-popup removed: open only from How to use → Open setup walkthrough / Setup guide.
1501 return;
1502 }
1503
1504 function syncHubHeaderOffset() {
1505 const header = document.querySelector('.hub-header');
1506 if (!header) return;
1507 const h = Math.max(48, Math.round(header.getBoundingClientRect().height));
1508 document.documentElement.style.setProperty('--hub-header-offset', h + 'px');
1509 }
1510
1511 function showMain() {
1512 if (app) app.classList.remove('login-screen');
1513 loginRequired.classList.add('hidden');
1514 main.classList.remove('hidden');
1515 btnHowToUse.classList.remove('hidden');
1516 if (btnSettings) btnSettings.classList.remove('hidden');
1517 syncHubHeaderOffset();
1518 syncModeToolbars(getActiveHubMainTab());
1519 if (token) {
1520 btnLoginGoogle.classList.add('hidden');
1521 btnLoginGithub.classList.add('hidden');
1522 oauthNotConfigured.classList.add('hidden');
1523 btnLogout.classList.remove('hidden');
1524 try {
1525 const payload = JSON.parse(atob(token.split('.')[1]));
1526 userName.textContent = payload.name || payload.sub || 'Logged in';
1527 window.__hubUserRole = payload.role || 'member';
1528 const isViewer = window.__hubUserRole === 'viewer';
1529 if (btnNewNote) btnNewNote.classList.toggle('hidden', isViewer);
1530 if (btnImport) btnImport.classList.toggle('hidden', isViewer);
1531 const railImport = el('hub-rail-import');
1532 if (railImport) railImport.classList.toggle('hidden', isViewer);
1533 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1534 refreshDeleteProjectPanelVisibility();
1535 void refreshReviewBadge();
1536 } catch (_) {
1537 userName.textContent = 'Logged in';
1538 window.__hubUserRole = 'member';
1539 if (btnNewNote) btnNewNote.classList.remove('hidden');
1540 if (btnImport) btnImport.classList.remove('hidden');
1541 const railImport = el('hub-rail-import');
1542 if (railImport) railImport.classList.remove('hidden');
1543 if (btnHeaderSuggested) btnHeaderSuggested.classList.remove('hidden');
1544 refreshDeleteProjectPanelVisibility();
1545 void refreshReviewBadge();
1546 }
1547 } else {
1548 if (btnNewNote) btnNewNote.classList.add('hidden');
1549 if (btnImport) btnImport.classList.add('hidden');
1550 const railImport = el('hub-rail-import');
1551 if (railImport) railImport.classList.add('hidden');
1552 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1553 applyReviewBadgeCount(0);
1554 }
1555 hubRefreshIndexStaleBanner();
1556 }
1557
1558 function loginUrl(provider) {
1559 const u = apiBase + '/api/v1/auth/login?provider=' + provider;
1560 const invite = params.get('invite');
1561 return invite ? u + '&invite=' + encodeURIComponent(invite) : u;
1562 }
1563 // Pre-warm the gateway Lambda before navigating to the OAuth URL.
1564 // Without this, a cold start (12-30 s) causes ERR_CONNECTION_CLOSED in the browser
1565 // because a direct window.location.href navigation has no retry mechanism.
1566 // We fire a cheap /api/v1/auth/providers fetch first; once it returns the Lambda is
1567 // guaranteed warm, and the OAuth redirect hits a hot instance.
1568 async function oauthNavigate(provider, btn) {
1569 const original = btn.textContent;
1570 btn.disabled = true;
1571 btn.textContent = 'Connecting…';
1572 try {
1573 // Allow up to 22 s for the cold start; the button stays in "Connecting…" state
1574 // during this time so the user knows something is happening.
1575 await fetch(apiBase + '/api/v1/auth/providers', {
1576 cache: 'no-store',
1577 signal: AbortSignal.timeout(22000),
1578 });
1579 } catch (_) {
1580 // Fetch failed — navigate anyway; the Lambda may still be starting up and the
1581 // OAuth handler itself has the full 26 s budget once TCP is established.
1582 }
1583 window.location.href = loginUrl(provider);
1584 // Navigation is underway; restore button state in case the browser returns here.
1585 setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 5000);
1586 }
1587 btnLoginGoogle.onclick = (e) => oauthNavigate('google', e.currentTarget);
1588 btnLoginGithub.onclick = (e) => oauthNavigate('github', e.currentTarget);
1589
1590 btnLogout.onclick = () => {
1591 // Revoke the refresh token server-side (real logout), then clear local state regardless
1592 // of whether the network call succeeds.
1593 try {
1594 fetch(apiBase + '/api/v1/auth/logout', {
1595 method: 'POST',
1596 credentials: 'include',
1597 cache: 'no-store',
1598 headers: { 'Content-Type': 'application/json' },
1599 }).catch(() => {});
1600 } catch (_) { /* best effort */ }
1601 token = null;
1602 localStorage.removeItem('hub_token');
1603 if (app) app.classList.add('login-screen');
1604 main.classList.add('hidden');
1605 browseToolbar.classList.add('hidden');
1606 btnNewNote.classList.add('hidden');
1607 if (btnImport) btnImport.classList.add('hidden');
1608 if (btnHeaderSuggested) btnHeaderSuggested.classList.add('hidden');
1609 if (btnHowToUse) btnHowToUse.classList.add('hidden');
1610 if (btnSettings) btnSettings.classList.add('hidden');
1611 closeOnboardingWizardResume();
1612 loginRequired.classList.remove('hidden');
1613 if (loginIntro) loginIntro.classList.remove('hidden');
1614 showLoginChrome();
1615 };
1616
1617 async function initProviders() {
1618 for (let attempt = 0; attempt < 3; attempt++) {
1619 try {
1620 const r = await fetch(apiBase + '/api/v1/auth/providers', { cache: 'no-store' });
1621 if (!r.ok) throw new Error('providers');
1622 providers = await r.json();
1623 break;
1624 } catch (_) {
1625 if (attempt < 2) {
1626 await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 3000));
1627 continue;
1628 }
1629 providers = { google: false, github: false };
1630 oauthNotConfigured.classList.remove('hidden');
1631 if (loginIntro) loginIntro.classList.add('hidden');
1632 const first = oauthNotConfigured.querySelector('p');
1633 if (first) {
1634 const isHosted = location.origin !== 'http://localhost:3333' && location.origin !== 'http://127.0.0.1:3333';
1635 const sameOrigin = apiBase === location.origin || apiBase === location.origin + '/';
1636 if (isHosted && sameOrigin) {
1637 first.innerHTML =
1638 '<strong>Could not load OAuth status.</strong> The Hub at <code>' + escapeHtml(location.origin) +
1639 '</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.';
1640 } else if (isHosted && !sameOrigin) {
1641 first.innerHTML =
1642 '<strong>Could not reach the gateway.</strong> Sign-in with Google or GitHub will appear once the gateway at <code>' + escapeHtml(apiBase) +
1643 '</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.';
1644 } else {
1645 first.innerHTML =
1646 '<strong>Could not load OAuth status.</strong> Is the Hub running at <code>' +
1647 escapeHtml(apiBase) +
1648 '</code>? Open this page from the same machine as <code>npm run hub</code> (e.g. <code>http://localhost:3333/</code>).';
1649 }
1650 }
1651 return;
1652 }
1653 }
1654
1655 if (!providers.google && !providers.github) {
1656 oauthNotConfigured.classList.remove('hidden');
1657 if (loginIntro) loginIntro.classList.add('hidden');
1658 } else {
1659 oauthNotConfigured.classList.add('hidden');
1660 if (loginIntro) loginIntro.classList.remove('hidden');
1661 // Do not show header OAuth buttons when already signed in; initProviders runs async after showMain().
1662 const loggedIn =
1663 Boolean(token) ||
1664 (typeof localStorage !== 'undefined' && Boolean(localStorage.getItem('hub_token')));
1665 if (!loggedIn) {
1666 if (providers.google) btnLoginGoogle.classList.remove('hidden');
1667 if (providers.github) btnLoginGithub.classList.remove('hidden');
1668 }
1669 }
1670 }
1671
1672 if (token) {
1673 if (params.get('invite')) {
1674 (async () => {
1675 const inviteToken = params.get('invite');
1676 let lastErr;
1677 for (let attempt = 0; attempt < 3; attempt++) {
1678 try {
1679 await api('/api/v1/invites/consume', { method: 'POST', body: JSON.stringify({ token: inviteToken }) });
1680 const u = new URL(location.href);
1681 u.searchParams.delete('invite');
1682 u.searchParams.set('invite_accepted', '1');
1683 history.replaceState({}, '', u.toString());
1684 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1685 return;
1686 } catch (e) {
1687 lastErr = e;
1688 const code = e && e.code;
1689 const msg = String(e && e.message ? e.message : e || '');
1690 const staleInvite =
1691 code === 'NOT_FOUND' ||
1692 code === 'EXPIRED' ||
1693 /not found|already used|expired/i.test(msg);
1694 if (staleInvite) {
1695 const u = new URL(location.href);
1696 u.searchParams.delete('invite');
1697 history.replaceState({}, '', u.toString());
1698 if (code === 'EXPIRED' && typeof showToast === 'function') {
1699 showToast('This invite link has expired. Ask an admin for a new one if you need access.', true);
1700 }
1701 return;
1702 }
1703 if (attempt < 2) await new Promise((r) => setTimeout(r, 800));
1704 }
1705 }
1706 if (typeof showToast === 'function') showToast(lastErr?.message || 'Invite could not be applied.', true);
1707 })();
1708 }
1709 showMain();
1710 getImageProxyToken().catch(function () {});
1711 (async function ensureVaultAndSwitcherThenLoad() {
1712 let settingsPayload = null;
1713 try {
1714 settingsPayload = await api('/api/v1/settings');
1715 applySettingsPayloadToHubChrome(settingsPayload);
1716 } catch (_) {}
1717 syncHubListSortUI('notes');
1718 syncModeToolbars('notes');
1719 refreshNewProposalTabVisibility();
1720 loadFacets();
1721 loadNotes();
1722 loadProposals();
1723 loadActivity();
1724 renderPresets();
1725 if (settingsPayload) void scheduleMaybeShowOnboardingWizard(settingsPayload);
1726 })();
1727 initProviders();
1728 if (params.get('open') === 'billing') {
1729 const checkoutSuccess = params.get('checkout') === 'success';
1730 // Clean up params before opening so back-button doesn't re-trigger.
1731 const u = new URL(location.href);
1732 u.searchParams.delete('open');
1733 u.searchParams.delete('checkout');
1734 history.replaceState({}, '', u.toString());
1735 // Small delay so the main Hub has rendered before the modal opens.
1736 setTimeout(() => {
1737 openSettingsBillingTab();
1738 if (checkoutSuccess && typeof showToast === 'function') {
1739 showToast('Subscription activated — welcome to your new plan!');
1740 }
1741 }, 400);
1742 }
1743 if (params.get('github_connected') === '1') {
1744 sessionStorage.setItem('knowtation_github_connect_pending', String(Date.now()));
1745 setTimeout(() => {
1746 if (typeof showToast === 'function') showToast('GitHub connected. Push will use the stored token.');
1747 const u = new URL(location.href);
1748 u.searchParams.delete('github_connected');
1749 history.replaceState({}, '', u.toString());
1750 }, 500);
1751 } else if (params.get('github_connect_error')) {
1752 setTimeout(() => {
1753 const code = params.get('github_connect_error');
1754 const msg =
1755 code === 'blob_storage'
1756 ? 'GitHub connect: could not save your token to storage. Check bridge Netlify logs or try again in a moment.'
1757 : 'GitHub connect: ' + code;
1758 if (typeof showToast === 'function') showToast(msg, true);
1759 const u = new URL(location.href);
1760 u.searchParams.delete('github_connect_error');
1761 history.replaceState({}, '', u.toString());
1762 }, 500);
1763 }
1764 } else {
1765 if (app) app.classList.add('login-screen');
1766 main.classList.add('hidden');
1767 loginRequired.classList.remove('hidden');
1768 btnNewNote.classList.add('hidden');
1769 if (btnImport) btnImport.classList.add('hidden');
1770 const inviteBanner = el('login-invite-banner');
1771 if (inviteBanner && params.get('invite')) {
1772 inviteBanner.textContent = "You've been invited. Sign in to join.";
1773 inviteBanner.classList.remove('hidden');
1774 }
1775 initProviders();
1776 }
1777 refreshApiBaseFootgunBanner();
1778 if (token && (params.get('invite_accepted') === '1' || hashParams.get('invite_accepted') === '1')) {
1779 setTimeout(() => {
1780 if (typeof showToast === 'function') showToast("You've been added. Your role is shown in Settings.");
1781 const u = new URL(location.href);
1782 u.searchParams.delete('invite_accepted');
1783 history.replaceState({}, '', u.pathname + u.search);
1784 }, 500);
1785 }
1786
1787 function dateSlice(d) {
1788 if (!d || typeof d !== 'string') return '';
1789 return d.trim().slice(0, 10);
1790 }
1791
1792 /** 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. */
1793 function materializeFrontmatter(fm) {
1794 if (fm == null) return {};
1795 if (typeof fm === 'object' && !Array.isArray(fm)) return fm;
1796 if (typeof fm === 'string') {
1797 let cur = fm.replace(/^\uFEFF/, '').trim();
1798 if (!cur) return {};
1799 for (let i = 0; i < 8; i++) {
1800 try {
1801 const o = JSON.parse(cur);
1802 if (o !== null && typeof o === 'object' && !Array.isArray(o)) return o;
1803 if (typeof o === 'string') {
1804 const next = o.trim();
1805 if (next === cur) return {};
1806 cur = next;
1807 continue;
1808 }
1809 return {};
1810 } catch {
1811 if (cur.length >= 2 && cur.charCodeAt(0) === 34) {
1812 try {
1813 const inner = JSON.parse(cur);
1814 if (typeof inner === 'string') {
1815 cur = inner.trim();
1816 continue;
1817 }
1818 } catch {
1819 /* fall through */
1820 }
1821 }
1822 return {};
1823 }
1824 }
1825 return {};
1826 }
1827 return {};
1828 }
1829
1830 function tagsFromFrontmatter(fm) {
1831 const raw = fm && fm.tags;
1832 if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1833 if (typeof raw === 'string' && raw.trim()) {
1834 return raw
1835 .split(/[,\n]/)
1836 .map((s) => s.trim())
1837 .filter(Boolean);
1838 }
1839 return [];
1840 }
1841
1842 /** Local calendar YYYY-MM-DD (user's browser timezone) from epoch ms. */
1843 function isoDateLocalFromMs(ms) {
1844 const d = new Date(ms);
1845 if (Number.isNaN(d.getTime())) return null;
1846 const y = d.getFullYear();
1847 const mo = String(d.getMonth() + 1).padStart(2, '0');
1848 const day = String(d.getDate()).padStart(2, '0');
1849 return y + '-' + mo + '-' + day;
1850 }
1851
1852 /**
1853 * Calendar bucket for Hub list/calendar/overview.
1854 * - Plain date `YYYY-MM-DD` (no time): use as-is (civil date from frontmatter).
1855 * - ISO datetimes: use the local calendar day so evening Pacific does not appear as "tomorrow" in UTC.
1856 */
1857 function calendarDisplayDayKey(raw) {
1858 if (raw == null) return null;
1859 const s = String(raw).trim();
1860 if (!s) return null;
1861 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
1862 const ms = Date.parse(s);
1863 if (Number.isNaN(ms)) return s.slice(0, 10);
1864 return isoDateLocalFromMs(ms);
1865 }
1866
1867 /** When frontmatter is empty, infer YYYY-MM-DD from `note-<epochMs>.md` quick-capture paths (hosted legacy rows). */
1868 function inferredDisplayDateFromNotePath(notePath) {
1869 if (!notePath || typeof notePath !== 'string') return null;
1870 const base = notePath.split('/').pop() || '';
1871 const m = /^note-(\d{10,})\.md$/i.exec(base);
1872 if (!m) return null;
1873 const ms = Number(m[1]);
1874 if (!Number.isFinite(ms)) return null;
1875 return isoDateLocalFromMs(ms);
1876 }
1877
1878 /** YYYY-MM-DD for calendar, overview, and range filters when `date` is unset (hosted notes often only have knowtation_edited_at). */
1879 function listItemDisplayDate(n, fm) {
1880 if (n.date != null && String(n.date).trim()) return calendarDisplayDayKey(n.date) || String(n.date).trim().slice(0, 10);
1881 if (fm.date != null && String(fm.date).trim()) return calendarDisplayDayKey(fm.date) || String(fm.date).trim().slice(0, 10);
1882 const ke = fm.knowtation_edited_at ?? n.knowtation_edited_at;
1883 if (ke != null && String(ke).trim()) return calendarDisplayDayKey(ke) || String(ke).trim().slice(0, 10);
1884 const inferred = inferredDisplayDateFromNotePath(n.path);
1885 return inferred || null;
1886 }
1887
1888 function noteSortOrCalendarDay(n) {
1889 const raw = n.date || n.updated || '';
1890 return calendarDisplayDayKey(raw) || dateSlice(raw);
1891 }
1892
1893 const HUB_SORT_STORAGE_NOTES = 'hub_list_sort_notes';
1894 const HUB_SORT_STORAGE_PROPOSALS = 'hub_list_sort_proposals';
1895 const HUB_SORT_NOTES_OPTS = [
1896 { v: 'date_desc', l: 'Newest first' },
1897 { v: 'date_asc', l: 'Oldest first' },
1898 { v: 'year_desc', l: 'Year (newest first)' },
1899 { v: 'year_asc', l: 'Year (oldest first)' },
1900 { v: 'path_asc', l: 'Path A–Z' },
1901 { v: 'title_asc', l: 'Title A–Z' },
1902 ];
1903 const HUB_SORT_PROP_OPTS = [
1904 { v: 'updated_desc', l: 'Newest first' },
1905 { v: 'updated_asc', l: 'Oldest first' },
1906 { v: 'path_asc', l: 'Path A–Z' },
1907 { v: 'status_asc', l: 'Status A–Z' },
1908 ];
1909
1910 function hubListSortGetSelect() {
1911 return el('hub-list-sort');
1912 }
1913
1914 function syncHubListSortUI(activeTab) {
1915 const sel = hubListSortGetSelect();
1916 if (!sel) return;
1917 const isNotes = activeTab === 'notes';
1918 const opts = isNotes ? HUB_SORT_NOTES_OPTS : HUB_SORT_PROP_OPTS;
1919 const key = isNotes ? HUB_SORT_STORAGE_NOTES : HUB_SORT_STORAGE_PROPOSALS;
1920 let saved = '';
1921 try {
1922 saved = localStorage.getItem(key) || '';
1923 } catch (_) {}
1924 sel.innerHTML = opts.map((o) => '<option value="' + o.v + '">' + o.l + '</option>').join('');
1925 if (!saved || !opts.some((o) => o.v === saved)) saved = opts[0].v;
1926 sel.value = saved;
1927 }
1928
1929 function setProposalFiltersBarVisible(show) {
1930 const bar = el('proposal-filters-bar');
1931 if (bar) bar.classList.toggle('hidden', !show);
1932 }
1933
1934 function refreshNewProposalTabVisibility() {
1935 const btn = el('btn-new-proposal');
1936 if (!btn) return;
1937 const tab = getActiveHubMainTab();
1938 const show = tab === 'suggested' && hubUserCanWriteNotes();
1939 btn.classList.toggle('hidden', !show);
1940 }
1941
1942 function applySortedNotesClient(notes) {
1943 const tab = getActiveHubMainTab();
1944 if (tab !== 'notes') return notes;
1945 const S = globalThis.HubListSort;
1946 const sel = hubListSortGetSelect();
1947 const mode = sel && sel.value ? sel.value : 'date_desc';
1948 if (!S || typeof S.sortNotesList !== 'function') return notes;
1949 return S.sortNotesList(notes, mode, noteSortOrCalendarDay);
1950 }
1951
1952 function applySortedProposalsClient(list) {
1953 const S = globalThis.HubListSort;
1954 const sel = hubListSortGetSelect();
1955 const mode = sel && sel.value ? sel.value : 'updated_desc';
1956 if (!S || typeof S.sortProposalsList !== 'function') return list;
1957 return S.sortProposalsList(list, mode);
1958 }
1959
1960 function normalizeHubListItem(n) {
1961 if (!n || typeof n !== 'object') return n;
1962 const fm = materializeFrontmatter(n.frontmatter);
1963 const tags = Array.isArray(n.tags) && n.tags.length ? n.tags.map(String) : tagsFromFrontmatter(fm);
1964 const displayDate = listItemDisplayDate(n, fm);
1965 const updated =
1966 n.updated != null
1967 ? String(n.updated)
1968 : fm.knowtation_edited_at != null
1969 ? String(fm.knowtation_edited_at)
1970 : null;
1971 return {
1972 ...n,
1973 frontmatter: fm,
1974 title: n.title != null ? n.title : fm.title != null ? String(fm.title) : null,
1975 project: n.project != null ? n.project : fm.project != null ? String(fm.project) : null,
1976 tags,
1977 date: displayDate,
1978 updated,
1979 };
1980 }
1981
1982 function facetsAreEmpty(f) {
1983 if (!f || typeof f !== 'object') return true;
1984 const pl = f.projects && f.projects.length;
1985 const tl = f.tags && f.tags.length;
1986 const fl = f.folders && f.folders.length;
1987 return !pl && !tl && !fl;
1988 }
1989
1990 async function deriveFacetsFromNotes() {
1991 const out = await api('/api/v1/notes?limit=500&offset=0');
1992 const projects = new Set();
1993 const tags = new Set();
1994 const folders = new Set();
1995 for (const raw of out.notes || []) {
1996 const n = normalizeHubListItem(raw);
1997 if (n.path) {
1998 const seg = String(n.path).split('/')[0];
1999 if (seg) folders.add(seg);
2000 }
2001 if (n.project) projects.add(String(n.project));
2002 (n.tags || []).forEach((t) => tags.add(String(t)));
2003 }
2004 return {
2005 projects: [...projects].sort((a, b) => a.localeCompare(b)),
2006 tags: [...tags].sort((a, b) => a.localeCompare(b)),
2007 folders: [...folders].sort((a, b) => a.localeCompare(b)),
2008 };
2009 }
2010
2011 async function fetchFacetsResolved() {
2012 let facets = await api('/api/v1/notes/facets');
2013 if (facetsAreEmpty(facets)) facets = await deriveFacetsFromNotes();
2014 return facets;
2015 }
2016
2017 function hubRowIsApprovalLog(n) {
2018 if (!n || !n.path) return false;
2019 const path = String(n.path).replace(/\\/g, '/');
2020 if (path === 'approvals' || path.startsWith('approvals/')) return true;
2021 const k =
2022 n.frontmatter && n.frontmatter.kind != null ? n.frontmatter.kind : n.kind != null ? n.kind : null;
2023 return String(k) === 'approval_log';
2024 }
2025
2026 /** Hosted canister ignores list query filters; mirror lib/list-notes.mjs on the client after normalizeHubListItem. */
2027 function applyVaultListFilters(notes, opts) {
2028 let out = notes.slice();
2029 if (opts.folder) {
2030 const f = String(opts.folder).replace(/\\/g, '/').replace(/\/$/, '') || String(opts.folder);
2031 const prefix = f + '/';
2032 out = out.filter((n) => n.path === f || (n.path && String(n.path).startsWith(prefix)));
2033 }
2034 if (opts.project) {
2035 const p = normSlug(opts.project);
2036 out = out.filter(
2037 (n) =>
2038 normSlug(String(n.project || '')) === p || normSlug(String(n.frontmatter?.project || '')) === p,
2039 );
2040 }
2041 if (opts.tag) {
2042 const t = normSlug(opts.tag);
2043 out = out.filter((n) => (n.tags || []).some((x) => normSlug(String(x)) === t));
2044 }
2045 if (opts.since) {
2046 const s = dateSlice(opts.since);
2047 if (s) out = out.filter((n) => noteSortOrCalendarDay(n) >= s);
2048 }
2049 if (opts.until) {
2050 const u = dateSlice(opts.until);
2051 if (u) out = out.filter((n) => noteSortOrCalendarDay(n) <= u);
2052 }
2053 const cs = opts.content_scope;
2054 if (cs === 'notes') {
2055 out = out.filter((n) => !hubRowIsApprovalLog(n));
2056 } else if (cs === 'approval_logs') {
2057 out = out.filter((n) => hubRowIsApprovalLog(n));
2058 }
2059 if (opts.content_class) {
2060 const cc = String(opts.content_class).trim().toLowerCase();
2061 out = out.filter((n) => {
2062 const v = n.content_class ?? n.frontmatter?.content_class;
2063 return v != null && String(v).trim().toLowerCase() === cc;
2064 });
2065 }
2066 // Phase 12 — blockchain filters (client-side safety net; gateway also filters on hosted)
2067 if (opts.network) {
2068 const net = String(opts.network).trim().toLowerCase();
2069 out = out.filter((n) => {
2070 const v = n.frontmatter?.network ?? n.network;
2071 return v != null && String(v).trim().toLowerCase() === net;
2072 });
2073 }
2074 if (opts.wallet_address) {
2075 const wa = String(opts.wallet_address).trim().toLowerCase();
2076 out = out.filter((n) => {
2077 const v = n.frontmatter?.wallet_address ?? n.wallet_address;
2078 return v != null && String(v).trim().toLowerCase() === wa;
2079 });
2080 }
2081 if (opts.payment_status) {
2082 const ps = String(opts.payment_status).trim().toLowerCase();
2083 out = out.filter((n) => {
2084 const v = n.frontmatter?.payment_status ?? n.payment_status;
2085 return v != null && String(v).trim().toLowerCase() === ps;
2086 });
2087 }
2088 return out;
2089 }
2090
2091 /** Match lib/hub-provenance.mjs — strip before merge; server re-applies provenance on write. */
2092 const HUB_RESERVED_FM_KEYS = new Set([
2093 'knowtation_editor',
2094 'knowtation_edited_at',
2095 'author_kind',
2096 'knowtation_proposed_by',
2097 'knowtation_approved_by',
2098 ]);
2099
2100 function stripReservedHubFm(fm) {
2101 const out = {};
2102 if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return out;
2103 for (const [k, v] of Object.entries(fm)) {
2104 if (HUB_RESERVED_FM_KEYS.has(k)) continue;
2105 out[k] = v;
2106 }
2107 return out;
2108 }
2109
2110 /**
2111 * ICP canister extractJsonString only saw `"frontmatter":"..."`; object-shaped frontmatter stored as `{}`.
2112 * Nesting frontmatter as a JSON string in the outer payload is always safe; gateway still merges provenance.
2113 */
2114 function stringifyNotePostPayload(path, body, frontmatter) {
2115 const fmStr =
2116 typeof frontmatter === 'string'
2117 ? frontmatter
2118 : JSON.stringify(frontmatter && typeof frontmatter === 'object' && !Array.isArray(frontmatter) ? frontmatter : {});
2119 return JSON.stringify({ path, body, frontmatter: fmStr });
2120 }
2121
2122 const DETAIL_EDIT_FM_KEYS = [
2123 'title',
2124 'date',
2125 'project',
2126 'tags',
2127 'causal_chain_id',
2128 'entity',
2129 'episode_id',
2130 'follows',
2131 ];
2132
2133 function mergedFrontmatterForDetailSave() {
2134 const base = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
2135 const preserved = {};
2136 for (const [k, v] of Object.entries(base)) {
2137 if (!DETAIL_EDIT_FM_KEYS.includes(k)) preserved[k] = v;
2138 }
2139 const dateVal =
2140 el('detail-edit-date') && el('detail-edit-date').value ? el('detail-edit-date').value.trim() : ymd(new Date());
2141 const title = (el('detail-edit-title') && el('detail-edit-title').value) || '';
2142 const tTitle = title.trim();
2143 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2144 const project = pathProj || ((el('detail-edit-project') && el('detail-edit-project').value) || '').trim();
2145 const tags = ((el('detail-edit-tags') && el('detail-edit-tags').value) || '').trim();
2146 const causalChain = el('detail-edit-causal-chain') && el('detail-edit-causal-chain').value.trim();
2147 const entityRaw = el('detail-edit-entity') && el('detail-edit-entity').value.trim();
2148 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
2149 const episode = el('detail-edit-episode') && el('detail-edit-episode').value.trim();
2150 const followsRaw = el('detail-edit-follows') && el('detail-edit-follows').value.trim();
2151 const follows = followsRaw
2152 ? followsRaw.includes(',')
2153 ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean)
2154 : followsRaw
2155 : undefined;
2156 const out = { ...preserved, date: dateVal };
2157 if (tTitle) out.title = tTitle;
2158 else delete out.title;
2159 if (project) out.project = project;
2160 else delete out.project;
2161 if (tags) out.tags = tags;
2162 else delete out.tags;
2163 if (causalChain) out.causal_chain_id = causalChain;
2164 else delete out.causal_chain_id;
2165 if (entity.length) out.entity = entity;
2166 else delete out.entity;
2167 if (episode) out.episode_id = episode;
2168 else delete out.episode_id;
2169 if (follows) out.follows = follows;
2170 else delete out.follows;
2171 return out;
2172 }
2173
2174 function fillDetailEditFieldsFromFrontmatter(fm) {
2175 const f = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
2176 const pathProj = currentOpenNote && projectSlugFromProjectsPath(currentOpenNote.path);
2177 const savedProj = f.project != null ? String(f.project).trim() : '';
2178 if (el('detail-edit-title')) el('detail-edit-title').value = f.title != null ? String(f.title) : '';
2179 if (el('detail-edit-body')) el('detail-edit-body').value = currentOpenNote.body || '';
2180 if (el('detail-edit-date')) el('detail-edit-date').value = f.date != null ? String(f.date).slice(0, 10) : '';
2181 if (el('detail-edit-project')) {
2182 const inp = el('detail-edit-project');
2183 if (pathProj) {
2184 inp.value = pathProj;
2185 inp.readOnly = true;
2186 inp.title = 'Project is taken from the vault path projects/' + pathProj + '/';
2187 } else {
2188 inp.readOnly = false;
2189 inp.title = '';
2190 inp.value = savedProj;
2191 }
2192 }
2193 const hint = el('detail-edit-project-hint');
2194 if (hint) {
2195 if (pathProj) {
2196 hint.classList.remove('hidden');
2197 const mismatch = savedProj && normSlug(savedProj) !== normSlug(pathProj);
2198 hint.textContent = mismatch
2199 ? 'Path implies project «' +
2200 pathProj +
2201 '»; saved frontmatter had «' +
2202 savedProj +
2203 '». Saving will store «' +
2204 pathProj +
2205 '» to match the path.'
2206 : 'Project slug matches vault path projects/' + pathProj + '/.';
2207 hint.className = mismatch ? 'muted small detail-project-hint warn' : 'muted small detail-project-hint';
2208 } else {
2209 hint.classList.remove('hidden');
2210 hint.className = 'muted small detail-project-hint';
2211 hint.textContent =
2212 '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.';
2213 }
2214 }
2215 const pathTypoEl = el('detail-edit-path-typo-hint');
2216 if (pathTypoEl && currentOpenNote) {
2217 const sug = projectsPathTypoSuggestion(currentOpenNote.path);
2218 if (sug) {
2219 pathTypoEl.textContent =
2220 'This path starts with project/ — the usual convention is projects/ (with an “s”). Example fix: ' +
2221 sug +
2222 '. Rename or move the file in your vault (path cannot be edited here).';
2223 pathTypoEl.className = 'muted small detail-project-hint warn';
2224 pathTypoEl.classList.remove('hidden');
2225 } else {
2226 pathTypoEl.textContent = '';
2227 pathTypoEl.className = 'muted small detail-project-hint hidden';
2228 pathTypoEl.classList.add('hidden');
2229 }
2230 }
2231 const tags = f.tags;
2232 const tagsStr = Array.isArray(tags) ? tags.join(', ') : tags != null ? String(tags) : '';
2233 if (el('detail-edit-tags')) el('detail-edit-tags').value = tagsStr;
2234 if (el('detail-edit-causal-chain')) el('detail-edit-causal-chain').value = f.causal_chain_id != null ? String(f.causal_chain_id) : '';
2235 const ent = f.entity;
2236 const entStr = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
2237 if (el('detail-edit-entity')) el('detail-edit-entity').value = entStr;
2238 if (el('detail-edit-episode')) el('detail-edit-episode').value = f.episode_id != null ? String(f.episode_id) : '';
2239 const fol = f.follows;
2240 const folStr = Array.isArray(fol) ? fol.join(', ') : fol != null ? String(fol) : '';
2241 if (el('detail-edit-follows')) el('detail-edit-follows').value = folStr;
2242 }
2243
2244 async function loadFacets() {
2245 try {
2246 const savedProject = filterProject.value;
2247 const savedTag = filterTag.value;
2248 const savedFolder = filterFolder.value;
2249 const savedNetwork = filterNetwork ? filterNetwork.value : '';
2250 const savedWallet = filterWallet ? filterWallet.value : '';
2251 const facets = await fetchFacetsResolved();
2252 lastHubFacets = facets;
2253 filterProject.innerHTML = '<option value="">All projects</option>' + (facets.projects || []).map((p) => '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>').join('');
2254 filterTag.innerHTML = '<option value="">All tags</option>' + (facets.tags || []).map((t) => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
2255 filterFolder.innerHTML = '<option value="">All folders</option>' + (facets.folders || []).map((f) => '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>').join('');
2256 if (facets.projects?.includes(savedProject)) filterProject.value = savedProject;
2257 if (facets.tags?.includes(savedTag)) filterTag.value = savedTag;
2258 if (facets.folders?.includes(savedFolder)) filterFolder.value = savedFolder;
2259 // Phase 12 — blockchain filter dropdowns (hidden when no data)
2260 if (filterNetwork) {
2261 const nets = facets.networks || [];
2262 filterNetwork.innerHTML = '<option value="">All networks</option>' + nets.map((n) => '<option value="' + escapeHtml(n) + '">' + escapeHtml(n) + '</option>').join('');
2263 filterNetwork.classList.toggle('hidden', nets.length === 0);
2264 if (nets.includes(savedNetwork)) filterNetwork.value = savedNetwork;
2265 }
2266 if (filterWallet) {
2267 const wallets = facets.wallets || [];
2268 filterWallet.innerHTML = '<option value="">All wallets</option>' + wallets.map((w) => '<option value="' + escapeHtml(w) + '">' + escapeHtml(w) + '</option>').join('');
2269 filterWallet.classList.toggle('hidden', wallets.length === 0);
2270 if (wallets.includes(savedWallet)) filterWallet.value = savedWallet;
2271 }
2272 renderFilterChips(facets);
2273 hydrateFullCreateProjectSlugSelect(facets);
2274 hydrateImportCreateProjectSlugSelect(facets);
2275 } catch (_) {
2276 renderFilterChips(null);
2277 lastHubFacets = null;
2278 hydrateFullCreateProjectSlugSelect(null);
2279 hydrateImportCreateProjectSlugSelect(null);
2280 }
2281 }
2282
2283 function normSlug(s) {
2284 return String(s || '')
2285 .toLowerCase()
2286 .replace(/[^a-z0-9-]/g, '-')
2287 .replace(/-+/g, '-')
2288 .replace(/^-|-$/g, '');
2289 }
2290
2291 /**
2292 * First path segment after `projects/` (vault-relative). Used so project frontmatter
2293 * stays aligned with on-disk layout (projects/<slug>/…).
2294 */
2295 function projectSlugFromProjectsPath(path) {
2296 if (!path || typeof path !== 'string') return null;
2297 const m = path.match(/^projects\/([^/]+)(?:\/|$)/);
2298 return m ? m[1] : null;
2299 }
2300
2301 /**
2302 * Common typo: vault path starts with `project/` instead of `projects/`.
2303 * Returns the same path with the corrected prefix, or null if no typo.
2304 */
2305 function projectsPathTypoSuggestion(path) {
2306 const p = String(path || '').trim();
2307 if (!p) return null;
2308 if (/^project\//.test(p) && !/^projects\//.test(p)) return p.replace(/^project\//, 'projects/');
2309 return null;
2310 }
2311
2312 function normalizeProjectKeyForSimilarity(s) {
2313 return String(s || '')
2314 .toLowerCase()
2315 .trim()
2316 .replace(/[\s_]+/g, '-')
2317 .replace(/-+/g, '-')
2318 .replace(/^-|-$/g, '');
2319 }
2320
2321 function levenshteinHub(a, b) {
2322 const m = a.length;
2323 const n = b.length;
2324 if (!m) return n;
2325 if (!n) return m;
2326 const row = new Array(n + 1);
2327 for (let j = 0; j <= n; j++) row[j] = j;
2328 for (let i = 1; i <= m; i++) {
2329 let prev = row[0];
2330 row[0] = i;
2331 for (let j = 1; j <= n; j++) {
2332 const cur = row[j];
2333 const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
2334 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost);
2335 prev = cur;
2336 }
2337 }
2338 return row[n];
2339 }
2340
2341 /**
2342 * If path uses `projects/<slug>/` where <slug> is close-but-not-equal to a facet project, return that facet string.
2343 * Exact normSlug match returns null (no warning).
2344 */
2345 function findSimilarFacetProject(userSlug, projectsArr) {
2346 if (!userSlug || !projectsArr || !projectsArr.length) return null;
2347 const uNorm = normSlug(String(userSlug));
2348 if (!uNorm) return null;
2349 for (const p of projectsArr) {
2350 if (normSlug(String(p)) === uNorm) return null;
2351 }
2352 const uCompact = normalizeProjectKeyForSimilarity(userSlug).replace(/-/g, '');
2353 let best = null;
2354 let bestScore = Infinity;
2355 for (const p of projectsArr) {
2356 const pv = String(p).trim();
2357 if (!pv) continue;
2358 const pNorm = normSlug(pv);
2359 if (!pNorm) continue;
2360 const pCompact = normalizeProjectKeyForSimilarity(pv).replace(/-/g, '');
2361 let score = Infinity;
2362 if (uCompact.length >= 3 && pCompact.length >= 3 && uCompact === pCompact) score = 0;
2363 if (score > 0) {
2364 const a = normalizeProjectKeyForSimilarity(userSlug);
2365 const b = normalizeProjectKeyForSimilarity(pv);
2366 const d = levenshteinHub(a, b);
2367 if (d <= 2 && Math.abs(a.length - b.length) <= 3) score = Math.min(score, d + 0.1);
2368 }
2369 if (score > 0) {
2370 const a = normalizeProjectKeyForSimilarity(userSlug);
2371 const b = normalizeProjectKeyForSimilarity(pv);
2372 const shorter = a.length <= b.length ? a : b;
2373 const longer = a.length <= b.length ? b : a;
2374 if (shorter.length >= 3 && longer.startsWith(shorter) && longer.length - shorter.length <= 2) {
2375 score = Math.min(score, longer.length - shorter.length + 0.5);
2376 }
2377 }
2378 if (score < bestScore) {
2379 bestScore = score;
2380 best = pv;
2381 }
2382 }
2383 return bestScore < 10 ? best : null;
2384 }
2385
2386 function collectProjectSubroots(slug, folderStrings) {
2387 const prefix = 'projects/' + slug.replace(/^\/+|\/+$/g, '') + '/';
2388 const subs = new Set();
2389 for (const f of folderStrings || []) {
2390 if (!f || typeof f !== 'string') continue;
2391 const n = f.replace(/\\/g, '/').replace(/\/+$/, '');
2392 if (!n.startsWith(prefix)) continue;
2393 const rest = n.slice(prefix.length);
2394 if (!rest) continue;
2395 const first = rest.split('/')[0];
2396 if (first) subs.add(first);
2397 }
2398 return [...subs].sort((a, b) => a.localeCompare(b));
2399 }
2400
2401 function fullCreatePathFilename(pathVal) {
2402 const t = String(pathVal || '').trim();
2403 const parts = t.split('/').filter(Boolean);
2404 const last = parts[parts.length - 1];
2405 if (last && /\.md$/i.test(last)) return last;
2406 return 'note-' + Date.now() + '.md';
2407 }
2408
2409 function mergeFolderStringsForSubroots() {
2410 const out = new Set();
2411 for (const f of lastVaultFoldersForCreate || []) {
2412 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2413 }
2414 for (const f of (lastHubFacets && lastHubFacets.folders) || []) {
2415 if (f && typeof f === 'string') out.add(f.replace(/\\/g, '/').replace(/\/+$/, ''));
2416 }
2417 return [...out];
2418 }
2419
2420 function updateFullCreatePathLayoutVisibility() {
2421 const slugSel = el('full-create-project-slug');
2422 const subWrap = el('full-create-project-subroot-wrap');
2423 const nonProj = el('full-create-nonproject-folder-wrap');
2424 const subSel = el('full-create-project-subroot');
2425 if (!slugSel) return;
2426 const v = slugSel.value;
2427 const useProject = v && v !== '__custom__';
2428 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2429 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2430 if (subSel) subSel.disabled = !useProject;
2431 }
2432
2433 function refreshFullCreateSubrootSelect() {
2434 const slugSel = el('full-create-project-slug');
2435 const subSel = el('full-create-project-subroot');
2436 if (!slugSel || !subSel) return;
2437 const slug = slugSel.value;
2438 const preserve = subSel.value;
2439 if (!slug || slug === '__custom__') {
2440 subSel.innerHTML = '';
2441 subSel.disabled = true;
2442 return;
2443 }
2444 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2445 const head = document.createElement('option');
2446 head.value = '';
2447 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2448 subSel.innerHTML = '';
2449 subSel.appendChild(head);
2450 for (const s of subs) {
2451 const o = document.createElement('option');
2452 o.value = s;
2453 o.textContent = s;
2454 subSel.appendChild(o);
2455 }
2456 const custom = document.createElement('option');
2457 custom.value = '__custom_sub__';
2458 custom.textContent = 'Custom (edit path)';
2459 subSel.appendChild(custom);
2460 subSel.disabled = false;
2461 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2462 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2463 else if (subs.includes('inbox')) subSel.value = 'inbox';
2464 else if (subs.length === 1) subSel.value = subs[0];
2465 else subSel.value = '';
2466 }
2467
2468 function composeFullPathFromCreatePickers() {
2469 const slugSel = el('full-create-project-slug');
2470 const subSel = el('full-create-project-subroot');
2471 const pathInp = el('full-path');
2472 if (!slugSel || !pathInp) return;
2473 const slugVal = slugSel.value;
2474 if (!slugVal || slugVal === '__custom__') return;
2475 if (subSel && subSel.value === '__custom_sub__') return;
2476 const sub =
2477 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2478 const fname = fullCreatePathFilename(pathInp.value);
2479 const base = sub ? 'projects/' + slugVal + '/' + sub + '/' + fname : 'projects/' + slugVal + '/' + fname;
2480 pathInp.value = base;
2481 }
2482
2483 function syncFullCreatePickersFromPath() {
2484 const slugSel = el('full-create-project-slug');
2485 const subSel = el('full-create-project-subroot');
2486 const pathInp = el('full-path');
2487 if (!slugSel || !pathInp) return;
2488 const raw = pathInp.value.trim();
2489 const m = raw.match(/^projects\/([^/]+)\/([\s\S]*)$/);
2490 if (!m) {
2491 slugSel.value = raw ? '__custom__' : '';
2492 refreshFullCreateSubrootSelect();
2493 updateFullCreatePathLayoutVisibility();
2494 return;
2495 }
2496 const diskSlug = m[1];
2497 const rest = m[2];
2498 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2499 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2500 if (match) slugSel.value = match;
2501 else slugSel.value = '__custom__';
2502 refreshFullCreateSubrootSelect();
2503 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2504 const segments = rest.split('/').filter(Boolean);
2505 const lastSeg = segments[segments.length - 1];
2506 const hasFile = lastSeg && /\.md$/i.test(lastSeg);
2507 const dirParts = hasFile ? segments.slice(0, -1) : segments.slice();
2508 const firstDir = dirParts[0] || '';
2509 const allowed = new Set(
2510 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2511 );
2512 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2513 else if (firstDir) subSel.value = '__custom_sub__';
2514 else subSel.value = '';
2515 }
2516 updateFullCreatePathLayoutVisibility();
2517 }
2518
2519 function hydrateFullCreateProjectSlugSelect(facets) {
2520 const sel = el('full-create-project-slug');
2521 if (!sel) return;
2522 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2523 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2524 const preserve = sel.value;
2525 sel.innerHTML =
2526 '<option value="">— Not under projects/ —</option>' +
2527 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2528 '<option value="__custom__">Custom (type full path)</option>';
2529 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2530 refreshFullCreateSubrootSelect();
2531 updateFullCreatePathLayoutVisibility();
2532 }
2533
2534 function updateImportPathLayoutVisibility() {
2535 const slugSel = el('import-create-project-slug');
2536 const subWrap = el('import-create-project-subroot-wrap');
2537 const nonProj = el('import-nonproject-folder-wrap');
2538 const subSel = el('import-create-project-subroot');
2539 if (!slugSel) return;
2540 const v = slugSel.value;
2541 const useProject = v && v !== '__custom__';
2542 if (subWrap) subWrap.classList.toggle('hidden', !useProject);
2543 if (nonProj) nonProj.classList.toggle('hidden', useProject);
2544 if (subSel) subSel.disabled = !useProject;
2545 }
2546
2547 function refreshImportCreateSubrootSelect() {
2548 const slugSel = el('import-create-project-slug');
2549 const subSel = el('import-create-project-subroot');
2550 if (!slugSel || !subSel) return;
2551 const slug = slugSel.value;
2552 const preserve = subSel.value;
2553 if (!slug || slug === '__custom__') {
2554 subSel.innerHTML = '';
2555 subSel.disabled = true;
2556 return;
2557 }
2558 const subs = collectProjectSubroots(slug, mergeFolderStringsForSubroots());
2559 const head = document.createElement('option');
2560 head.value = '';
2561 head.textContent = subs.length ? '— Project root (no extra folder) —' : '— Type path or add folders —';
2562 subSel.innerHTML = '';
2563 subSel.appendChild(head);
2564 for (const s of subs) {
2565 const o = document.createElement('option');
2566 o.value = s;
2567 o.textContent = s;
2568 subSel.appendChild(o);
2569 }
2570 const custom = document.createElement('option');
2571 custom.value = '__custom_sub__';
2572 custom.textContent = 'Custom (edit path)';
2573 subSel.appendChild(custom);
2574 subSel.disabled = false;
2575 if (preserve === '__custom_sub__') subSel.value = '__custom_sub__';
2576 else if (preserve && subs.includes(preserve)) subSel.value = preserve;
2577 else if (subs.includes('inbox')) subSel.value = 'inbox';
2578 else if (subs.length === 1) subSel.value = subs[0];
2579 else subSel.value = '';
2580 }
2581
2582 function composeImportOutputDirFromPickers() {
2583 const slugSel = el('import-create-project-slug');
2584 const subSel = el('import-create-project-subroot');
2585 const outInp = el('import-output-dir');
2586 if (!slugSel || !outInp) return;
2587 const slugVal = slugSel.value;
2588 if (!slugVal || slugVal === '__custom__') return;
2589 if (subSel && subSel.value === '__custom_sub__') return;
2590 const sub =
2591 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2592 const subUse = sub || 'inbox';
2593 outInp.value = 'projects/' + slugVal + '/' + subUse;
2594 }
2595
2596 function syncImportPickersFromOutputDir() {
2597 const slugSel = el('import-create-project-slug');
2598 const subSel = el('import-create-project-subroot');
2599 const outInp = el('import-output-dir');
2600 if (!slugSel || !outInp) return;
2601 const raw = outInp.value.trim().replace(/\/+$/, '');
2602 const m = raw.match(/^projects\/([^/]+)(?:\/(.*))?$/);
2603 if (!m) {
2604 slugSel.value = raw ? '__custom__' : '';
2605 refreshImportCreateSubrootSelect();
2606 updateImportPathLayoutVisibility();
2607 return;
2608 }
2609 const diskSlug = m[1];
2610 const rest = m[2] || '';
2611 const projects = (lastHubFacets && lastHubFacets.projects) || [];
2612 const match = projects.find((p) => normSlug(String(p)) === normSlug(diskSlug));
2613 if (match) slugSel.value = match;
2614 else slugSel.value = '__custom__';
2615 refreshImportCreateSubrootSelect();
2616 if (slugSel.value && slugSel.value !== '__custom__' && subSel) {
2617 const segments = rest.split('/').filter(Boolean);
2618 const firstDir = segments[0] || '';
2619 const allowed = new Set(
2620 [...subSel.options].map((o) => o.value).filter((v) => v && v !== '__custom_sub__'),
2621 );
2622 if (firstDir && allowed.has(firstDir)) subSel.value = firstDir;
2623 else if (firstDir) subSel.value = '__custom_sub__';
2624 else subSel.value = '';
2625 }
2626 updateImportPathLayoutVisibility();
2627 }
2628
2629 function hydrateImportCreateProjectSlugSelect(facets) {
2630 const sel = el('import-create-project-slug');
2631 if (!sel) return;
2632 const f = facets && typeof facets === 'object' ? facets : lastHubFacets;
2633 const projects = f && Array.isArray(f.projects) ? [...f.projects].filter((p) => p != null && String(p).trim()) : [];
2634 const preserve = sel.value;
2635 sel.innerHTML =
2636 '<option value="">— Not under projects/ —</option>' +
2637 projects.map((p) => '<option value="' + escapeHtml(String(p)) + '">' + escapeHtml(String(p)) + '</option>').join('') +
2638 '<option value="__custom__">Custom (type full path)</option>';
2639 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
2640 refreshImportCreateSubrootSelect();
2641 updateImportPathLayoutVisibility();
2642 }
2643
2644 function syncImportFolderSelectToOutputDir() {
2645 const outInp = el('import-output-dir');
2646 const sel = el('import-vault-folder');
2647 if (!outInp || !sel) return;
2648 const p = outInp.value.trim().replace(/\/+$/, '');
2649 if (!p) return;
2650 let best = '__custom__';
2651 let bestLen = -1;
2652 for (const opt of sel.options) {
2653 const v = opt.value;
2654 if (v === '__custom__') continue;
2655 if (p === v || p.startsWith(v + '/')) {
2656 if (v.length > bestLen) {
2657 best = v;
2658 bestLen = v.length;
2659 }
2660 }
2661 }
2662 sel.value = bestLen >= 0 ? best : '__custom__';
2663 }
2664
2665 function defaultImportOutputDir() {
2666 const slugSel = el('import-create-project-slug');
2667 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2668 const subSel = el('import-create-project-subroot');
2669 const sub =
2670 subSel && subSel.value && subSel.value !== '__custom_sub__' ? String(subSel.value).replace(/^\/+|\/+$/g, '') : '';
2671 const subUse = sub || 'inbox';
2672 return 'projects/' + slugSel.value + '/' + subUse;
2673 }
2674 const sel = el('import-vault-folder');
2675 const folder = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
2676 return folder;
2677 }
2678
2679 function getImportProjectAndOutputDir() {
2680 const outInp = el('import-output-dir');
2681 const slugSel = el('import-create-project-slug');
2682 const raw = outInp && outInp.value ? String(outInp.value).trim().replace(/\/+$/, '') : '';
2683 if (raw) {
2684 const sug = projectsPathTypoSuggestion(raw);
2685 if (sug) {
2686 return {
2687 err: 'Destination uses project/ but the standard prefix is projects/ (plural). Edit the path or use the suggested value: ' + sug,
2688 project: '',
2689 outputDir: undefined,
2690 };
2691 }
2692 }
2693 const outputDir = raw || undefined;
2694 let project = '';
2695 if (slugSel && slugSel.value && slugSel.value !== '__custom__') {
2696 project = normSlug(slugSel.value);
2697 }
2698 if (!project && outputDir) {
2699 const m = outputDir.match(/^projects\/([^/]+)/);
2700 if (m) project = normSlug(m[1]);
2701 }
2702 return { err: null, project: project || '', outputDir: outputDir || undefined };
2703 }
2704
2705 function updateFullCreateSimilarInlineHint() {
2706 const hint = el('full-path-similar-hint');
2707 const btn = el('btn-full-path-use-similar-project');
2708 const pathInp = el('full-path');
2709 if (!hint || !pathInp) return;
2710 const notePath = pathInp.value.trim();
2711 const slug = projectSlugFromProjectsPath(notePath);
2712 const similar =
2713 slug && (lastHubFacets && lastHubFacets.projects)
2714 ? findSimilarFacetProject(slug, lastHubFacets.projects)
2715 : null;
2716 if (similar && notePath.startsWith('projects/')) {
2717 hint.textContent =
2718 'A filter project «' + similar + '» looks like a better match than «' + slug + '» in your path. You can fix the path before creating.';
2719 hint.className = 'muted small detail-project-hint warn';
2720 hint.classList.remove('hidden');
2721 if (btn) {
2722 btn.classList.remove('hidden');
2723 btn.onclick = () => {
2724 const fixed = notePath.replace(/^projects\/[^/]+/, 'projects/' + similar);
2725 pathInp.value = fixed;
2726 syncFolderSelectToPathInput();
2727 syncFullCreatePickersFromPath();
2728 syncFullProjectFromPath();
2729 updateFullPathProjectTypoHint();
2730 updateFullCreateSimilarInlineHint();
2731 };
2732 }
2733 } else {
2734 hint.textContent = '';
2735 hint.className = 'muted small detail-project-hint hidden';
2736 hint.classList.add('hidden');
2737 if (btn) {
2738 btn.classList.add('hidden');
2739 btn.onclick = null;
2740 }
2741 }
2742 }
2743
2744 function scheduleFullCreateSimilarHint() {
2745 if (fullPathSimilarDebounceTimer) clearTimeout(fullPathSimilarDebounceTimer);
2746 fullPathSimilarDebounceTimer = window.setTimeout(() => {
2747 fullPathSimilarDebounceTimer = 0;
2748 updateFullCreateSimilarInlineHint();
2749 }, 220);
2750 }
2751
2752 function openFullCreateSimilarModal(notePath, suggestedSlug) {
2753 const modal = el('modal-create-similar-project');
2754 const body = el('modal-create-similar-project-body');
2755 if (!modal || !body) return;
2756 fullCreateSimilarModalSuggestedSlug = suggestedSlug;
2757 fullCreateSimilarModalPendingPath = notePath;
2758 const bad = projectSlugFromProjectsPath(notePath) || '…';
2759 body.textContent =
2760 'Your path starts with projects/' +
2761 bad +
2762 '/ but an existing project slug is «' +
2763 suggestedSlug +
2764 '». Use the existing slug so filters and charts stay consistent, or keep your path if you intend a separate folder.';
2765 modal.classList.remove('hidden');
2766 const focusBtn = el('btn-modal-create-similar-use-existing');
2767 if (focusBtn) window.setTimeout(() => focusBtn.focus(), 0);
2768 }
2769
2770 function closeFullCreateSimilarModal() {
2771 const modal = el('modal-create-similar-project');
2772 if (modal) modal.classList.add('hidden');
2773 fullCreateSimilarModalSuggestedSlug = '';
2774 fullCreateSimilarModalPendingPath = '';
2775 }
2776
2777 /** True when any list filter used by loadNotes / Quick chips is set. */
2778 function listFacetFiltersActive() {
2779 if (filterProject.value) return true;
2780 if (filterTag.value) return true;
2781 if (filterFolder.value) return true;
2782 if (filterNetwork && filterNetwork.value) return true;
2783 if (filterWallet && filterWallet.value) return true;
2784 const fps = el('filter-payment-status');
2785 if (fps && fps.value) return true;
2786 if (filterSince && filterSince.value) return true;
2787 if (filterUntil && filterUntil.value) return true;
2788 if (filterContentScope && filterContentScope.value) return true;
2789 return false;
2790 }
2791
2792 function clearListFacetFilters() {
2793 filterProject.value = '';
2794 filterTag.value = '';
2795 filterFolder.value = '';
2796 if (filterNetwork) filterNetwork.value = '';
2797 if (filterWallet) filterWallet.value = '';
2798 const fps = el('filter-payment-status');
2799 if (fps) fps.value = '';
2800 if (filterSince) filterSince.value = '';
2801 if (filterUntil) filterUntil.value = '';
2802 if (filterContentScope) filterContentScope.value = '';
2803 }
2804
2805 function renderFilterChips(facets) {
2806 filterChipsEl.innerHTML = '';
2807 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2808
2809 const header = document.createElement('div');
2810 header.className = 'filter-chips-header';
2811
2812 const label = document.createElement('span');
2813 label.className = 'toolbar-label';
2814 label.textContent = 'Quick tags';
2815 label.title = 'Quick tags: project, tag, folder, and network filter chips (not the key glossary)';
2816
2817 const toggle = document.createElement('button');
2818 toggle.type = 'button';
2819 toggle.className = 'filter-chips-toggle';
2820 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2821 toggle.setAttribute('aria-controls', 'filter-chips-panel');
2822 toggle.title = filterChipsExpanded
2823 ? 'Hide Quick tags filter chips'
2824 : 'Show Quick tags filter chips';
2825 toggle.setAttribute(
2826 'aria-label',
2827 filterChipsExpanded
2828 ? 'Collapse Quick tags filter chips'
2829 : 'Expand Quick tags filter chips',
2830 );
2831 toggle.innerHTML =
2832 '<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>';
2833 toggle.onclick = () => {
2834 filterChipsExpanded = !filterChipsExpanded;
2835 try {
2836 localStorage.setItem(FILTER_CHIPS_EXPANDED_KEY, filterChipsExpanded ? '1' : '0');
2837 } catch (_) {}
2838 filterChipsEl.classList.toggle('is-expanded', filterChipsExpanded);
2839 toggle.setAttribute('aria-expanded', filterChipsExpanded ? 'true' : 'false');
2840 toggle.title = filterChipsExpanded
2841 ? 'Hide Quick tags filter chips'
2842 : 'Show Quick tags filter chips';
2843 toggle.setAttribute(
2844 'aria-label',
2845 filterChipsExpanded
2846 ? 'Collapse Quick tags filter chips'
2847 : 'Expand Quick tags filter chips',
2848 );
2849 };
2850
2851 header.appendChild(label);
2852 header.appendChild(toggle);
2853 filterChipsEl.appendChild(header);
2854
2855 const panel = document.createElement('div');
2856 panel.id = 'filter-chips-panel';
2857 panel.className = 'filter-chips-panel';
2858 panel.setAttribute('role', 'region');
2859 panel.setAttribute('aria-label', 'Quick tags filter chips');
2860 filterChipsEl.appendChild(panel);
2861
2862 const allBtn = document.createElement('button');
2863 allBtn.type = 'button';
2864 allBtn.className = 'chip-btn chip-all' + (listFacetFiltersActive() ? '' : ' active');
2865 allBtn.textContent = 'All';
2866 allBtn.title =
2867 'Show all notes: clear project, tag, folder, dates, content scope, and blockchain list filters';
2868 allBtn.onclick = () => {
2869 searchQuery.value = '';
2870 clearListFacetFilters();
2871 switchNotesView('list');
2872 loadNotes();
2873 renderFilterChips(null);
2874 };
2875 panel.appendChild(allBtn);
2876
2877 const apply = (f) => {
2878 if (!f) return;
2879 (f.projects || []).slice(0, 12).forEach((p) => {
2880 const b = document.createElement('button');
2881 b.type = 'button';
2882 b.className = 'chip-btn' + (filterProject.value === p ? ' active' : '');
2883 b.textContent = 'project:' + p;
2884 b.onclick = () => {
2885 searchQuery.value = '';
2886 filterProject.value = p;
2887 filterTag.value = '';
2888 filterFolder.value = '';
2889 switchNotesView('list');
2890 loadNotes();
2891 renderFilterChips(null);
2892 };
2893 panel.appendChild(b);
2894 });
2895 (f.tags || []).slice(0, 10).forEach((t) => {
2896 const b = document.createElement('button');
2897 b.type = 'button';
2898 b.className = 'chip-btn' + (filterTag.value === t ? ' active' : '');
2899 b.textContent = 'tag:' + t;
2900 b.onclick = () => {
2901 searchQuery.value = '';
2902 filterTag.value = t;
2903 filterProject.value = '';
2904 filterFolder.value = '';
2905 switchNotesView('list');
2906 loadNotes();
2907 renderFilterChips(null);
2908 };
2909 panel.appendChild(b);
2910 });
2911 (f.folders || []).slice(0, 12).forEach((folder) => {
2912 const b = document.createElement('button');
2913 b.type = 'button';
2914 b.className = 'chip-btn' + (filterFolder.value === folder ? ' active' : '');
2915 b.textContent = 'folder:' + folder;
2916 b.onclick = () => {
2917 searchQuery.value = '';
2918 filterFolder.value = folder;
2919 filterProject.value = '';
2920 filterTag.value = '';
2921 switchNotesView('list');
2922 loadNotes();
2923 renderFilterChips(null);
2924 };
2925 panel.appendChild(b);
2926 });
2927 // Phase 12 — network chips
2928 (f.networks || []).slice(0, 8).forEach((net) => {
2929 const b = document.createElement('button');
2930 b.type = 'button';
2931 b.className = 'chip-btn chip-blockchain' + (filterNetwork && filterNetwork.value === net ? ' active' : '');
2932 b.textContent = 'net:' + net;
2933 b.onclick = () => {
2934 searchQuery.value = '';
2935 if (filterNetwork) filterNetwork.value = net;
2936 switchNotesView('list');
2937 loadNotes();
2938 renderFilterChips(null);
2939 };
2940 panel.appendChild(b);
2941 });
2942 // Phase 12 — payment_status Quick chips (fixed enum, shown when vault has any blockchain notes)
2943 if ((f.networks || []).length > 0 || (f.wallets || []).length > 0) {
2944 const payStatuses = ['pending', 'settled', 'failed'];
2945 payStatuses.forEach((ps) => {
2946 const b = document.createElement('button');
2947 b.type = 'button';
2948 b.className = 'chip-btn chip-blockchain';
2949 b.textContent = 'status:' + ps;
2950 b.onclick = () => {
2951 searchQuery.value = '';
2952 const fpsEl = el('filter-payment-status');
2953 if (fpsEl) fpsEl.value = ps;
2954 switchNotesView('list');
2955 loadNotes();
2956 renderFilterChips(null);
2957 };
2958 panel.appendChild(b);
2959 });
2960 }
2961 };
2962 if (facets) apply(facets);
2963 else fetchFacetsResolved().then(apply).catch(() => {});
2964 }
2965
2966 function getPresets() {
2967 try {
2968 const raw = localStorage.getItem(PRESETS_KEY);
2969 return raw ? JSON.parse(raw) : [];
2970 } catch (_) {
2971 return [];
2972 }
2973 }
2974
2975 function savePreset() {
2976 const name = (presetNameInput.value || '').trim();
2977 if (!name) return;
2978 const presets = getPresets().filter((p) => p.name !== name);
2979 presets.push({
2980 name,
2981 project: filterProject.value,
2982 tag: filterTag.value,
2983 folder: filterFolder.value,
2984 since: filterSince?.value || '',
2985 until: filterUntil?.value || '',
2986 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
2987 });
2988 localStorage.setItem(PRESETS_KEY, JSON.stringify(presets.slice(-20)));
2989 presetNameInput.value = '';
2990 renderPresets();
2991 }
2992
2993 function renderPresets() {
2994 presetsListEl.innerHTML = '';
2995 getPresets().forEach((p) => {
2996 const b = document.createElement('button');
2997 b.type = 'button';
2998 b.className = 'preset-pill';
2999 b.textContent = p.name;
3000 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(' ');
3001 b.onclick = () => {
3002 filterProject.value = p.project || '';
3003 filterTag.value = p.tag || '';
3004 filterFolder.value = p.folder || '';
3005 if (filterSince) filterSince.value = p.since || '';
3006 if (filterUntil) filterUntil.value = p.until || '';
3007 if (filterContentScope) filterContentScope.value = p.content_scope || '';
3008 switchNotesView('list');
3009 loadNotes();
3010 renderFilterChips(null);
3011 };
3012 presetsListEl.appendChild(b);
3013 });
3014 }
3015
3016 el('btn-save-preset').onclick = savePreset;
3017
3018 function renderNoteRow(n) {
3019 const title = n.title || n.path;
3020 const isLog = hubRowIsApprovalLog(n);
3021 const chips = [];
3022 if (n.project) chips.push('<span class="chip chip-project">' + escapeHtml(n.project) + '</span>');
3023 (n.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3024 const meta = [n.date].filter(Boolean).join(' · ');
3025 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3026 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3027 return (
3028 '<div class="' +
3029 rowClass +
3030 '" data-path="' +
3031 escapeHtml(n.path) +
3032 '"><span class="row-title">' +
3033 escapeHtml(title) +
3034 badge +
3035 '</span><div class="row-chips">' +
3036 chips.join('') +
3037 '</div>' +
3038 (meta ? '<div class="status">' + escapeHtml(meta) + '</div>' : '') +
3039 '<button class="list-item-delete" title="Delete note" aria-label="Delete note">✕</button>' +
3040 '</div>'
3041 );
3042 }
3043
3044 function bindNoteClicks(container) {
3045 container.querySelectorAll('.list-item').forEach((item) => {
3046 item.onclick = () => openNote(item.dataset.path);
3047 const delBtn = item.querySelector('.list-item-delete');
3048 if (delBtn) {
3049 delBtn.onclick = async (e) => {
3050 e.stopPropagation();
3051 const path = item.dataset.path;
3052 if (!path) return;
3053 if (!confirm('Permanently delete "' + path + '"?\nThis cannot be undone.')) return;
3054 try {
3055 await api('/api/v1/notes/' + encodeURIComponent(path), { method: 'DELETE' });
3056 if (typeof showToast === 'function') showToast('Deleted: ' + path);
3057 hubMarkSemanticIndexStale();
3058 if (currentOpenNote && currentOpenNote.path === path) {
3059 currentOpenNote = null;
3060 resetDetailSectionSourceState();
3061 hideDetailPanelChrome();
3062 }
3063 loadNotes();
3064 loadFacets();
3065 } catch (err) {
3066 if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || err), true);
3067 }
3068 };
3069 }
3070 });
3071 }
3072
3073 function hasActiveNoteListFilters() {
3074 if (filterProject && filterProject.value) return true;
3075 if (filterTag && filterTag.value) return true;
3076 if (filterFolder && filterFolder.value) return true;
3077 if (filterSince && filterSince.value) return true;
3078 if (filterUntil && filterUntil.value) return true;
3079 if (filterContentScope && filterContentScope.value) return true;
3080 if (filterNetwork && filterNetwork.value) return true;
3081 if (filterWallet && filterWallet.value) return true;
3082 const paymentStatusEl = el('filter-payment-status');
3083 if (paymentStatusEl && paymentStatusEl.value) return true;
3084 return false;
3085 }
3086
3087 function readOnboardingDismissedSync() {
3088 try {
3089 const raw = localStorage.getItem('knowtation_onboarding_v1');
3090 if (!raw) return false;
3091 const o = JSON.parse(raw);
3092 return Boolean(o && o.v === 1 && o.status === 'dismissed');
3093 } catch (_) {
3094 return false;
3095 }
3096 }
3097
3098 function isSearchResultsView() {
3099 const t = notesTotal && notesTotal.textContent ? String(notesTotal.textContent) : '';
3100 return /\b(keyword|semantic)\b/i.test(t) && /result/i.test(t);
3101 }
3102
3103 function updateEmptyVaultStripVisibility() {
3104 const strip = el('hub-empty-vault-strip');
3105 if (!strip) return;
3106 const mainVisible = main && !main.classList.contains('hidden');
3107 const notesTab = getActiveHubMainTab() === 'notes';
3108 const q = searchQuery && String(searchQuery.value).trim();
3109 const show =
3110 Boolean(mainVisible && token) &&
3111 readOnboardingDismissedSync() &&
3112 hubBrowseListEmptyUnfiltered &&
3113 notesTab &&
3114 !q &&
3115 !isSearchResultsView();
3116 strip.classList.toggle('hidden', !show);
3117 }
3118
3119 async function loadNotes() {
3120 const q = new URLSearchParams();
3121 q.set('limit', '100');
3122 if (filterFolder.value) q.set('folder', filterFolder.value);
3123 if (filterProject.value) q.set('project', filterProject.value);
3124 if (filterTag.value) q.set('tag', filterTag.value);
3125 if (filterSince && filterSince.value) q.set('since', filterSince.value);
3126 if (filterUntil && filterUntil.value) q.set('until', filterUntil.value);
3127 if (filterContentScope && filterContentScope.value) q.set('content_scope', filterContentScope.value);
3128 if (filterContentClass && filterContentClass.value) q.set('content_class', filterContentClass.value);
3129 // Phase 12 — blockchain filters
3130 const networkVal = filterNetwork ? filterNetwork.value : '';
3131 const walletVal = filterWallet ? filterWallet.value : '';
3132 const paymentStatusVal = el('filter-payment-status') ? el('filter-payment-status').value : '';
3133 if (networkVal) q.set('network', networkVal);
3134 if (walletVal) q.set('wallet_address', walletVal);
3135 if (paymentStatusVal) q.set('payment_status', paymentStatusVal);
3136 notesList.innerHTML = loadingHtml;
3137 notesTotal.textContent = '';
3138 try {
3139 const out = await api('/api/v1/notes?' + q.toString());
3140 let notes = (out.notes || []).map(normalizeHubListItem);
3141 notes = applyVaultListFilters(notes, {
3142 folder: filterFolder.value,
3143 project: filterProject.value,
3144 tag: filterTag.value,
3145 since: filterSince?.value || '',
3146 until: filterUntil?.value || '',
3147 content_scope: filterContentScope && filterContentScope.value ? filterContentScope.value : '',
3148 content_class: filterContentClass && filterContentClass.value ? filterContentClass.value : '',
3149 network: networkVal,
3150 wallet_address: walletVal,
3151 payment_status: paymentStatusVal,
3152 });
3153 notes = applySortedNotesClient(notes);
3154 const totalCount = notes.length;
3155 notes = notes.slice(0, 100);
3156 if (notes.length === 0) {
3157 notesList.innerHTML =
3158 '<div class="empty-state">No notes for this filter. <a id="empty-add">Add a note</a> or clear filters.</div>';
3159 const ea = el('empty-add');
3160 if (ea) ea.onclick = () => openCreateModal();
3161 notesTotal.textContent = 'Total: 0';
3162 } else {
3163 notesList.innerHTML = notes.map(renderNoteRow).join('');
3164 notesTotal.textContent = 'Total: ' + totalCount;
3165 bindNoteClicks(notesList);
3166 listSelectedIndex = 0;
3167 updateListSelection();
3168 }
3169 hubBrowseListEmptyUnfiltered = totalCount === 0 && !hasActiveNoteListFilters();
3170 updateEmptyVaultStripVisibility();
3171 } catch (e) {
3172 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3173 notesTotal.textContent = '';
3174 hubBrowseListEmptyUnfiltered = false;
3175 updateEmptyVaultStripVisibility();
3176 }
3177 }
3178
3179 function switchHubMainTab(name) {
3180 closeHubMoreSheet();
3181 document.querySelectorAll('[data-tab].tab').forEach((t) => {
3182 t.classList.toggle('active', t.dataset.tab === name);
3183 });
3184 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3185 syncHubListSortUI(name);
3186 refreshNewProposalTabVisibility();
3187 const panel = el(
3188 'tab-' +
3189 (name === 'notes'
3190 ? 'notes'
3191 : name === 'activity'
3192 ? 'activity'
3193 : name === 'suggested'
3194 ? 'suggested'
3195 : 'problem'),
3196 );
3197 if (panel) panel.classList.remove('hidden');
3198 if (name === 'notes') {
3199 const graphPanel = el('notes-view-graph');
3200 if (graphPanel && !graphPanel.classList.contains('hidden')) {
3201 switchNotesView('list');
3202 } else {
3203 syncHubRailChrome(name);
3204 syncModeToolbars(name);
3205 }
3206 loadNotes();
3207 updateNeedsYouBanner(hubReviewBadgePrevCount);
3208 } else {
3209 syncHubRailChrome(name);
3210 syncModeToolbars(name);
3211 if (name === 'activity') loadActivity();
3212 if (name === 'suggested' || name === 'problem') loadProposals();
3213 updateEmptyVaultStripVisibility();
3214 updateNeedsYouBanner(hubReviewBadgePrevCount);
3215 }
3216 }
3217
3218 function updateListSelection() {
3219 const container = notesList;
3220 const items = container.querySelectorAll('.list-item');
3221 if (items.length === 0) { listSelectedIndex = 0; return; }
3222 listSelectedIndex = Math.max(0, Math.min(listSelectedIndex, items.length - 1));
3223 items.forEach((item, i) => item.classList.toggle('selected', i === listSelectedIndex));
3224 const sel = items[listSelectedIndex];
3225 if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3226 }
3227
3228 btnApplyFilters.onclick = () => {
3229 switchNotesView('list');
3230 loadNotes();
3231 renderFilterChips(null);
3232 syncVaultAdvancedFiltersOpen();
3233 };
3234
3235 if (filterContentScope) {
3236 filterContentScope.addEventListener('change', () => {
3237 switchNotesView('list');
3238 loadNotes();
3239 renderFilterChips(null);
3240 });
3241 }
3242
3243 function formatSearchScopeSummary() {
3244 const parts = [];
3245 if (filterProject.value) parts.push('project: ' + filterProject.value);
3246 if (filterTag.value) parts.push('tag: ' + filterTag.value);
3247 if (filterFolder.value) parts.push('folder: ' + filterFolder.value);
3248 if (filterSince && filterSince.value) parts.push('since ' + filterSince.value);
3249 if (filterUntil && filterUntil.value) parts.push('until ' + filterUntil.value);
3250 if (filterContentScope && filterContentScope.value === 'notes') parts.push('notes only');
3251 if (filterContentScope && filterContentScope.value === 'approval_logs') parts.push('approval logs only');
3252 return parts.length ? parts.join(' · ') : '';
3253 }
3254
3255 function semanticMatchStrengthLabel(score) {
3256 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3257 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3258 return 'Match strength ~' + pct + '% (higher = closer in meaning)';
3259 }
3260
3261 function keywordMatchStrengthLabel(score) {
3262 if (score == null || typeof score !== 'number' || Number.isNaN(score)) return '';
3263 const pct = Math.round(Math.min(1, Math.max(0, score)) * 100);
3264 return 'Keyword match ~' + pct + '% (text overlap)';
3265 }
3266
3267 if (btnClearSearch) {
3268 btnClearSearch.onclick = () => {
3269 searchQuery.value = '';
3270 clearListFacetFilters();
3271 switchNotesView('list');
3272 switchHubMainTab('notes');
3273 renderFilterChips(null);
3274 const adv = el('hub-search-advanced');
3275 if (adv && !hasActiveNoteListFilters()) adv.open = false;
3276 };
3277 }
3278
3279 function showToast(message, isError = false) {
3280 const toast = document.createElement('div');
3281 toast.className = 'toast' + (isError ? ' toast-err' : '');
3282 toast.textContent = message;
3283 toast.setAttribute('role', 'status');
3284 document.body.appendChild(toast);
3285 requestAnimationFrame(() => toast.classList.add('toast-show'));
3286 setTimeout(() => {
3287 toast.classList.remove('toast-show');
3288 setTimeout(() => toast.remove(), 300);
3289 }, 3000);
3290 }
3291
3292 const proposalFilterApply = el('proposal-filter-apply');
3293 if (proposalFilterApply) {
3294 proposalFilterApply.onclick = () => {
3295 loadProposals();
3296 loadActivity();
3297 syncPendingEvalQuickChip();
3298 };
3299 }
3300 const proposalFilterClear = el('proposal-filter-clear');
3301 if (proposalFilterClear) {
3302 proposalFilterClear.onclick = () => {
3303 const lf = el('proposal-filter-label');
3304 const sf = el('proposal-filter-source');
3305 const pf = el('proposal-filter-path-prefix');
3306 const pe = el('proposal-filter-pending-eval');
3307 const rq = el('proposal-filter-review-queue');
3308 const rs = el('proposal-filter-review-severity');
3309 if (lf) lf.value = '';
3310 if (sf) sf.value = '';
3311 if (pf) pf.value = '';
3312 if (pe) pe.checked = false;
3313 if (rq) rq.value = '';
3314 if (rs) rs.value = '';
3315 loadProposals();
3316 loadActivity();
3317 syncPendingEvalQuickChip();
3318 };
3319 }
3320 const pendingEvalChip = el('proposal-pending-eval-chip');
3321 if (pendingEvalChip) {
3322 pendingEvalChip.onclick = () => {
3323 const pe = el('proposal-filter-pending-eval');
3324 if (!pe) return;
3325 pe.checked = !pe.checked;
3326 syncPendingEvalQuickChip();
3327 loadProposals();
3328 loadActivity();
3329 };
3330 }
3331
3332 const hubListSortEl = hubListSortGetSelect();
3333 if (hubListSortEl) {
3334 hubListSortEl.addEventListener('change', () => {
3335 const tab = getActiveHubMainTab();
3336 try {
3337 if (tab === 'notes') localStorage.setItem(HUB_SORT_STORAGE_NOTES, hubListSortEl.value);
3338 else if (tab === 'activity' || tab === 'suggested' || tab === 'problem') {
3339 localStorage.setItem(HUB_SORT_STORAGE_PROPOSALS, hubListSortEl.value);
3340 }
3341 } catch (_) {}
3342 if (tab === 'notes') loadNotes();
3343 else if (tab === 'activity') loadActivity();
3344 else if (tab === 'suggested' || tab === 'problem') loadProposals();
3345 });
3346 }
3347
3348 if (btnReindex) {
3349 btnReindex.onclick = async () => {
3350 await withButtonBusy(btnReindex, 'Indexing…', async () => {
3351 try {
3352 // `noRetry: true` prevents duplicate bridge invocations on gateway timeout
3353 // (see api() helper). Bridge may return one of three shapes:
3354 // 200 {ok:true, ...} → sync completed
3355 // 202 {status:'background', ...} → routed to bridge-index-background fn
3356 // 409 {status:'already_running'} → another background job in flight
3357 const out = await api('/api/v1/index', { method: 'POST', noRetry: true });
3358 if (out && out.status === 'background') {
3359 showToast(out.message || 'Large re-index started in the background. Refresh in 1–2 minutes.');
3360 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3361 } else if (out && out.status === 'already_running') {
3362 showToast(out.message || 'A background re-index is already running for this vault.');
3363 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3364 } else {
3365 const n = out.notesProcessed ?? 0;
3366 const c = out.chunksIndexed ?? 0;
3367 const skipped = out.chunksSkippedCached ?? 0;
3368 const embedded = out.chunksEmbedded ?? c;
3369 const detail = skipped > 0
3370 ? ' (' + embedded + ' embedded, ' + skipped + ' cached)'
3371 : '';
3372 showToast('Indexed ' + n + ' notes, ' + c + ' chunks' + detail + '.');
3373 hubClearSemanticIndexStale();
3374 loadFacets();
3375 loadNotes();
3376 hubLoadIndexStatus().catch(() => {});
3377 }
3378 } catch (e) {
3379 showToast(e.message || 'Re-index failed', true);
3380 }
3381 });
3382 };
3383 }
3384
3385 /*
3386 * Passive "Last indexed: N minutes ago" line next to the Re-index button.
3387 * Reads from `GET /api/v1/index/status` which both sync and background paths
3388 * keep current via `lib/bridge-index-last-indexed.mjs`. We poll while a
3389 * background job is in flight so the line flips from
3390 * "Re-indexing in background…" → "Last indexed: just now"
3391 * without the user needing to click anything.
3392 */
3393 let _hubIndexStatusPollTimer = null;
3394 function hubFormatRelativeTime(epochMs) {
3395 if (!Number.isFinite(epochMs)) return '';
3396 const ageMs = Date.now() - epochMs;
3397 if (ageMs < 0) return 'just now';
3398 const sec = Math.round(ageMs / 1000);
3399 if (sec < 45) return 'just now';
3400 const min = Math.round(sec / 60);
3401 if (min < 60) return min + ' minute' + (min === 1 ? '' : 's') + ' ago';
3402 const hr = Math.round(min / 60);
3403 if (hr < 48) return hr + ' hour' + (hr === 1 ? '' : 's') + ' ago';
3404 const days = Math.round(hr / 24);
3405 return days + ' day' + (days === 1 ? '' : 's') + ' ago';
3406 }
3407 async function hubLoadIndexStatus(opts) {
3408 opts = opts || {};
3409 const el = document.getElementById('hub-index-status');
3410 if (!el) return;
3411 let status;
3412 try {
3413 status = await api('/api/v1/index/status', { method: 'GET' });
3414 } catch (_) {
3415 // Endpoint not deployed yet (e.g. older bridge) → leave the line empty.
3416 el.textContent = '';
3417 el.classList.remove('hub-index-status-running');
3418 return;
3419 }
3420 if (status && status.inProgress) {
3421 el.textContent = 'Re-indexing in background…';
3422 el.classList.add('hub-index-status-running');
3423 // Keep polling so the line auto-clears when the background job finishes.
3424 // 5-second cadence matches typical embedding batch completion granularity
3425 // and stays well under any sane rate limit.
3426 if (_hubIndexStatusPollTimer == null && opts.pollWhileRunning !== false) {
3427 _hubIndexStatusPollTimer = setInterval(() => {
3428 hubLoadIndexStatus({ pollWhileRunning: true }).catch(() => {});
3429 }, 5000);
3430 }
3431 return;
3432 }
3433 // No in-flight job — stop polling if we were.
3434 if (_hubIndexStatusPollTimer != null) {
3435 clearInterval(_hubIndexStatusPollTimer);
3436 _hubIndexStatusPollTimer = null;
3437 }
3438 el.classList.remove('hub-index-status-running');
3439 if (status && status.lastIndexed && Number.isFinite(status.lastIndexed.lastIndexedAtEpochMs)) {
3440 const rel = hubFormatRelativeTime(status.lastIndexed.lastIndexedAtEpochMs);
3441 el.textContent = 'Last indexed: ' + rel;
3442 el.title =
3443 'Last successful index: ' +
3444 (status.lastIndexed.lastIndexedAt || '') +
3445 ' · ' +
3446 (status.lastIndexed.chunksIndexed || 0) +
3447 ' chunks · mode: ' +
3448 (status.lastIndexed.mode || 'sync');
3449 } else {
3450 el.textContent = '';
3451 el.title = '';
3452 }
3453 }
3454 // Kick off an initial status load once the user is logged in (the API call
3455 // 401s otherwise). We piggyback on the same `loadFacets`/`loadNotes` startup
3456 // that already happens after token validation succeeds.
3457 hubLoadIndexStatus().catch(() => {});
3458
3459 const hubIndexStaleRun = el('hub-index-stale-run');
3460 const hubIndexStaleDismiss = el('hub-index-stale-dismiss');
3461 if (hubIndexStaleRun && btnReindex) {
3462 hubIndexStaleRun.onclick = () => {
3463 btnReindex.click();
3464 };
3465 }
3466 if (hubIndexStaleDismiss) {
3467 hubIndexStaleDismiss.onclick = () => {
3468 hubClearSemanticIndexStale();
3469 };
3470 }
3471
3472 function proposalFilterQuerySuffix() {
3473 const params = [];
3474 const lab = el('proposal-filter-label');
3475 const src = el('proposal-filter-source');
3476 const pre = el('proposal-filter-path-prefix');
3477 if (lab && lab.value.trim()) params.push('label=' + encodeURIComponent(lab.value.trim()));
3478 if (src && src.value.trim()) params.push('source=' + encodeURIComponent(src.value.trim()));
3479 if (pre && pre.value.trim()) params.push('path_prefix=' + encodeURIComponent(pre.value.trim()));
3480 const pe = el('proposal-filter-pending-eval');
3481 if (pe && pe.checked) params.push('evaluation_status=pending');
3482 const rq = el('proposal-filter-review-queue');
3483 if (rq && rq.value.trim()) params.push('review_queue=' + encodeURIComponent(rq.value.trim()));
3484 const rs = el('proposal-filter-review-severity');
3485 if (rs && rs.value.trim()) params.push('review_severity=' + encodeURIComponent(rs.value.trim()));
3486 return params.length ? '&' + params.join('&') : '';
3487 }
3488
3489 // Discard a proposal directly from the list without opening the detail panel.
3490 async function discardProposalInline(id, itemEl) {
3491 if (!confirm('Discard this proposal?\nThis cannot be undone.')) return;
3492 try {
3493 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
3494 if (typeof showToast === 'function') showToast('Proposal discarded.');
3495 const panel = el('detail-panel');
3496 if (panel && !panel.classList.contains('hidden')) {
3497 hideDetailPanelChrome();
3498 }
3499 loadProposals();
3500 loadActivity();
3501 } catch (err) {
3502 if (typeof showToast === 'function') showToast('Discard failed: ' + (err.message || err), true);
3503 }
3504 }
3505
3506 async function loadProposals() {
3507 void refreshReviewBadge();
3508 syncPendingEvalQuickChip();
3509 const SI = hubShellIa();
3510 const primaryCta =
3511 SI && typeof SI.emptyReviewPrimaryCtaLabel === 'function'
3512 ? SI.emptyReviewPrimaryCtaLabel()
3513 : 'New proposal';
3514 const secondaryCta =
3515 SI && typeof SI.emptyReviewSecondaryCtaLabel === 'function'
3516 ? SI.emptyReviewSecondaryCtaLabel()
3517 : 'How Review works';
3518 const canCreate = hubUserCanWriteNotes();
3519 const emptySuggested =
3520 '<div class="empty-state empty-state-suggested">' +
3521 '<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>' +
3522 '<p class="empty-state-suggested-actions">' +
3523 (canCreate
3524 ? '<button type="button" class="btn-primary" id="empty-suggested-new">' +
3525 escapeHtml(primaryCta) +
3526 '</button>'
3527 : '') +
3528 '<button type="button" class="btn-secondary" id="empty-suggested-how-to">' +
3529 escapeHtml(secondaryCta) +
3530 '</button>' +
3531 '</p>' +
3532 '</div>';
3533 const emptyDiscarded = '<div class="empty-state">No discarded proposals.</div>';
3534 const fq = proposalFilterQuerySuffix();
3535 [
3536 { kind: 'suggested', status: 'proposed', empty: emptySuggested },
3537 { kind: 'problem', status: 'discarded', empty: emptyDiscarded },
3538 ].forEach(({ kind, status, empty: emptyHtml }) => {
3539 const container = el('proposals-' + kind);
3540 if (!container) return;
3541 container.innerHTML = loadingHtml;
3542 api('/api/v1/proposals?status=' + encodeURIComponent(status) + '&limit=100' + fq)
3543 .then((out) => {
3544 let list = out.proposals || [];
3545 list = applySortedProposalsClient(list);
3546 if (list.length === 0) {
3547 container.innerHTML = emptyHtml;
3548 if (kind === 'suggested') {
3549 proposalListIds = [];
3550 clearReviewSplitPosition();
3551 const how = container.querySelector('#empty-suggested-how-to');
3552 if (how) how.onclick = () => openHowToUse('knowledge-agents');
3553 const neu = container.querySelector('#empty-suggested-new');
3554 if (neu) neu.onclick = () => openCreateProposalModal({});
3555 const peChip = el('proposal-pending-eval-chip');
3556 if (peChip && !peChip.classList.contains('hidden')) {
3557 // chip remains available above empty state when policy requires eval
3558 }
3559 }
3560 return;
3561 }
3562 const canDiscard = kind === 'suggested' && hubUserCanWriteNotes();
3563 if (kind === 'suggested') {
3564 proposalListIds = list.map((p) => String(p.proposal_id));
3565 proposalListSelectedIndex = 0;
3566 }
3567 container.innerHTML = list
3568 .map((p) => {
3569 const srcChip = p.source
3570 ? '<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>'
3571 : '';
3572 const pendingChip =
3573 SI && typeof SI.reviewRowNeedsPendingEvalChip === 'function'
3574 ? SI.reviewRowNeedsPendingEvalChip(p.evaluation_status)
3575 : String(p.evaluation_status || '').toLowerCase() === 'pending';
3576 const pendingHtml = pendingChip
3577 ? '<span class="proposal-chip proposal-chip-pending-eval">Pending eval</span>'
3578 : '';
3579 const rel =
3580 SI && typeof SI.formatRelativeTime === 'function'
3581 ? SI.formatRelativeTime(p.updated_at || p.created_at)
3582 : '';
3583 const timeHtml = rel
3584 ? '<span class="row-time">' + escapeHtml(rel) + '</span>'
3585 : p.updated_at
3586 ? '<span class="row-time">' +
3587 escapeHtml(calendarDisplayDayKey(p.updated_at) || p.updated_at.slice(0, 10)) +
3588 '</span>'
3589 : '';
3590 const discardBtn = canDiscard
3591 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3592 : '';
3593 return (
3594 '<div class="list-item review-row" data-id="' +
3595 escapeHtml(p.proposal_id) +
3596 '"><span class="row-title">' +
3597 escapeHtml(p.path) +
3598 '</span><div class="row-meta">' +
3599 srcChip +
3600 pendingHtml +
3601 timeHtml +
3602 '</div>' +
3603 discardBtn +
3604 '</div>'
3605 );
3606 })
3607 .join('');
3608 container.querySelectorAll('.list-item').forEach((item, idx) => {
3609 item.onclick = () => {
3610 proposalListSelectedIndex = idx;
3611 updateProposalListSelection(container);
3612 if (kind === 'suggested') {
3613 setReviewSplitPosition(idx + 1, list.length);
3614 }
3615 openProposal(item.dataset.id);
3616 };
3617 const db = item.querySelector('.list-item-delete');
3618 if (db) {
3619 db.onclick = (e) => {
3620 e.stopPropagation();
3621 discardProposalInline(item.dataset.id, item);
3622 };
3623 }
3624 });
3625 if (kind === 'suggested') updateProposalListSelection(container);
3626 })
3627 .catch(() => (container.innerHTML = '<p class="muted">Failed to load</p>'));
3628 });
3629 }
3630
3631 async function loadActivity() {
3632 const container = el('proposals-activity');
3633 if (!container) return;
3634 container.innerHTML = loadingHtml;
3635 try {
3636 const fq = proposalFilterQuerySuffix();
3637 const out = await api('/api/v1/proposals?limit=100' + fq);
3638 let list = out.proposals || [];
3639 list = applySortedProposalsClient(list);
3640 if (list.length === 0) {
3641 container.innerHTML =
3642 '<div class="empty-state empty-state-activity">' +
3643 '<p>No proposal activity yet.</p>' +
3644 '<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>' +
3645 '<p class="empty-state-activity-actions"><button type="button" class="btn-secondary" id="empty-activity-goto-suggested">Open Review</button></p>' +
3646 '</div>';
3647 const go = container.querySelector('#empty-activity-goto-suggested');
3648 if (go) go.onclick = () => switchHubMainTab('suggested');
3649 return;
3650 }
3651 const canDiscard = hubUserCanWriteNotes();
3652 container.innerHTML = list
3653 .map((p) => {
3654 const statusClass = p.status === 'approved' ? 'status-approved' : p.status === 'discarded' ? 'status-discarded' : 'status-proposed';
3655 const date = calendarDisplayDayKey(p.updated_at || p.created_at || '') || (p.updated_at || p.created_at || '').slice(0, 10);
3656 // Show discard for proposed; show discard-again for discarded (idempotent cleanup);
3657 // approved records stay as-is unless the user opens them.
3658 const showDiscard = canDiscard && p.status !== 'approved';
3659 const discardBtn = showDiscard
3660 ? '<button class="list-item-delete" title="Discard proposal" aria-label="Discard proposal">✕</button>'
3661 : '';
3662 return (
3663 '<div class="list-item activity-item ' +
3664 statusClass +
3665 '" data-id="' +
3666 escapeHtml(p.proposal_id) +
3667 '"><span class="row-title">' +
3668 escapeHtml(p.path) +
3669 '</span><div class="status">' +
3670 escapeHtml(p.status) +
3671 ' · ' +
3672 escapeHtml(date) +
3673 '</div>' + discardBtn + '</div>'
3674 );
3675 })
3676 .join('');
3677 container.querySelectorAll('.list-item').forEach((item) => {
3678 item.onclick = () => openProposal(item.dataset.id);
3679 const db = item.querySelector('.list-item-delete');
3680 if (db) {
3681 db.onclick = (e) => {
3682 e.stopPropagation();
3683 discardProposalInline(item.dataset.id, item);
3684 };
3685 }
3686 });
3687 } catch (e) {
3688 container.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3689 }
3690 }
3691
3692 async function runVaultSearch() {
3693 const query = searchQuery.value.trim();
3694 if (!query) return;
3695 hubBrowseListEmptyUnfiltered = false;
3696 updateEmptyVaultStripVisibility();
3697 const activeMainTab = getActiveHubMainTab();
3698 const useKeyword = searchMode && searchMode.value === 'keyword';
3699 if (activeMainTab && activeMainTab !== 'notes') {
3700 showToast(useKeyword ? 'Keyword results are shown under Vault.' : 'Semantic results are shown under Vault.');
3701 }
3702 switchNotesView('list');
3703 document.querySelectorAll('[data-tab].tab').forEach((t) => {
3704 t.classList.toggle('active', t.dataset.tab === 'notes');
3705 });
3706 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
3707 const tabNotes = el('tab-notes');
3708 if (tabNotes) tabNotes.classList.remove('hidden');
3709 setProposalFiltersBarVisible(false);
3710 refreshNewProposalTabVisibility();
3711 syncHubRailChrome('notes');
3712 syncModeToolbars('notes');
3713 syncHubListSortUI('notes');
3714 notesList.innerHTML = loadingHtml;
3715 notesTotal.textContent = '';
3716 const scopeSummary = formatSearchScopeSummary();
3717 const scopeSuffix = scopeSummary
3718 ? ' · scope: ' + scopeSummary
3719 : ' · scope: entire vault (use dropdowns to narrow)';
3720 try {
3721 const body = { query, limit: 20 };
3722 if (useKeyword) body.mode = 'keyword';
3723 if (filterProject.value) body.project = filterProject.value;
3724 if (filterTag.value) body.tag = filterTag.value;
3725 if (filterFolder.value) body.folder = filterFolder.value;
3726 if (filterSince && filterSince.value) body.since = filterSince.value;
3727 if (filterUntil && filterUntil.value) body.until = filterUntil.value;
3728 if (filterContentScope && filterContentScope.value) body.content_scope = filterContentScope.value;
3729 const out = await api('/api/v1/search', { method: 'POST', body: JSON.stringify(body) });
3730 const results = out.results || [];
3731 if (results.length === 0) {
3732 notesList.innerHTML = useKeyword
3733 ? '<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>'
3734 : '<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>';
3735 notesTotal.textContent = (useKeyword ? '0 keyword' : '0 semantic') + ' results' + scopeSuffix;
3736 return;
3737 }
3738 notesList.innerHTML = results
3739 .map((r) => {
3740 const chips = [];
3741 if (r.project) chips.push('<span class="chip chip-project">' + escapeHtml(r.project) + '</span>');
3742 (r.tags || []).slice(0, 3).forEach((t) => chips.push('<span class="chip chip-tag">' + escapeHtml(t) + '</span>'));
3743 const strength = useKeyword ? keywordMatchStrengthLabel(r.score) : semanticMatchStrengthLabel(r.score);
3744 const pathStr = String(r.path || '').replace(/\\/g, '/');
3745 const isLog = pathStr === 'approvals' || pathStr.startsWith('approvals/');
3746 const badge = isLog ? '<span class="badge-approval-log">Approval log</span>' : '';
3747 const rowClass = 'list-item' + (isLog ? ' row-approval-log' : '');
3748 return (
3749 '<div class="' +
3750 rowClass +
3751 '" data-path="' +
3752 escapeHtml(r.path) +
3753 '"><span class="row-title">' +
3754 escapeHtml(r.path) +
3755 badge +
3756 '</span><div class="row-chips">' +
3757 chips.join('') +
3758 '</div>' +
3759 (strength ? '<div class="status muted small">' + escapeHtml(strength) + '</div>' : '') +
3760 (r.snippet ? '<div class="status">' + escapeHtml(r.snippet.slice(0, 120)) + '…</div>' : '') +
3761 '</div>'
3762 );
3763 })
3764 .join('');
3765 notesTotal.textContent =
3766 results.length +
3767 (useKeyword ? ' keyword' : ' semantic') +
3768 ' result' +
3769 (results.length === 1 ? '' : 's') +
3770 scopeSuffix;
3771 bindNoteClicks(notesList);
3772 listSelectedIndex = 0;
3773 updateListSelection();
3774 } catch (e) {
3775 notesList.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3776 notesTotal.textContent = '';
3777 }
3778 }
3779
3780 btnSearch.onclick = () => {
3781 void runVaultSearch();
3782 };
3783
3784 searchQuery.addEventListener('keydown', (e) => {
3785 if (e.key === 'Enter') {
3786 e.preventDefault();
3787 void runVaultSearch();
3788 }
3789 });
3790 searchQuery.addEventListener('input', () => {
3791 updateEmptyVaultStripVisibility();
3792 });
3793
3794 function switchNotesView(view) {
3795 document.querySelectorAll('.view-tab').forEach((t) => t.classList.toggle('active', t.dataset.view === view));
3796 el('notes-view-list').classList.toggle('hidden', view !== 'list');
3797 el('notes-view-calendar').classList.toggle('hidden', view !== 'calendar');
3798 el('notes-view-graph').classList.toggle('hidden', view !== 'graph');
3799 if (view === 'calendar') renderCalendar();
3800 if (view === 'graph') { renderDashboard(); refreshConsolidationCard(); }
3801 syncHubRailChrome(getActiveHubMainTab());
3802 syncModeToolbars(getActiveHubMainTab());
3803 }
3804
3805 document.querySelectorAll('.view-tab').forEach((t) => {
3806 t.onclick = () => switchNotesView(t.dataset.view);
3807 });
3808
3809 function ymd(d) {
3810 const y = d.getFullYear();
3811 const m = String(d.getMonth() + 1).padStart(2, '0');
3812 const day = String(d.getDate()).padStart(2, '0');
3813 return y + '-' + m + '-' + day;
3814 }
3815
3816 async function renderCalendar() {
3817 const grid = el('calendar-grid');
3818 const title = el('cal-title');
3819 const dayList = el('calendar-day-list');
3820 const dayNotes = el('calendar-day-notes');
3821 dayList.classList.add('hidden');
3822 grid.classList.remove('hidden');
3823 el('calendar-nav').classList.remove('hidden');
3824
3825 const y = calendarMonth.getFullYear();
3826 const m = calendarMonth.getMonth();
3827 title.textContent = calendarMonth.toLocaleString('default', { month: 'long', year: 'numeric' });
3828
3829 grid.innerHTML = loadingHtml;
3830 const first = new Date(y, m, 1);
3831 const last = new Date(y, m + 1, 0);
3832 const since = ymd(first);
3833 const until = ymd(last);
3834
3835 let notesInMonth = [];
3836 try {
3837 const q = new URLSearchParams({ since, until, limit: '100' });
3838 const out = await api('/api/v1/notes?' + q.toString());
3839 notesInMonth = (out.notes || [])
3840 .map(normalizeHubListItem)
3841 .filter((n) => {
3842 const ds = noteSortOrCalendarDay(n);
3843 return ds >= since && ds <= until;
3844 });
3845 } catch (_) {
3846 notesInMonth = [];
3847 }
3848
3849 const byDay = {};
3850 notesInMonth.forEach((n) => {
3851 const ds = noteSortOrCalendarDay(n);
3852 if (ds >= since && ds <= until) {
3853 byDay[ds] = (byDay[ds] || 0) + 1;
3854 }
3855 });
3856
3857 const startPad = first.getDay();
3858 const daysInMonth = last.getDate();
3859 const cells = [];
3860 const prevLast = new Date(y, m, 0).getDate();
3861 for (let i = 0; i < startPad; i++) {
3862 const d = prevLast - startPad + i + 1;
3863 cells.push({ out: true, day: d, key: null });
3864 }
3865 for (let d = 1; d <= daysInMonth; d++) {
3866 cells.push({ out: false, day: d, key: ymd(new Date(y, m, d)) });
3867 }
3868 let nextMonthDay = 1;
3869 while (cells.length % 7 !== 0 || cells.length < 42) {
3870 cells.push({ out: true, day: nextMonthDay++, key: null });
3871 }
3872
3873 const today = ymd(new Date());
3874 grid.innerHTML = cells
3875 .map((c) => {
3876 if (c.out) return '<div class="cal-cell out"><span class="cal-day-num">' + c.day + '</span></div>';
3877 const cnt = byDay[c.key] || 0;
3878 const isToday = c.key === today;
3879 return (
3880 '<div class="cal-cell' +
3881 (isToday ? ' today' : '') +
3882 '" data-day="' +
3883 escapeHtml(c.key) +
3884 '"><span class="cal-day-num">' +
3885 c.day +
3886 '</span>' +
3887 (cnt ? '<span class="cal-count">' + cnt + ' note' + (cnt > 1 ? 's' : '') + '</span>' : '') +
3888 '</div>'
3889 );
3890 })
3891 .join('');
3892
3893 grid.querySelectorAll('.cal-cell:not(.out)').forEach((cell) => {
3894 cell.onclick = () => showCalendarDay(cell.dataset.day, notesInMonth);
3895 });
3896 }
3897
3898 el('cal-prev').onclick = () => {
3899 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() - 1, 1);
3900 renderCalendar();
3901 };
3902 el('cal-next').onclick = () => {
3903 calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + 1, 1);
3904 renderCalendar();
3905 };
3906 el('cal-back').onclick = () => {
3907 el('calendar-day-list').classList.add('hidden');
3908 el('calendar-grid').classList.remove('hidden');
3909 el('calendar-nav').classList.remove('hidden');
3910 };
3911
3912 function showCalendarDay(dayKey, notesInMonth) {
3913 const matches = notesInMonth.filter((n) => noteSortOrCalendarDay(n) === dayKey);
3914 el('cal-day-title').textContent = dayKey + ' (' + matches.length + ' notes)';
3915 el('calendar-day-notes').innerHTML = matches.length ? matches.map(renderNoteRow).join('') : '<p class="muted">No notes</p>';
3916 bindNoteClicks(el('calendar-day-notes'));
3917 el('calendar-grid').classList.add('hidden');
3918 el('calendar-nav').classList.add('hidden');
3919 el('calendar-day-list').classList.remove('hidden');
3920 }
3921
3922 async function fetchNotesForDashboard() {
3923 const all = [];
3924 let offset = 0;
3925 const limit = 100;
3926 let total = Infinity;
3927 while (offset < 500 && all.length < total) {
3928 const out = await api('/api/v1/notes?limit=' + limit + '&offset=' + offset);
3929 total = out.total ?? 0;
3930 const batch = (out.notes || []).map(normalizeHubListItem);
3931 all.push(...batch);
3932 if (batch.length < limit) break;
3933 offset += limit;
3934 }
3935 return { notes: all, total };
3936 }
3937
3938 async function renderDashboard() {
3939 chartInstances.forEach((c) => c.destroy());
3940 chartInstances = [];
3941 const cards = el('dashboard-cards');
3942 const foot = el('dashboard-footnote');
3943 cards.innerHTML = loadingHtml;
3944 foot.textContent = '';
3945
3946 let notes, total;
3947 try {
3948 const r = await fetchNotesForDashboard();
3949 notes = r.notes;
3950 total = r.total;
3951 } catch (e) {
3952 cards.innerHTML = '<p class="muted">' + escapeHtml(e.message) + '</p>';
3953 return;
3954 }
3955
3956 const weekAgo = new Date();
3957 weekAgo.setDate(weekAgo.getDate() - 7);
3958 const weekStr = ymd(weekAgo);
3959 const thisWeek = notes.filter((n) => noteSortOrCalendarDay(n) >= weekStr).length;
3960
3961 const byProject = {};
3962 const byTag = {};
3963 const byWeek = {};
3964 notes.forEach((n) => {
3965 if (n.project) byProject[n.project] = (byProject[n.project] || 0) + 1;
3966 (n.tags || []).forEach((t) => {
3967 byTag[t] = (byTag[t] || 0) + 1;
3968 });
3969 const ds = noteSortOrCalendarDay(n);
3970 if (ds) {
3971 const w = ds.slice(0, 7);
3972 byWeek[w] = (byWeek[w] || 0) + 1;
3973 }
3974 });
3975
3976 const topProjects = Object.entries(byProject)
3977 .sort((a, b) => b[1] - a[1])
3978 .slice(0, 8);
3979 const topTags = Object.entries(byTag)
3980 .sort((a, b) => b[1] - a[1])
3981 .slice(0, 8);
3982 const weeks = Object.keys(byWeek).sort();
3983
3984 cards.innerHTML =
3985 '<div class="dash-card"><div class="dash-value">' +
3986 total +
3987 '</div><div class="dash-label">Notes (indexed)</div></div>' +
3988 '<div class="dash-card"><div class="dash-value">' +
3989 thisWeek +
3990 '</div><div class="dash-label">Last 7 days</div></div>' +
3991 '<div class="dash-card"><div class="dash-value">' +
3992 Object.keys(byProject).length +
3993 '</div><div class="dash-label">Projects</div></div>' +
3994 '<div class="dash-card"><div class="dash-value">' +
3995 Object.keys(byTag).length +
3996 '</div><div class="dash-label">Tags</div></div>';
3997
3998 if (notes.length < total) {
3999 foot.textContent = 'Charts use the first ' + notes.length + ' notes (of ' + total + '). Refine filters or paginate in API for full coverage.';
4000 }
4001
4002 if (typeof Chart === 'undefined') {
4003 foot.textContent += ' Chart.js failed to load.';
4004 return;
4005 }
4006
4007 const commonOpts = {
4008 responsive: true,
4009 maintainAspectRatio: false,
4010 plugins: { legend: { labels: { color: '#a1a1a1' } } },
4011 scales: {
4012 x: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4013 y: { ticks: { color: '#a1a1a1' }, grid: { color: '#2a3f5c' } },
4014 },
4015 };
4016
4017 const ctxP = el('chart-projects').getContext('2d');
4018 chartInstances.push(
4019 new Chart(ctxP, {
4020 type: 'bar',
4021 data: {
4022 labels: topProjects.map((x) => x[0]),
4023 datasets: [{ label: 'Notes', data: topProjects.map((x) => x[1]), backgroundColor: 'rgba(137, 207, 240, 0.5)', borderColor: '#89cff0' }],
4024 },
4025 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By project', color: '#ebebeb' } } },
4026 })
4027 );
4028
4029 const ctxT = el('chart-tags').getContext('2d');
4030 chartInstances.push(
4031 new Chart(ctxT, {
4032 type: 'doughnut',
4033 data: {
4034 labels: topTags.map((x) => x[0]),
4035 datasets: [{ data: topTags.map((x) => x[1]), backgroundColor: ['#89cff0', '#22c55e', '#a78bfa', '#f472b6', '#fb923c', '#6b9dc4', '#4ade80', '#c084fc'] }],
4036 },
4037 options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#a1a1a1' } }, title: { display: true, text: 'Top tags', color: '#ebebeb' } } },
4038 })
4039 );
4040
4041 const ctxL = el('chart-timeline').getContext('2d');
4042 chartInstances.push(
4043 new Chart(ctxL, {
4044 type: 'line',
4045 data: {
4046 labels: weeks,
4047 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 }],
4048 },
4049 options: { ...commonOpts, plugins: { ...commonOpts.plugins, title: { display: true, text: 'By month (note date)', color: '#ebebeb' } } },
4050 })
4051 );
4052 }
4053
4054 function resetDuplicateCreateState() {
4055 pendingDuplicateDeleteSource = null;
4056 const ban = el('duplicate-source-banner');
4057 if (ban) ban.classList.add('hidden');
4058 const chk = el('duplicate-delete-after-save');
4059 if (chk) chk.checked = false;
4060 const mt = el('modal-create-title');
4061 if (mt && mt.textContent === 'Duplicate note') mt.textContent = 'Add to vault';
4062 const fs = el('btn-full-save');
4063 if (fs && fs.textContent === 'Save duplicate') fs.textContent = 'Create note';
4064 }
4065
4066 function openCreateModal() {
4067 resetDuplicateCreateState();
4068 closeCreateProposalModal();
4069 closeFullCreateSimilarModal();
4070 hideDetailPanelChrome();
4071 el('modal-create').classList.remove('hidden');
4072 el('create-msg-quick').textContent = '';
4073 el('create-msg-quick').className = 'create-msg';
4074 el('create-msg-full').textContent = '';
4075 el('create-msg-full').className = 'create-msg';
4076 fullCreateSimilarOverrideOnce = false;
4077 if (token) {
4078 void (async () => {
4079 await refreshFullPathFolderSelect();
4080 if (!lastHubFacets) {
4081 try {
4082 lastHubFacets = await fetchFacetsResolved();
4083 } catch (_) {}
4084 }
4085 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4086 })();
4087 }
4088 }
4089
4090 /** Suggested path for a duplicate (`note.md` → `note-copy.md`). */
4091 function suggestDuplicateVaultPath(srcPath) {
4092 const t = String(srcPath || '')
4093 .replace(/\\/g, '/')
4094 .trim();
4095 if (!t) return 'inbox/duplicate-' + Date.now() + '.md';
4096 if (/\.md$/i.test(t)) return t.replace(/\.md$/i, '-copy.md');
4097 return (t.replace(/\/$/, '') || 'inbox') + '-copy.md';
4098 }
4099
4100 function tagsInputFromFrontmatter(tagsVal) {
4101 if (tagsVal == null) return '';
4102 if (Array.isArray(tagsVal)) return tagsVal.map((x) => String(x).trim()).filter(Boolean).join(', ');
4103 return String(tagsVal).trim();
4104 }
4105
4106 /**
4107 * Open Add to vault → New note (full) prefilled from the open note, for same-vault duplicate.
4108 * Optional checkbox deletes the source path after a successful save (different path only).
4109 */
4110 async function openDuplicateNoteModal() {
4111 if (!currentOpenNote || !hubUserCanWriteNotes()) return;
4112 if (!token) {
4113 if (typeof showToast === 'function') showToast('Sign in to duplicate notes.', true);
4114 return;
4115 }
4116 pendingDuplicateDeleteSource = { path: currentOpenNote.path };
4117 closeCreateProposalModal();
4118 closeFullCreateSimilarModal();
4119 el('modal-create').classList.remove('hidden');
4120 el('create-msg-quick').textContent = '';
4121 el('create-msg-quick').className = 'create-msg';
4122 el('create-msg-full').textContent = '';
4123 el('create-msg-full').className = 'create-msg';
4124 fullCreateSimilarOverrideOnce = false;
4125 const mt = el('modal-create-title');
4126 if (mt) mt.textContent = 'Duplicate note';
4127 const fs = el('btn-full-save');
4128 if (fs) fs.textContent = 'Save duplicate';
4129 document.querySelectorAll('#modal-create .modal-tab').forEach((x) => x.classList.remove('active'));
4130 const tabFull = document.querySelector('#modal-create .modal-tab[data-create-tab="full"]');
4131 const tabQuick = document.querySelector('#modal-create .modal-tab[data-create-tab="quick"]');
4132 if (tabFull) tabFull.classList.add('active');
4133 if (tabQuick) tabQuick.classList.remove('active');
4134 el('create-quick').classList.add('hidden');
4135 el('create-full').classList.remove('hidden');
4136 if (token) {
4137 try {
4138 await refreshFullPathFolderSelect();
4139 if (!lastHubFacets) {
4140 try {
4141 lastHubFacets = await fetchFacetsResolved();
4142 } catch (_) {}
4143 }
4144 hydrateFullCreateProjectSlugSelect(lastHubFacets);
4145 } catch (_) {}
4146 }
4147 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
4148 if (el('full-body')) el('full-body').value = currentOpenNote.body || '';
4149 if (el('full-title')) el('full-title').value = fm.title != null ? String(fm.title) : '';
4150 if (el('full-tags')) el('full-tags').value = tagsInputFromFrontmatter(fm.tags);
4151 if (el('full-date')) el('full-date').value = fm.date != null ? String(fm.date).slice(0, 10) : ymd(new Date());
4152 if (el('full-causal-chain')) el('full-causal-chain').value = fm.causal_chain_id != null ? String(fm.causal_chain_id) : '';
4153 if (el('full-entity')) {
4154 const ent = fm.entity;
4155 el('full-entity').value = Array.isArray(ent) ? ent.join(', ') : ent != null ? String(ent) : '';
4156 }
4157 if (el('full-episode')) el('full-episode').value = fm.episode_id != null ? String(fm.episode_id) : '';
4158 if (el('full-follows')) el('full-follows').value = fm.follows != null ? String(fm.follows) : '';
4159 const sug = suggestDuplicateVaultPath(currentOpenNote.path);
4160 if (el('full-path')) {
4161 el('full-path').value = sug;
4162 if (typeof syncFolderSelectToPathInput === 'function') syncFolderSelectToPathInput();
4163 if (typeof syncFullCreatePickersFromPath === 'function') syncFullCreatePickersFromPath();
4164 if (typeof syncFullProjectFromPath === 'function') syncFullProjectFromPath();
4165 if (typeof updateFullPathProjectTypoHint === 'function') updateFullPathProjectTypoHint();
4166 if (typeof updateFullCreateSimilarInlineHint === 'function') updateFullCreateSimilarInlineHint();
4167 }
4168 const dsp = el('duplicate-source-path');
4169 if (dsp) dsp.textContent = currentOpenNote.path;
4170 const ban = el('duplicate-source-banner');
4171 if (ban) ban.classList.remove('hidden');
4172 const chk = el('duplicate-delete-after-save');
4173 if (chk) chk.checked = false;
4174 }
4175
4176 function closeCreateModal() {
4177 closeFullCreateSimilarModal();
4178 resetDuplicateCreateState();
4179 el('modal-create').classList.add('hidden');
4180 }
4181 function closeCreateProposalModal() {
4182 const m = el('modal-create-proposal');
4183 if (m) m.classList.add('hidden');
4184 const pathInput = el('proposal-create-path');
4185 if (pathInput) pathInput.readOnly = false;
4186 }
4187 /** @param {{ path?: string, body?: string, intent?: string, fromNote?: boolean }} [opts] */
4188 function openCreateProposalModal(opts) {
4189 if (!token) {
4190 if (typeof showToast === 'function') showToast('Sign in to create a proposal.', true);
4191 return;
4192 }
4193 if (!hubUserCanWriteNotes()) {
4194 if (typeof showToast === 'function') showToast('Your role cannot create proposals.', true);
4195 return;
4196 }
4197 closeCreateModal();
4198 closeImportModal();
4199 hideDetailPanelChrome();
4200 const modal = el('modal-create-proposal');
4201 const pathInput = el('proposal-create-path');
4202 const hint = el('modal-create-proposal-hint');
4203 const bodyEl = el('proposal-create-body');
4204 const intentEl = el('proposal-create-intent');
4205 const msgEl = el('proposal-create-msg');
4206 if (!modal || !pathInput || !bodyEl || !intentEl) return;
4207 if (opts && opts.fromNote) {
4208 pathInput.readOnly = true;
4209 pathInput.value = opts.path || '';
4210 if (hint)
4211 hint.textContent =
4212 'You are proposing a new version of this note. Edit the body below; the path matches the open note.';
4213 } else {
4214 pathInput.readOnly = false;
4215 pathInput.value = (opts && opts.path) || '';
4216 if (hint)
4217 hint.textContent =
4218 'Submit a proposed file change for review (same as POST /api/v1/proposals). An admin approves in Review.';
4219 }
4220 bodyEl.value = (opts && opts.body) || '';
4221 intentEl.value = (opts && opts.intent) || '';
4222 if (msgEl) {
4223 msgEl.textContent = '';
4224 msgEl.className = 'create-msg';
4225 }
4226 modal.classList.remove('hidden');
4227 }
4228 btnNewNote.onclick = openCreateModal;
4229 el('modal-create-backdrop').onclick = closeCreateModal;
4230 el('modal-create-close').onclick = closeCreateModal;
4231
4232 const modalCreateProposalBackdrop = el('modal-create-proposal-backdrop');
4233 const modalCreateProposalClose = el('modal-create-proposal-close');
4234 if (modalCreateProposalBackdrop) modalCreateProposalBackdrop.onclick = closeCreateProposalModal;
4235 if (modalCreateProposalClose) modalCreateProposalClose.onclick = closeCreateProposalModal;
4236
4237 const btnNewProposal = el('btn-new-proposal');
4238 if (btnNewProposal) {
4239 btnNewProposal.onclick = () => openCreateProposalModal({});
4240 }
4241
4242 const btnProposalCreateSubmit = el('btn-proposal-create-submit');
4243 if (btnProposalCreateSubmit) {
4244 btnProposalCreateSubmit.onclick = async () => {
4245 const pathInput = el('proposal-create-path');
4246 const bodyInput = el('proposal-create-body');
4247 const intentInput = el('proposal-create-intent');
4248 const msgEl = el('proposal-create-msg');
4249 const rawPath = pathInput && pathInput.value != null ? String(pathInput.value).trim() : '';
4250 if (!rawPath) {
4251 if (msgEl) {
4252 msgEl.textContent = 'Path is required.';
4253 msgEl.className = 'create-msg err';
4254 }
4255 return;
4256 }
4257 const body = bodyInput && bodyInput.value != null ? String(bodyInput.value) : '';
4258 const intent = intentInput && intentInput.value != null ? String(intentInput.value).trim() : '';
4259 await withButtonBusy(btnProposalCreateSubmit, 'Submitting…', async () => {
4260 try {
4261 await api('/api/v1/proposals', {
4262 method: 'POST',
4263 body: JSON.stringify({
4264 path: rawPath,
4265 body,
4266 ...(intent ? { intent } : {}),
4267 source: 'hub_ui',
4268 }),
4269 });
4270 closeCreateProposalModal();
4271 if (typeof showToast === 'function') showToast('Proposal submitted');
4272 document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
4273 document.querySelectorAll('.tab-panel').forEach((p) => p.classList.add('hidden'));
4274 const suggestedTab = document.querySelector('[data-tab="suggested"]');
4275 const suggestedPanel = el('tab-suggested');
4276 if (suggestedTab) suggestedTab.classList.add('active');
4277 if (suggestedPanel) suggestedPanel.classList.remove('hidden');
4278 syncHubListSortUI('suggested');
4279 syncModeToolbars('suggested');
4280 refreshNewProposalTabVisibility();
4281 loadProposals();
4282 } catch (e) {
4283 if (msgEl) {
4284 msgEl.textContent = e.message || 'Proposal failed';
4285 msgEl.className = 'create-msg err';
4286 }
4287 }
4288 });
4289 };
4290 }
4291
4292 function syncImportSheetsBlock() {
4293 const sel = el('import-source-type');
4294 const block = el('import-sheets-block');
4295 if (block && sel) block.hidden = sel.value !== 'google-sheets';
4296 }
4297
4298 function openImportModal(preselectSourceType) {
4299 if (!token) {
4300 if (typeof showToast === 'function') showToast('Sign in to import into your vault.', true);
4301 return;
4302 }
4303 closeCreateModal();
4304 closeCreateProposalModal();
4305 hideDetailPanelChrome();
4306 el('modal-import').classList.remove('hidden');
4307 el('import-msg').textContent = '';
4308 if (importFileEl) importFileEl.value = '';
4309 if (importFileFolderEl) importFileFolderEl.value = '';
4310 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4311 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4312 setImportBatchAria('');
4313 clearImportDropPending();
4314 const urlIn = el('import-url');
4315 if (urlIn) urlIn.value = '';
4316 const sid = el('import-spreadsheet-id');
4317 const srange = el('import-sheets-range');
4318 if (sid) sid.value = '';
4319 if (srange) srange.value = '';
4320 const importSel = el('import-source-type');
4321 if (importSel && preselectSourceType) {
4322 const hasOption = Array.from(importSel.options).some((o) => o.value === preselectSourceType);
4323 if (hasOption) importSel.value = preselectSourceType;
4324 }
4325 syncImportSheetsBlock();
4326 const outDirEl = el('import-output-dir');
4327 if (outDirEl) outDirEl.value = '';
4328 void (async () => {
4329 await refreshImportVaultFolderSelect();
4330 if (!lastHubFacets) {
4331 try {
4332 lastHubFacets = await fetchFacetsResolved();
4333 } catch (_) {}
4334 }
4335 hydrateImportCreateProjectSlugSelect(lastHubFacets);
4336 const out = el('import-output-dir');
4337 if (out) out.value = defaultImportOutputDir();
4338 syncImportFolderSelectToOutputDir();
4339 syncImportPickersFromOutputDir();
4340 updateImportPathLayoutVisibility();
4341 })();
4342 }
4343 function closeImportModal() {
4344 el('modal-import').classList.add('hidden');
4345 clearImportDropPending();
4346 }
4347 if (btnImport) btnImport.onclick = openImportModal;
4348 el('modal-import-backdrop').onclick = closeImportModal;
4349 el('modal-import-close').onclick = closeImportModal;
4350 const importSourceTypeEl = el('import-source-type');
4351 if (importSourceTypeEl) importSourceTypeEl.addEventListener('change', syncImportSheetsBlock);
4352
4353 function closeProjectsHelpModal() {
4354 const m = el('modal-projects-help');
4355 if (m) m.classList.add('hidden');
4356 }
4357 function openProjectsHelpModal() {
4358 closeCreateModal();
4359 closeCreateProposalModal();
4360 hideDetailPanelChrome();
4361 const m = el('modal-projects-help');
4362 if (m) m.classList.remove('hidden');
4363 }
4364 const btnProjectsHelp = el('btn-projects-help');
4365 if (btnProjectsHelp) btnProjectsHelp.onclick = openProjectsHelpModal;
4366 const btnFullProjectHelp = el('btn-full-project-help');
4367 if (btnFullProjectHelp) {
4368 btnFullProjectHelp.onclick = () => {
4369 const m = el('modal-projects-help');
4370 if (m) m.classList.remove('hidden');
4371 };
4372 }
4373 const modalProjectsHelpBackdrop = el('modal-projects-help-backdrop');
4374 const modalProjectsHelpClose = el('modal-projects-help-close');
4375 if (modalProjectsHelpBackdrop) modalProjectsHelpBackdrop.onclick = closeProjectsHelpModal;
4376 if (modalProjectsHelpClose) modalProjectsHelpClose.onclick = closeProjectsHelpModal;
4377
4378 if (btnImportChooseFolder && importFileFolderEl) {
4379 btnImportChooseFolder.onclick = () => {
4380 importFileFolderEl.click();
4381 };
4382 }
4383 if (importFileFolderEl) {
4384 importFileFolderEl.addEventListener('change', () => {
4385 if (importFileFolderEl.files && importFileFolderEl.files.length) {
4386 clearImportDropPending();
4387 if (importFileEl) importFileEl.value = '';
4388 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4389 }
4390 });
4391 }
4392 if (importFileEl) {
4393 importFileEl.addEventListener('change', () => {
4394 clearImportDropPending();
4395 if (importFileFolderEl) importFileFolderEl.value = '';
4396 if (importFolderHintEl) importFolderHintEl.classList.add('hidden');
4397 });
4398 }
4399 if (importDropZoneEl) {
4400 let dragOverCount = 0;
4401 const setOver = (on) => {
4402 if (on) importDropZoneEl.classList.add('import-drop-zone--over');
4403 else importDropZoneEl.classList.remove('import-drop-zone--over');
4404 };
4405 importDropZoneEl.addEventListener('dragenter', (e) => {
4406 e.preventDefault();
4407 dragOverCount += 1;
4408 setOver(true);
4409 });
4410 importDropZoneEl.addEventListener('dragleave', (e) => {
4411 e.preventDefault();
4412 dragOverCount = Math.max(0, dragOverCount - 1);
4413 if (dragOverCount === 0) setOver(false);
4414 });
4415 importDropZoneEl.addEventListener('dragover', (e) => {
4416 e.preventDefault();
4417 e.stopPropagation();
4418 if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
4419 });
4420 importDropZoneEl.addEventListener('drop', (e) => {
4421 e.preventDefault();
4422 e.stopPropagation();
4423 dragOverCount = 0;
4424 setOver(false);
4425 const msgEl = el('import-msg');
4426 const p = (async () => {
4427 if (!e.dataTransfer) {
4428 if (msgEl) {
4429 msgEl.textContent = 'Drop did not include any files.';
4430 msgEl.className = 'create-msg err';
4431 }
4432 return;
4433 }
4434 let files;
4435 try {
4436 files = await collectFilesFromDataTransfer(e.dataTransfer);
4437 } catch (dropErr) {
4438 if (msgEl) {
4439 msgEl.textContent =
4440 dropErr && dropErr.message ? 'Could not read drop: ' + String(dropErr.message) : 'Could not read drop.';
4441 msgEl.className = 'create-msg err';
4442 }
4443 return;
4444 }
4445 if (!files || files.length === 0) {
4446 if (msgEl) {
4447 msgEl.textContent = 'No files in that drop. Try a folder of files, or the file picker below.';
4448 msgEl.className = 'create-msg err';
4449 }
4450 return;
4451 }
4452 importPendingDropFiles = files;
4453 if (importFileEl) importFileEl.value = '';
4454 if (importFileFolderEl) importFileFolderEl.value = '';
4455 if (importFolderHintEl) importFolderHintEl.classList.remove('hidden');
4456 updateImportDropStatusUi();
4457 if (msgEl) {
4458 msgEl.textContent = 'Ready: ' + files.length + ' file(s) from drop. Choose source type, then click Import.';
4459 msgEl.className = 'create-msg';
4460 }
4461 })();
4462 p.catch((err) => {
4463 if (el('import-msg')) {
4464 const msg = el('import-msg');
4465 msg.textContent = err && err.message ? String(err.message) : 'Import drop failed';
4466 msg.className = 'create-msg err';
4467 }
4468 });
4469 });
4470 }
4471 if (importBatchCancelBtn) {
4472 importBatchCancelBtn.onclick = () => {
4473 if (importBatchAbort) importBatchAbort.abort();
4474 };
4475 }
4476
4477 /**
4478 * @param {string} postPath
4479 * @param {FormData} formData
4480 * @param {Record<string, string>} importHeaders
4481 * @returns {Promise<{ ok: boolean, data?: object, errText?: string, status?: number }>}
4482 */
4483 async function hubPostImportOnce(postPath, formData, importHeaders) {
4484 let res;
4485 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4486 try {
4487 res = await fetch(postPath, {
4488 method: 'POST',
4489 cache: 'no-store',
4490 headers: importHeaders,
4491 body: formData,
4492 });
4493 break;
4494 } catch (importErr) {
4495 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4496 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4497 await new Promise((r) => setTimeout(r, 3000));
4498 continue;
4499 }
4500 return { ok: false, errText: em, status: 0 };
4501 }
4502 }
4503 const text = await res.text();
4504 let data = {};
4505 try {
4506 data = text ? JSON.parse(text) : {};
4507 } catch (_) {
4508 data = {};
4509 }
4510 if (!res.ok) {
4511 let apiErr = '';
4512 if (data && typeof data === 'object') {
4513 const parts = [data.error, data.message, data.detail].filter(
4514 (x) => x != null && String(x).trim().length > 0,
4515 );
4516 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4517 }
4518 if (!apiErr && text) {
4519 const t = text.trim();
4520 if (t.startsWith('<')) {
4521 apiErr = `HTTP ${res.status}: server returned an HTML error page (check gateway/bridge Netlify logs).`;
4522 } else {
4523 apiErr = t.slice(0, 280);
4524 }
4525 }
4526 return { ok: false, errText: apiErr || `Import failed (HTTP ${res.status})`, status: res.status, data };
4527 }
4528 return { ok: true, data };
4529 }
4530
4531 el('btn-import-submit').onclick = async () => {
4532 const importSubmitBtn = el('btn-import-submit');
4533 const sourceType = el('import-source-type').value;
4534 const fileInput = el('import-file');
4535 const urlInput = el('import-url');
4536 const urlTrim = urlInput && urlInput.value ? String(urlInput.value).trim() : '';
4537 const msgEl = el('import-msg');
4538 /** @type {{ getHubImportFileMode: (a: string, f: File[]) => string, buildImportZipBlob: (f: File[], o: object) => Promise<Blob>, assertSingleFileWithinLimit: (f: File) => void } | null | undefined} */
4539 const kz = globalThis.knowtationHubImportZip;
4540
4541 if (!token) {
4542 msgEl.textContent = 'Sign in to import.';
4543 msgEl.className = 'create-msg err';
4544 return;
4545 }
4546 const useUrlImport = urlTrim.length > 0;
4547 if (sourceType === 'url' && !useUrlImport) {
4548 msgEl.textContent = 'Enter an https URL above, or pick another source type and upload a file.';
4549 msgEl.className = 'create-msg err';
4550 return;
4551 }
4552 const importSpreadsheetIdEl = el('import-spreadsheet-id');
4553 const sheetId = importSpreadsheetIdEl && importSpreadsheetIdEl.value ? String(importSpreadsheetIdEl.value).trim() : '';
4554 const usedFolder = importFileFolderEl && importFileFolderEl.files && importFileFolderEl.files.length > 0;
4555 const usedDrop = importPendingDropFiles && importPendingDropFiles.length > 0;
4556 const fileArr = usedDrop
4557 ? importPendingDropFiles
4558 : usedFolder
4559 ? Array.from(importFileFolderEl.files)
4560 : fileInput && fileInput.files
4561 ? Array.from(fileInput.files)
4562 : [];
4563 if (sourceType === 'google-sheets' && !useUrlImport) {
4564 if (!sheetId) {
4565 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL) for this source type.';
4566 msgEl.className = 'create-msg err';
4567 return;
4568 }
4569 if (fileArr.length > 0) {
4570 msgEl.textContent = 'Remove file selection for Google Sheets, or change source type. This import uses the API only (no file upload).';
4571 msgEl.className = 'create-msg err';
4572 return;
4573 }
4574 }
4575 if (!useUrlImport && fileArr.length === 0 && sourceType !== 'google-sheets') {
4576 msgEl.textContent = 'Choose file(s) or a folder to import, or paste an https URL above.';
4577 msgEl.className = 'create-msg err';
4578 return;
4579 }
4580 if (sourceType === 'notion' && fileArr.length > 1) {
4581 msgEl.textContent = 'Notion: use a single file or the CLI. Page IDs in one text file, or one import at a time.';
4582 msgEl.className = 'create-msg err';
4583 return;
4584 }
4585
4586 const dest = getImportProjectAndOutputDir();
4587 if (dest.err) {
4588 msgEl.textContent = dest.err;
4589 msgEl.className = 'create-msg err';
4590 return;
4591 }
4592 const project = dest.project || '';
4593 const outputDir = dest.outputDir;
4594 const tags = (el('import-tags') && el('import-tags').value) ? el('import-tags').value.trim() : '';
4595 const urlModeEl = el('import-url-mode');
4596 const urlMode = urlModeEl && urlModeEl.value ? urlModeEl.value : 'auto';
4597 const importPostPath = apiBase + '/api/v1/import';
4598 const urlPostPath = apiBase + '/api/v1/import-url';
4599 const mode =
4600 !useUrlImport && kz && typeof kz.getHubImportFileMode === 'function'
4601 ? kz.getHubImportFileMode(sourceType, fileArr)
4602 : 'direct';
4603
4604 if (!useUrlImport && !kz && fileArr.length > 1) {
4605 msgEl.textContent =
4606 '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.';
4607 msgEl.className = 'create-msg err';
4608 return;
4609 }
4610
4611 if (!useUrlImport && mode === 'client_zip' && !kz) {
4612 msgEl.textContent =
4613 '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.';
4614 msgEl.className = 'create-msg err';
4615 return;
4616 }
4617 if (!useUrlImport && mode === 'sequential' && fileArr.length > HUB_IMPORT_MAX_SEQUENTIAL) {
4618 msgEl.textContent =
4619 'Too many files for one batch (max ' +
4620 HUB_IMPORT_MAX_SEQUENTIAL +
4621 '). Split the batch, use the CLI, or use one in-browser folder ZIP (Phase 4A₂) for tree-shaped source types.';
4622 msgEl.className = 'create-msg err';
4623 return;
4624 }
4625
4626 if (useUrlImport) {
4627 const jsonBody = { url: urlTrim, mode: urlMode };
4628 if (project) jsonBody.project = project;
4629 if (outputDir) jsonBody.output_dir = outputDir;
4630 if (tags) jsonBody.tags = tags;
4631 msgEl.textContent = 'Importing…';
4632 msgEl.className = 'create-msg';
4633 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4634 try {
4635 const importHeaders = token ? { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' } : {};
4636 const importVaultId = getCurrentVaultId();
4637 if (importVaultId) importHeaders['X-Vault-Id'] = importVaultId;
4638 let res;
4639 for (let importAttempt = 0; importAttempt < 2; importAttempt++) {
4640 try {
4641 res = await fetch(urlPostPath, {
4642 method: 'POST',
4643 cache: 'no-store',
4644 headers: importHeaders,
4645 body: JSON.stringify(jsonBody),
4646 });
4647 break;
4648 } catch (importErr) {
4649 const em = importErr && importErr.message ? String(importErr.message) : String(importErr);
4650 if (importAttempt === 0 && (em === 'Failed to fetch' || em.includes('NetworkError'))) {
4651 await new Promise((r) => setTimeout(r, 3000));
4652 continue;
4653 }
4654 throw importErr;
4655 }
4656 }
4657 const text = await res.text();
4658 let data = {};
4659 try {
4660 data = text ? JSON.parse(text) : {};
4661 } catch (_) {
4662 data = {};
4663 }
4664 if (!res.ok) {
4665 let apiErr = '';
4666 if (data && typeof data === 'object') {
4667 const parts = [data.error, data.message, data.detail].filter(
4668 (x) => x != null && String(x).trim().length > 0,
4669 );
4670 apiErr = [...new Set(parts.map((x) => String(x).trim()))].join(' — ');
4671 }
4672 if (!apiErr && text) {
4673 const t = text.trim();
4674 if (t.startsWith('<')) {
4675 apiErr = `HTTP ${res.status}: server returned an HTML error page.`;
4676 } else {
4677 apiErr = t.slice(0, 280);
4678 }
4679 }
4680 msgEl.textContent = apiErr || (res.status ? `Import failed (HTTP ${res.status})` : '') || 'Import failed';
4681 msgEl.className = 'create-msg err';
4682 return;
4683 }
4684 const count = data.count ?? data.imported?.length ?? 0;
4685 if (count === 0) {
4686 msgEl.textContent = 'Imported 0 notes from URL. Try Bookmark mode or a different link.';
4687 msgEl.className = 'create-msg warn';
4688 } else {
4689 msgEl.textContent = 'Imported ' + count + ' note(s).';
4690 msgEl.className = 'create-msg ok';
4691 }
4692 if (count > 0) hubMarkSemanticIndexStale();
4693 if (typeof loadNotes === 'function') loadNotes();
4694 if (typeof loadFacets === 'function') loadFacets();
4695 if (typeof showToast === 'function') showToast('Import complete');
4696 setTimeout(() => closeImportModal(), 1500);
4697 } catch (e) {
4698 const raw = e && e.message ? String(e.message) : 'Import failed';
4699 const isNetwork =
4700 raw === 'Failed to fetch' ||
4701 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4702 msgEl.textContent = isNetwork
4703 ? raw +
4704 ' — Often: CORS, upload too large for the gateway, or timeout. On hosted, check DevTools → Network for POST /api/v1/import-url.'
4705 : raw;
4706 msgEl.className = 'create-msg err';
4707 }
4708 });
4709 return;
4710 }
4711
4712 const importHeadersBase = token ? { Authorization: 'Bearer ' + token } : {};
4713 const importVaultId = getCurrentVaultId();
4714 if (importVaultId) importHeadersBase['X-Vault-Id'] = importVaultId;
4715
4716 if (mode === 'sequential') {
4717 if (importBatchCancelBtn) importBatchCancelBtn.classList.remove('hidden');
4718 importBatchAbort = new AbortController();
4719 msgEl.textContent = 'Importing ' + fileArr.length + ' file(s)…';
4720 msgEl.className = 'create-msg';
4721 setImportBatchAria('Starting batch import, 0 of ' + fileArr.length);
4722 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4723 const failures = [];
4724 let totalImported = 0;
4725 let okN = 0;
4726 for (let i = 0; i < fileArr.length; i++) {
4727 if (importBatchAbort && importBatchAbort.signal.aborted) {
4728 setImportBatchAria('Batch import stopped by user after ' + okN + ' of ' + fileArr.length);
4729 break;
4730 }
4731 const f = fileArr[i];
4732 try {
4733 if (kz && kz.assertSingleFileWithinLimit) kz.assertSingleFileWithinLimit(f);
4734 } catch (limErr) {
4735 failures.push({ name: f.name, err: limErr && limErr.message ? String(limErr.message) : String(limErr) });
4736 continue;
4737 }
4738 setImportBatchAria('Importing file ' + (i + 1) + ' of ' + fileArr.length + ': ' + f.name);
4739 const fd = new FormData();
4740 fd.append('source_type', sourceType);
4741 fd.append('file', f);
4742 if (project) fd.append('project', project);
4743 if (outputDir) fd.append('output_dir', outputDir);
4744 if (tags) fd.append('tags', tags);
4745 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4746 if (r.ok && r.data) {
4747 const c = r.data.count ?? r.data.imported?.length ?? 0;
4748 totalImported += typeof c === 'number' ? c : 0;
4749 okN++;
4750 } else {
4751 failures.push({ name: f.name, err: r.errText || 'error' });
4752 }
4753 }
4754 if (importBatchCancelBtn) importBatchCancelBtn.classList.add('hidden');
4755 importBatchAbort = null;
4756 const fl = failures.length
4757 ? ' Failures: ' + failures.map((x) => x.name + (x.err ? ' — ' + x.err.slice(0, 120) : '')).join('; ') + '.'
4758 : '.';
4759 msgEl.textContent =
4760 'Batch: ' + okN + ' of ' + fileArr.length + ' file import(s) succeeded' + (totalImported ? ' (' + totalImported + ' note(s) reported).' : '.') + fl;
4761 msgEl.className = 'create-msg ' + (failures.length && okN === 0 ? 'err' : failures.length ? 'warn' : 'ok');
4762 setImportBatchAria(msgEl.textContent);
4763 if (totalImported > 0) hubMarkSemanticIndexStale();
4764 if (typeof loadNotes === 'function') loadNotes();
4765 if (typeof loadFacets === 'function') loadFacets();
4766 if (okN > 0 && typeof showToast === 'function') showToast('Import complete');
4767 if (okN > 0) setTimeout(() => closeImportModal(), 2000);
4768 });
4769 return;
4770 }
4771
4772 msgEl.textContent = 'Importing…';
4773 msgEl.className = 'create-msg';
4774 await withButtonBusy(importSubmitBtn, 'Importing…', async () => {
4775 try {
4776 if (sourceType === 'google-sheets') {
4777 const sid = el('import-spreadsheet-id') && el('import-spreadsheet-id').value
4778 ? el('import-spreadsheet-id').value.trim()
4779 : '';
4780 if (!sid) {
4781 msgEl.textContent = 'Enter the spreadsheet id (from the Google Sheet URL).';
4782 msgEl.className = 'create-msg err';
4783 return;
4784 }
4785 const rEl = el('import-sheets-range');
4786 const range = rEl && rEl.value ? rEl.value.trim() : '';
4787 const fd = new FormData();
4788 fd.append('source_type', 'google-sheets');
4789 fd.append('spreadsheet_id', sid);
4790 if (range) fd.append('sheets_range', range);
4791 if (project) fd.append('project', project);
4792 if (outputDir) fd.append('output_dir', outputDir);
4793 if (tags) fd.append('tags', tags);
4794 const r = await hubPostImportOnce(importPostPath, fd, { ...importHeadersBase });
4795 if (!r.ok) {
4796 msgEl.textContent = r.errText || 'Import failed';
4797 msgEl.className = 'create-msg err';
4798 return;
4799 }
4800 const data = r.data || {};
4801 const count = data.count ?? data.imported?.length ?? 0;
4802 if (count === 0) {
4803 msgEl.textContent =
4804 'Imported 0 notes. Check spreadsheet id, sharing with the bridge service account, and optional range. See IMPORT-SOURCES.';
4805 msgEl.className = 'create-msg warn';
4806 } else {
4807 msgEl.textContent = 'Imported ' + count + ' note(s).';
4808 msgEl.className = 'create-msg ok';
4809 }
4810 if (count > 0) hubMarkSemanticIndexStale();
4811 if (typeof loadNotes === 'function') loadNotes();
4812 if (typeof loadFacets === 'function') loadFacets();
4813 if (typeof showToast === 'function') showToast('Import complete');
4814 setTimeout(() => closeImportModal(), 1500);
4815 return;
4816 }
4817 const dupWarn = [];
4818 const warnFn = (s) => {
4819 dupWarn.push(s);
4820 };
4821 /** @type {FormData} */
4822 let formData;
4823 if (mode === 'client_zip' && kz) {
4824 const blob = await kz.buildImportZipBlob(fileArr, {
4825 signal: null,
4826 warn: warnFn,
4827 });
4828 const fileOut = new File([blob], 'hub-bulk.zip', { type: 'application/zip' });
4829 formData = new FormData();
4830 formData.append('source_type', sourceType);
4831 formData.append('file', fileOut);
4832 if (project) formData.append('project', project);
4833 if (outputDir) formData.append('output_dir', outputDir);
4834 if (tags) formData.append('tags', tags);
4835 if (dupWarn.length) {
4836 msgEl.className = 'create-msg';
4837 msgEl.textContent = dupWarn.join(' ') + ' Zipping, then uploading…';
4838 }
4839 } else {
4840 if (fileArr[0] && kz && kz.assertSingleFileWithinLimit) {
4841 try {
4842 kz.assertSingleFileWithinLimit(fileArr[0]);
4843 } catch (e1) {
4844 msgEl.textContent = e1 && e1.message ? String(e1.message) : String(e1);
4845 msgEl.className = 'create-msg err';
4846 return;
4847 }
4848 }
4849 formData = new FormData();
4850 formData.append('source_type', sourceType);
4851 formData.append('file', fileArr[0]);
4852 if (project) formData.append('project', project);
4853 if (outputDir) formData.append('output_dir', outputDir);
4854 if (tags) formData.append('tags', tags);
4855 }
4856 const r = await hubPostImportOnce(importPostPath, formData, { ...importHeadersBase });
4857 if (!r.ok) {
4858 msgEl.textContent = r.errText || 'Import failed';
4859 msgEl.className = 'create-msg err';
4860 return;
4861 }
4862 const data = r.data || {};
4863 const count = data.count ?? data.imported?.length ?? 0;
4864 let extra = '';
4865 if (mode === 'client_zip' && dupWarn.length) extra = ' ' + dupWarn.join(' ');
4866 if (count === 0) {
4867 const zeroMsg =
4868 sourceType === 'markdown'
4869 ? '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).'
4870 : sourceType === 'pdf'
4871 ? 'Imported 0 notes. PDF import could not produce a note (wrong file type, corrupt file, or no extractable text—try OCR for scans).'
4872 : sourceType === 'docx'
4873 ? 'Imported 0 notes. DOCX import could not produce a note (wrong file type, corrupt file, empty document, or not Office Open XML .docx).'
4874 : 'Imported 0 notes. Check that the file matches the selected source type (e.g. ChatGPT export needs chatgpt-export).';
4875 msgEl.textContent = zeroMsg + extra;
4876 msgEl.className = 'create-msg warn';
4877 } else {
4878 msgEl.textContent = 'Imported ' + count + ' note(s).' + extra;
4879 msgEl.className = 'create-msg ok';
4880 }
4881 if (count > 0) hubMarkSemanticIndexStale();
4882 if (typeof loadNotes === 'function') loadNotes();
4883 if (typeof loadFacets === 'function') loadFacets();
4884 if (typeof showToast === 'function') showToast('Import complete');
4885 setTimeout(() => closeImportModal(), 1500);
4886 } catch (e) {
4887 const raw = e && e.message ? String(e.message) : 'Import failed';
4888 if (e && e.name === 'AbortError') {
4889 msgEl.textContent = 'Cancelled.';
4890 } else {
4891 const isNetwork =
4892 raw === 'Failed to fetch' ||
4893 (e && e.name === 'TypeError' && /fetch|network|load failed/i.test(raw));
4894 msgEl.textContent = isNetwork
4895 ? raw +
4896 ' — 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.'
4897 : raw;
4898 }
4899 msgEl.className = 'create-msg err';
4900 }
4901 });
4902 };
4903
4904 function openHowToUse(tabId, scrollToId) {
4905 const id = tabId || 'setup';
4906 el('modal-how-to-use').classList.remove('hidden');
4907 document.querySelectorAll('.how-to-tab').forEach((t) => t.classList.toggle('active', t.dataset.howToTab === id));
4908 document.querySelectorAll('.how-to-tab').forEach((t) => t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false'));
4909 document.querySelectorAll('.how-to-panel').forEach((p) => p.classList.toggle('active', p.id === 'how-to-panel-' + id));
4910 if (scrollToId) {
4911 requestAnimationFrame(() => {
4912 const target = document.getElementById(scrollToId);
4913 if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4914 });
4915 }
4916 }
4917 function closeHowToUse() {
4918 el('modal-how-to-use').classList.add('hidden');
4919 }
4920 if (btnHowToUse) btnHowToUse.onclick = () => openHowToUse();
4921 const btnLoginHowToUse = el('btn-login-how-to-use');
4922 if (btnLoginHowToUse) btnLoginHowToUse.onclick = () => openHowToUse();
4923 const btnSettingsHelp = el('btn-settings-help');
4924 if (btnSettingsHelp) {
4925 btnSettingsHelp.onclick = () => {
4926 closeSettings();
4927 openHowToUse('knowledge-agents');
4928 };
4929 }
4930 el('modal-how-to-use-backdrop').onclick = closeHowToUse;
4931 el('modal-how-to-use-close').onclick = closeHowToUse;
4932
4933 document.querySelectorAll('.how-to-tab').forEach((tab) => {
4934 tab.addEventListener('click', () => {
4935 const id = tab.dataset.howToTab;
4936 document.querySelectorAll('.how-to-tab').forEach((t) => {
4937 t.classList.toggle('active', t.dataset.howToTab === id);
4938 t.setAttribute('aria-selected', t.dataset.howToTab === id ? 'true' : 'false');
4939 });
4940 document.querySelectorAll('.how-to-panel').forEach((p) => {
4941 p.classList.toggle('active', p.id === 'how-to-panel-' + id);
4942 });
4943 });
4944 });
4945
4946 const modalHowTo = el('modal-how-to-use');
4947 if (modalHowTo) {
4948 modalHowTo.addEventListener('click', (e) => {
4949 const t = e.target;
4950 if (t && t.classList && t.classList.contains('how-to-jump-consolidation')) {
4951 e.preventDefault();
4952 openHowToUse('consolidation');
4953 }
4954 });
4955 }
4956
4957 const btnHowToOpenOnboarding = el('btn-how-to-open-onboarding');
4958 if (btnHowToOpenOnboarding && !btnHowToOpenOnboarding.dataset.knowtationBound) {
4959 btnHowToOpenOnboarding.dataset.knowtationBound = '1';
4960 btnHowToOpenOnboarding.addEventListener('click', () => {
4961 closeHowToUse();
4962 void openOnboardingWizard({ restart: false });
4963 });
4964 }
4965 const btnEmptyStripWizard = el('btn-empty-strip-wizard');
4966 if (btnEmptyStripWizard && !btnEmptyStripWizard.dataset.knowtationBound) {
4967 btnEmptyStripWizard.dataset.knowtationBound = '1';
4968 btnEmptyStripWizard.addEventListener('click', () => {
4969 void openOnboardingWizard({ restart: true });
4970 });
4971 }
4972 const btnEmptyStripGettingStarted = el('btn-empty-strip-getting-started');
4973 if (btnEmptyStripGettingStarted && !btnEmptyStripGettingStarted.dataset.knowtationBound) {
4974 btnEmptyStripGettingStarted.dataset.knowtationBound = '1';
4975 btnEmptyStripGettingStarted.addEventListener('click', () => {
4976 openHowToUse('getting-started');
4977 });
4978 }
4979
4980 function openTokenSavingsHowToFromSettings() {
4981 closeSettings();
4982 openHowToUse('token-savings');
4983 }
4984 const btnConsolToken = el('btn-consol-how-token-savings');
4985 if (btnConsolToken) btnConsolToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4986 const btnIntegToken = el('btn-integrations-how-token-savings');
4987 if (btnIntegToken) btnIntegToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4988 const btnAgentsToken = el('btn-agents-how-token-savings');
4989 if (btnAgentsToken) btnAgentsToken.addEventListener('click', (e) => { e.preventDefault(); openTokenSavingsHowToFromSettings(); });
4990
4991 function openSettings() {
4992 refreshApiBaseFootgunBanner();
4993 closeCreateModal();
4994 el('modal-settings').classList.remove('hidden');
4995 document.querySelectorAll('.settings-tab').forEach((t) => t.classList.toggle('active', t.dataset.settingsTab === 'backup'));
4996 document.querySelectorAll('.settings-panel').forEach((p) => {
4997 p.classList.toggle('active', p.id === 'settings-panel-backup');
4998 });
4999 syncAccentUI();
5000 syncThemeUI();
5001 syncColorPaletteUI();
5002 refreshIntegApiStatus();
5003 el('settings-sync-msg').textContent = '';
5004 el('settings-sync-msg').className = 'settings-msg';
5005 el('settings-save-msg').textContent = '';
5006 el('settings-save-msg').className = 'settings-msg';
5007 const policyMsg = el('settings-proposal-policy-msg');
5008 if (policyMsg) {
5009 policyMsg.textContent = '';
5010 policyMsg.className = 'settings-msg';
5011 }
5012 el('settings-mode-display').textContent = 'Loading…';
5013 el('settings-vault-display').textContent = 'Loading…';
5014 el('settings-git-status').textContent = 'Loading…';
5015 const ghStatus = el('settings-github-status');
5016 if (ghStatus) ghStatus.textContent = 'Loading…';
5017 fetchSettingsForBackupModal()
5018 .then((s) => {
5019 // api() returns null for empty 200 body or JSON `null` — do not access s.role (throws → catch → all "—").
5020 if (s == null || typeof s !== 'object' || Array.isArray(s)) {
5021 throw new Error(
5022 '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.',
5023 );
5024 }
5025 applySettingsPayloadToHubChrome(s);
5026 const roleEl = el('settings-role-display');
5027 if (roleEl) roleEl.textContent = s.role ? String(s.role) : '—';
5028 const userIdEl = el('settings-user-id');
5029 if (userIdEl) userIdEl.textContent = s.user_id || '—';
5030 const vaultDisplay = s.vault_path_display || '—';
5031 const isHosted = (vaultDisplay + '').toLowerCase() === 'canister';
5032 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHosted ? 'Hosted (beta)' : 'Self-hosted';
5033 el('settings-vault-display').textContent = vaultDisplay;
5034 const configureSection = el('settings-configure-backup-section');
5035 const configureHr = el('settings-hr-configure');
5036 if (configureSection) configureSection.style.display = isHosted ? 'none' : '';
5037 if (configureHr) configureHr.style.display = isHosted ? 'none' : '';
5038 const vg = s.vault_git || {};
5039 // Guided Setup checklist: step 1 = vault path (self-hosted) or account (hosted), step 4 = backup configured
5040 const step1 = document.getElementById('setup-step-1');
5041 const step4 = document.getElementById('setup-step-4');
5042 const step1Label = el('setup-step-1-label');
5043 const step1Hint = el('setup-step-1-hint');
5044 if (step1Label) step1Label.textContent = isHosted ? 'Account ready' : 'Vault path set';
5045 if (step1Hint) {
5046 step1Hint.textContent = isHosted
5047 ? 'Your notes live in your hosted vault'
5048 : 'Set below under Configure backup';
5049 }
5050 if (step1) {
5051 const done = Boolean(s.vault_path_display && s.vault_path_display.trim());
5052 step1.classList.toggle('setup-step-done', done);
5053 const icon = step1.querySelector('.setup-step-icon');
5054 if (icon) icon.textContent = done ? '✓' : '';
5055 }
5056 if (step4) {
5057 const done = !!(vg.enabled && vg.has_remote);
5058 step4.classList.toggle('setup-step-done', done);
5059 const icon = step4.querySelector('.setup-step-icon');
5060 if (icon) icon.textContent = done ? '✓' : '';
5061 }
5062 let gitText = 'Not configured';
5063 if (vg.enabled && vg.has_remote) {
5064 gitText = 'Configured';
5065 if (vg.auto_commit) gitText += ' (auto-commit on)';
5066 if (vg.auto_push) gitText += ', auto-push on';
5067 } else if (vg.enabled) gitText = 'Enabled but no remote set';
5068 el('settings-git-status').textContent = gitText;
5069 const evalReqEl = el('settings-proposal-eval-required');
5070 if (evalReqEl) evalReqEl.textContent = s.proposal_evaluation_required ? 'On' : 'Off';
5071 const hintsEl = el('settings-proposal-hints-enabled');
5072 if (hintsEl) hintsEl.textContent = s.proposal_review_hints_enabled ? 'On' : 'Off';
5073 const enrichStatusEl = el('settings-proposal-enrich-enabled');
5074 if (enrichStatusEl) enrichStatusEl.textContent = s.proposal_enrich_enabled ? 'On' : 'Off';
5075 const evApEl = el('settings-evaluator-may-approve');
5076 if (evApEl) evApEl.textContent = s.hub_evaluator_may_approve ? 'Yes' : 'No';
5077 const syncBtn = el('btn-settings-sync');
5078 const isAdmin = s.role === 'admin';
5079 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHosted);
5080 const saveSetupBtn = el('btn-settings-save');
5081 if (saveSetupBtn) {
5082 saveSetupBtn.disabled = false;
5083 saveSetupBtn.title = isAdmin ? '' : 'Only admins can save; your role is shown under Status above.';
5084 }
5085 const teamTab = el('settings-tab-team');
5086 if (teamTab) teamTab.classList.toggle('hidden', !isAdmin);
5087 const vaultsTab = el('settings-tab-vaults');
5088 if (vaultsTab) vaultsTab.classList.toggle('hidden', !isAdmin);
5089 const policyAdmin = el('settings-proposal-policy-admin');
5090 const storedPolicy = s.proposal_policy_stored || {};
5091 const policyLocks = s.proposal_policy_env_locked || {};
5092 if (policyAdmin) {
5093 policyAdmin.classList.toggle('hidden', !isAdmin);
5094 const cEval = el('settings-policy-eval');
5095 const cHints = el('settings-policy-hints');
5096 const cEnrich = el('settings-policy-enrich');
5097 if (cEval && cHints && cEnrich) {
5098 cEval.checked = Boolean(storedPolicy.proposal_evaluation_required);
5099 cHints.checked = Boolean(storedPolicy.review_hints_enabled);
5100 cEnrich.checked = Boolean(storedPolicy.enrich_enabled);
5101 cEval.disabled = Boolean(policyLocks.proposal_evaluation_required);
5102 cHints.disabled = Boolean(policyLocks.review_hints_enabled);
5103 cEnrich.disabled = Boolean(policyLocks.enrich_enabled);
5104 const lockHint =
5105 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5106 cEval.title = policyLocks.proposal_evaluation_required ? lockHint : '';
5107 cHints.title = policyLocks.review_hints_enabled ? lockHint : '';
5108 cEnrich.title = policyLocks.enrich_enabled ? lockHint : '';
5109 }
5110 }
5111 const connectBtn = el('btn-connect-github');
5112 const ghStatus = el('settings-github-status');
5113 const hostedGhHint = el('settings-hosted-connect-github-hint');
5114 if (s.github_connect_available) {
5115 if (connectBtn) {
5116 connectBtn.classList.remove('hidden');
5117 connectBtn.onclick = () => {
5118 const base = apiBase.replace(/\/$/, '');
5119 const qs = token ? '?' + new URLSearchParams({ token }).toString() : '';
5120 window.location.assign(base + '/api/v1/auth/github-connect' + qs);
5121 };
5122 }
5123 if (ghStatus) ghStatus.textContent = s.github_connected ? 'Connected (token stored for push)' : 'Not connected';
5124 } else {
5125 if (connectBtn) {
5126 connectBtn.classList.add('hidden');
5127 connectBtn.onclick = null;
5128 }
5129 if (ghStatus) ghStatus.textContent = '—';
5130 }
5131 if (hostedGhHint) {
5132 const vd = s.vault_path_display || '';
5133 hostedGhHint.classList.toggle('hidden', !(String(vd).toLowerCase() === 'canister' && s.github_connect_available));
5134 }
5135 const hostedRepoSection = el('settings-hosted-backup-repo-section');
5136 const hostedRepoInput = el('settings-hosted-repo');
5137 if (hostedRepoSection) {
5138 hostedRepoSection.classList.toggle('hidden', !(isHosted && s.github_connect_available));
5139 }
5140 if (hostedRepoInput && isHosted && s.github_connect_available) {
5141 if (!hostedRepoInput.value.trim()) {
5142 hostedRepoInput.value = (s.repo && String(s.repo)) || localStorage.getItem(HOSTED_BACKUP_REPO_LS) || '';
5143 }
5144 if (!hostedRepoInput.dataset.knowtationBound) {
5145 hostedRepoInput.dataset.knowtationBound = '1';
5146 hostedRepoInput.addEventListener('input', () => {
5147 const syncBtn = el('btn-settings-sync');
5148 if (!syncBtn || !lastBackupSettingsPayload) return;
5149 const vd = lastBackupSettingsPayload.vault_path_display || '';
5150 const ih = (vd + '').toLowerCase() === 'canister';
5151 if (ih && lastBackupSettingsPayload.github_connect_available) {
5152 const vg = lastBackupSettingsPayload.vault_git || {};
5153 syncBtn.disabled = settingsSyncDisabled(lastBackupSettingsPayload, vg, ih);
5154 }
5155 });
5156 }
5157 }
5158 const ed = s.embedding_display || {};
5159 if (el('agents-embedding-provider')) el('agents-embedding-provider').textContent = ed.provider || '—';
5160 if (el('agents-embedding-model')) el('agents-embedding-model').textContent = ed.model || '—';
5161 const ollamaRow = el('agents-ollama-row');
5162 if (ollamaRow) ollamaRow.style.display = ed.provider === 'ollama' ? '' : 'none';
5163 if (el('agents-embedding-ollama-url')) el('agents-embedding-ollama-url').textContent = ed.ollama_url || '—';
5164 applyChatProviderSettings(s);
5165 const apiRow = el('settings-api-base-row');
5166 const apiDisp = el('settings-api-base-display');
5167 if (apiRow && apiDisp) {
5168 if (isLocalHubHostname()) {
5169 apiRow.classList.remove('hidden');
5170 apiDisp.textContent = apiBase;
5171 } else {
5172 apiRow.classList.add('hidden');
5173 }
5174 }
5175 refreshApiBaseFootgunBanner();
5176 void refreshBulkDeletePresetDropdowns();
5177 })
5178 .catch((e) => {
5179 const syncMsg = el('settings-sync-msg');
5180 if (syncMsg) {
5181 const m = e && e.message ? String(e.message) : 'Could not load settings.';
5182 syncMsg.textContent = m.length > 280 ? m.slice(0, 280) + '…' : m;
5183 syncMsg.className = 'settings-msg err';
5184 }
5185 if (typeof console !== 'undefined' && console.error) {
5186 console.error('[openSettings] GET /api/v1/settings failed or invalid payload', e);
5187 }
5188 const hostedGhHint = el('settings-hosted-connect-github-hint');
5189 if (hostedGhHint) hostedGhHint.classList.add('hidden');
5190 const roleEl = el('settings-role-display');
5191 if (roleEl) roleEl.textContent = '—';
5192 const userIdEl = el('settings-user-id');
5193 if (userIdEl) userIdEl.textContent = '—';
5194 if (el('settings-mode-display')) el('settings-mode-display').textContent = '—';
5195 el('settings-vault-display').textContent = '—';
5196 el('settings-git-status').textContent = 'Could not load';
5197 const evalReqErr = el('settings-proposal-eval-required');
5198 if (evalReqErr) evalReqErr.textContent = '—';
5199 const hintsErr = el('settings-proposal-hints-enabled');
5200 if (hintsErr) hintsErr.textContent = '—';
5201 const enrichErr = el('settings-proposal-enrich-enabled');
5202 if (enrichErr) enrichErr.textContent = '—';
5203 const evApErr = el('settings-evaluator-may-approve');
5204 if (evApErr) evApErr.textContent = '—';
5205 const configureSection = el('settings-configure-backup-section');
5206 const configureHr = el('settings-hr-configure');
5207 if (configureSection) configureSection.style.display = '';
5208 if (configureHr) configureHr.style.display = '';
5209 const ghStatus = el('settings-github-status');
5210 if (ghStatus) ghStatus.textContent = '—';
5211 if (el('btn-settings-sync')) el('btn-settings-sync').disabled = true;
5212 const apiRowErr = el('settings-api-base-row');
5213 const apiDispErr = el('settings-api-base-display');
5214 if (apiRowErr && apiDispErr && isLocalHubHostname()) {
5215 apiRowErr.classList.remove('hidden');
5216 apiDispErr.textContent = apiBase;
5217 }
5218 refreshApiBaseFootgunBanner();
5219 });
5220 api('/api/v1/setup')
5221 .then((u) => {
5222 if (el('setup-vault-path')) el('setup-vault-path').value = u.vault_path || '';
5223 if (el('setup-git-enabled')) el('setup-git-enabled').checked = !!(u.vault_git && u.vault_git.enabled);
5224 if (el('setup-git-remote')) el('setup-git-remote').value = (u.vault_git && u.vault_git.remote) || '';
5225 })
5226 .catch(() => {});
5227 }
5228 function closeSettings() {
5229 el('modal-settings').classList.add('hidden');
5230 }
5231 function openSettingsBillingTab() {
5232 openSettings();
5233 document.querySelectorAll('.settings-tab').forEach((t) => {
5234 t.classList.toggle('active', t.dataset.settingsTab === 'billing');
5235 t.setAttribute('aria-selected', t.dataset.settingsTab === 'billing' ? 'true' : 'false');
5236 });
5237 document.querySelectorAll('.settings-panel').forEach((p) => {
5238 p.classList.toggle('active', p.id === 'settings-panel-billing');
5239 });
5240 loadBillingPanel();
5241 }
5242
5243 function openSettingsIntegrationsTab() {
5244 openSettings();
5245 document.querySelectorAll('.settings-tab').forEach((t) => {
5246 t.classList.toggle('active', t.dataset.settingsTab === 'integrations');
5247 t.setAttribute('aria-selected', t.dataset.settingsTab === 'integrations' ? 'true' : 'false');
5248 });
5249 document.querySelectorAll('.settings-panel').forEach((p) => {
5250 p.classList.toggle('active', p.id === 'settings-panel-integrations');
5251 });
5252 refreshIntegApiStatus();
5253 applyMuseBridgePanel(lastBackupSettingsPayload);
5254 if (typeof scheduleIntegrationGuidesInit === 'function') scheduleIntegrationGuidesInit(0);
5255 }
5256
5257 if (btnSettings) btnSettings.onclick = openSettings;
5258
5259 const btnSettingsSetupGuide = el('btn-settings-setup-guide');
5260 if (btnSettingsSetupGuide) {
5261 btnSettingsSetupGuide.addEventListener('click', () => {
5262 closeSettings();
5263 void openOnboardingWizard({ restart: true });
5264 });
5265 }
5266
5267 const btnProposalPolicySave = el('btn-proposal-policy-save');
5268 if (btnProposalPolicySave && !btnProposalPolicySave.dataset.knowtationPolicyBound) {
5269 btnProposalPolicySave.dataset.knowtationPolicyBound = '1';
5270 btnProposalPolicySave.addEventListener('click', async () => {
5271 const msg = el('settings-proposal-policy-msg');
5272 if (msg) {
5273 msg.textContent = '';
5274 msg.className = 'settings-msg';
5275 }
5276 try {
5277 await api('/api/v1/settings/proposal-policy', {
5278 method: 'POST',
5279 body: JSON.stringify({
5280 proposal_evaluation_required: el('settings-policy-eval').checked,
5281 review_hints_enabled: el('settings-policy-hints').checked,
5282 enrich_enabled: el('settings-policy-enrich').checked,
5283 }),
5284 });
5285 if (msg) {
5286 msg.textContent = 'Saved.';
5287 msg.className = 'settings-msg ok';
5288 }
5289 const fresh = await fetchSettingsForBackupModal();
5290 applySettingsPayloadToHubChrome(fresh);
5291 const evalReqEl = el('settings-proposal-eval-required');
5292 if (evalReqEl) evalReqEl.textContent = fresh.proposal_evaluation_required ? 'On' : 'Off';
5293 const hintsEl2 = el('settings-proposal-hints-enabled');
5294 if (hintsEl2) hintsEl2.textContent = fresh.proposal_review_hints_enabled ? 'On' : 'Off';
5295 const enrichEl2 = el('settings-proposal-enrich-enabled');
5296 if (enrichEl2) enrichEl2.textContent = fresh.proposal_enrich_enabled ? 'On' : 'Off';
5297 const st = fresh.proposal_policy_stored || {};
5298 const lk = fresh.proposal_policy_env_locked || {};
5299 const ce = el('settings-policy-eval');
5300 const ch = el('settings-policy-hints');
5301 const cr = el('settings-policy-enrich');
5302 if (ce && ch && cr) {
5303 ce.checked = Boolean(st.proposal_evaluation_required);
5304 ch.checked = Boolean(st.review_hints_enabled);
5305 cr.checked = Boolean(st.enrich_enabled);
5306 ce.disabled = Boolean(lk.proposal_evaluation_required);
5307 ch.disabled = Boolean(lk.review_hints_enabled);
5308 cr.disabled = Boolean(lk.enrich_enabled);
5309 const lockHint =
5310 'Fixed by a server environment variable; change or unset it on the host to control this from here.';
5311 ce.title = lk.proposal_evaluation_required ? lockHint : '';
5312 ch.title = lk.review_hints_enabled ? lockHint : '';
5313 cr.title = lk.enrich_enabled ? lockHint : '';
5314 }
5315 } catch (e) {
5316 if (msg) {
5317 msg.textContent = e && e.message ? String(e.message) : String(e);
5318 msg.className = 'settings-msg err';
5319 }
5320 }
5321 });
5322 }
5323 el('modal-settings-backdrop').onclick = closeSettings;
5324 el('modal-settings-close').onclick = closeSettings;
5325
5326 el('btn-copy-env-agentception').onclick = () => {
5327 const provider = (el('agents-embedding-provider') && el('agents-embedding-provider').textContent) || '';
5328 const model = (el('agents-embedding-model') && el('agents-embedding-model').textContent) || '';
5329 const ollamaUrl = (el('agents-embedding-ollama-url') && el('agents-embedding-ollama-url').textContent) || '';
5330 const lines = [];
5331 if (provider === 'ollama' && ollamaUrl && ollamaUrl !== '—') {
5332 lines.push('OLLAMA_BASE_URL=' + ollamaUrl.trim());
5333 }
5334 lines.push('# Embedding model: ' + (model !== '—' ? model : 'nomic-embed-text'));
5335 const snippet = lines.join('\n');
5336 const msg = el('agents-copy-msg');
5337 if (navigator.clipboard && navigator.clipboard.writeText) {
5338 navigator.clipboard.writeText(snippet).then(() => {
5339 if (msg) { msg.textContent = 'Embedding env copied.'; msg.className = 'settings-msg'; }
5340 setTimeout(() => { if (msg) msg.textContent = ''; }, 2000);
5341 }).catch(() => {
5342 if (msg) { msg.textContent = 'Copy failed'; msg.className = 'settings-msg err'; }
5343 });
5344 } else {
5345 if (msg) { msg.textContent = 'Clipboard not available'; msg.className = 'settings-msg err'; }
5346 }
5347 };
5348
5349 function refreshIntegApiStatus() {
5350 var dot = el('integ-api-status');
5351 if (!dot) return;
5352 var hasToken = Boolean(token || (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')));
5353 dot.classList.toggle('active', hasToken);
5354 dot.title = hasToken ? 'Token available — signed in' : 'No token — sign in to enable';
5355 }
5356
5357 /** @type {import('./hub-integration-guides.mjs').IntegrationGuide | null} */
5358 let activeIntegGuide = null;
5359
5360 function closeIntegGuideModal() {
5361 const modal = el('modal-integ-guide');
5362 if (modal) modal.classList.add('hidden');
5363 activeIntegGuide = null;
5364 }
5365
5366 function openIntegGuideModal(guide) {
5367 const mod = globalThis.HubIntegrationGuides;
5368 if (!mod || !guide) return;
5369 const modal = el('modal-integ-guide');
5370 const iconEl = el('modal-integ-guide-icon');
5371 const nameEl = el('modal-integ-guide-name');
5372 const leadEl = el('modal-integ-guide-lead');
5373 const contentEl = el('modal-integ-guide-content');
5374 const importBtn = el('btn-integ-guide-import');
5375 const teamBtn = el('btn-integ-guide-team');
5376 const msgEl = el('modal-integ-guide-msg');
5377 if (!modal || !contentEl) return;
5378 activeIntegGuide = guide;
5379 if (iconEl) iconEl.textContent = guide.icon || '';
5380 if (nameEl) nameEl.textContent = guide.name || 'Integration';
5381 if (leadEl) {
5382 leadEl.textContent =
5383 guide.kind === 'capture'
5384 ? 'Live capture — messages become inbox notes via POST /api/v1/capture.'
5385 : guide.desc || 'Import files or exports into your vault.';
5386 }
5387 contentEl.innerHTML = mod.renderIntegrationGuideHtml(guide);
5388 if (msgEl) msgEl.textContent = '';
5389 if (importBtn) {
5390 const importSel = el('import-source-type');
5391 const canPreselect =
5392 guide.hubImport &&
5393 guide.sourceType &&
5394 importSel &&
5395 Array.from(importSel.options).some((o) => o.value === guide.sourceType);
5396 const showImport =
5397 guide.hubImport && (canPreselect || guide.id === 'imports' || guide.id === 'hermes');
5398 importBtn.classList.toggle('hidden', !showImport);
5399 importBtn.textContent =
5400 guide.id === 'hermes'
5401 ? 'Open Import (Markdown)'
5402 : guide.id === 'imports'
5403 ? 'Open Import'
5404 : 'Open Import';
5405 }
5406 if (teamBtn) teamBtn.classList.toggle('hidden', guide.id !== 'imports');
5407 modal.classList.remove('hidden');
5408 }
5409
5410 let integGuideControlsBound = false;
5411
5412 function bindIntegrationGuideModalControlsOnce() {
5413 if (integGuideControlsBound) return;
5414 integGuideControlsBound = true;
5415 const backdrop = el('modal-integ-guide-backdrop');
5416 const closeBtn = el('modal-integ-guide-close');
5417 const importBtn = el('btn-integ-guide-import');
5418 const teamBtn = el('btn-integ-guide-team');
5419 const contentEl = el('modal-integ-guide-content');
5420 if (backdrop) backdrop.onclick = closeIntegGuideModal;
5421 if (closeBtn) closeBtn.onclick = closeIntegGuideModal;
5422 if (contentEl) {
5423 contentEl.addEventListener('click', (ev) => {
5424 const btn = ev.target instanceof Element ? ev.target.closest('.integ-guide-copy') : null;
5425 if (!btn) return;
5426 const text = btn.getAttribute('data-copy') || '';
5427 const msgEl = el('modal-integ-guide-msg');
5428 if (navigator.clipboard && navigator.clipboard.writeText && text) {
5429 navigator.clipboard.writeText(text).then(() => {
5430 if (msgEl) {
5431 msgEl.textContent = 'Copied.';
5432 msgEl.className = 'settings-msg ok';
5433 }
5434 setTimeout(() => {
5435 if (msgEl) msgEl.textContent = '';
5436 }, 2000);
5437 }).catch(() => {
5438 if (msgEl) {
5439 msgEl.textContent = 'Copy failed';
5440 msgEl.className = 'settings-msg err';
5441 }
5442 });
5443 } else if (msgEl) {
5444 msgEl.textContent = 'Clipboard not available';
5445 msgEl.className = 'settings-msg err';
5446 }
5447 });
5448 }
5449 if (importBtn) {
5450 importBtn.onclick = () => {
5451 const guide = activeIntegGuide;
5452 closeIntegGuideModal();
5453 closeSettings();
5454 const preselect =
5455 guide && guide.id === 'hermes'
5456 ? 'markdown'
5457 : guide && guide.sourceType
5458 ? guide.sourceType
5459 : undefined;
5460 openImportModal(preselect);
5461 };
5462 }
5463 if (teamBtn) {
5464 teamBtn.onclick = () => {
5465 closeIntegGuideModal();
5466 openSettings();
5467 document.querySelectorAll('.settings-tab').forEach((t) => {
5468 t.classList.toggle('active', t.dataset.settingsTab === 'team');
5469 t.setAttribute('aria-selected', t.dataset.settingsTab === 'team' ? 'true' : 'false');
5470 });
5471 document.querySelectorAll('.settings-panel').forEach((p) => {
5472 p.classList.toggle('active', p.id === 'settings-panel-team');
5473 });
5474 };
5475 }
5476 document.addEventListener('click', (ev) => {
5477 const tile =
5478 ev.target instanceof Element
5479 ? ev.target.closest('#settings-panel-integrations [data-integ-id]')
5480 : null;
5481 if (!tile) return;
5482 const mod = globalThis.HubIntegrationGuides;
5483 if (!mod || typeof mod.getIntegrationGuide !== 'function') {
5484 if (typeof showToast === 'function') {
5485 showToast('Integration details still loading — try again in a moment.', true);
5486 }
5487 scheduleIntegrationGuidesInit(0);
5488 return;
5489 }
5490 const id = tile.getAttribute('data-integ-id');
5491 const guide = id ? mod.getIntegrationGuide(id) : null;
5492 if (guide) {
5493 ev.preventDefault();
5494 openIntegGuideModal(guide);
5495 }
5496 });
5497 }
5498
5499 function scheduleIntegrationGuidesInit(attempt) {
5500 bindIntegrationGuideModalControlsOnce();
5501 if (globalThis.HubIntegrationGuides) return;
5502 if (attempt >= 80) return;
5503 setTimeout(() => scheduleIntegrationGuidesInit(attempt + 1), 50);
5504 }
5505
5506 scheduleIntegrationGuidesInit(0);
5507
5508 const btnCopyMcpPrime = el('btn-copy-mcp-prime');
5509 if (btnCopyMcpPrime) {
5510 btnCopyMcpPrime.onclick = () => {
5511 const base = String(apiBase || '').replace(/\/$/, '');
5512 const vaultId = getCurrentVaultId() || 'default';
5513 const msg = el('integrations-hub-api-copy-msg');
5514 const payload = {
5515 schema: 'knowtation.hub_copy_prime/v1',
5516 mcp_read_resource_uri: 'knowtation://hosted/prime',
5517 instructions:
5518 '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 ' +
5519 INTEGRATION_DOC_URL,
5520 KNOWTATION_HUB_URL: base,
5521 KNOWTATION_HUB_VAULT_ID: vaultId,
5522 ...(mcpPublicUrl !== '' ? { KNOWTATION_MCP_URL: mcpPublicUrl } : {}),
5523 };
5524 const snippet = JSON.stringify(payload, null, 2);
5525 if (navigator.clipboard && navigator.clipboard.writeText) {
5526 navigator.clipboard.writeText(snippet).then(() => {
5527 if (msg) {
5528 msg.textContent = 'Copied prime (URI + hub URL + vault id; no JWT).';
5529 msg.className = 'settings-msg';
5530 }
5531 setTimeout(() => {
5532 if (msg) msg.textContent = '';
5533 }, 2800);
5534 }).catch(() => {
5535 if (msg) {
5536 msg.textContent = 'Copy failed';
5537 msg.className = 'settings-msg err';
5538 }
5539 });
5540 } else if (msg) {
5541 msg.textContent = 'Clipboard not available';
5542 msg.className = 'settings-msg err';
5543 }
5544 };
5545 }
5546
5547 const btnCopyHubApiEnv = el('btn-copy-hub-api-env');
5548 if (btnCopyHubApiEnv) {
5549 btnCopyHubApiEnv.onclick = () => {
5550 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5551 const vaultId = getCurrentVaultId() || 'default';
5552 const base = String(apiBase || '').replace(/\/$/, '');
5553 const msg = el('integrations-hub-api-copy-msg');
5554 if (!hubTok) {
5555 if (msg) {
5556 msg.textContent = 'Sign in first, then copy again.';
5557 msg.className = 'settings-msg err';
5558 }
5559 return;
5560 }
5561 const copyLines = [
5562 'KNOWTATION_HUB_URL=' + base,
5563 'KNOWTATION_HUB_TOKEN=' + hubTok,
5564 'KNOWTATION_HUB_VAULT_ID=' + vaultId,
5565 ];
5566 if (mcpPublicUrl !== '') {
5567 copyLines.push('KNOWTATION_MCP_URL=' + mcpPublicUrl);
5568 }
5569 copyLines.push('');
5570 copyLines.push('# Use with Hub REST, remote MCP, and local CLI: ' + INTEGRATION_DOC_URL);
5571 copyLines.push(
5572 '# Example curl (append these headers to any Hub REST call): ' +
5573 '-H "Authorization: Bearer $KNOWTATION_HUB_TOKEN" ' +
5574 '-H "Content-Type: application/json" ' +
5575 '-H "X-Vault-Id: $KNOWTATION_HUB_VAULT_ID"'
5576 );
5577 const snippet = copyLines.join('\n');
5578 if (navigator.clipboard && navigator.clipboard.writeText) {
5579 navigator.clipboard.writeText(snippet).then(() => {
5580 if (msg) {
5581 msg.textContent = 'Copied session access token (expires — not for always-on agents).';
5582 msg.className = 'settings-msg';
5583 }
5584 refreshIntegApiStatus();
5585 setTimeout(() => {
5586 if (msg) msg.textContent = '';
5587 }, 3500);
5588 }).catch(() => {
5589 if (msg) {
5590 msg.textContent = 'Copy failed';
5591 msg.className = 'settings-msg err';
5592 }
5593 });
5594 } else if (msg) {
5595 msg.textContent = 'Clipboard not available';
5596 msg.className = 'settings-msg err';
5597 }
5598 };
5599 }
5600
5601 /** Settings → Integrations → Connect cloud agent (RFC 8628 device approval). */
5602 /** Device auth mounts on the persistent MCP host — not Netlify api.knowtation.store. */
5603 function deviceAuthBase() {
5604 if (mcpPublicUrl) {
5605 try {
5606 const u = new URL(mcpPublicUrl);
5607 return u.origin;
5608 } catch (_) { /* fall through */ }
5609 }
5610 return String(apiBase || '').replace(/\/$/, '');
5611 }
5612
5613 function setDeviceConnectMsg(text, isErr) {
5614 const msg = el('device-connect-msg');
5615 if (!msg) return;
5616 msg.textContent = text || '';
5617 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5618 }
5619
5620 async function refreshDevicePendingList() {
5621 const list = el('device-pending-list');
5622 if (!list) return;
5623 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5624 if (!hubTok) {
5625 list.innerHTML = '<li>Sign in to see pending agent codes.</li>';
5626 return;
5627 }
5628 try {
5629 const res = await fetch(deviceAuthBase() + '/api/v1/auth/device/pending', {
5630 headers: { Authorization: 'Bearer ' + hubTok },
5631 credentials: 'omit',
5632 });
5633 if (!res.ok) {
5634 list.innerHTML = '<li>Pending list unavailable on this host (device auth mounts on the persistent MCP gateway).</li>';
5635 return;
5636 }
5637 const data = await res.json();
5638 const pending = Array.isArray(data.pending) ? data.pending : [];
5639 if (pending.length === 0) {
5640 list.innerHTML = '<li>No pending cloud-agent codes.</li>';
5641 return;
5642 }
5643 list.innerHTML = pending
5644 .map(function (p) {
5645 const code = String(p.userCode || '').replace(/[<>&]/g, '');
5646 const name = String(p.clientName || p.clientId || 'agent').replace(/[<>&]/g, '');
5647 return '<li><strong>' + code + '</strong> — ' + name + '</li>';
5648 })
5649 .join('');
5650 } catch (_) {
5651 list.innerHTML = '<li>Could not load pending codes.</li>';
5652 }
5653 }
5654
5655 async function postDeviceApproveOrDeny(path) {
5656 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5657 const input = el('device-user-code-input');
5658 const userCode = input ? String(input.value || '').trim() : '';
5659 if (!hubTok) {
5660 setDeviceConnectMsg('Sign in first.', true);
5661 return;
5662 }
5663 if (!userCode) {
5664 setDeviceConnectMsg('Enter the user code shown by your agent.', true);
5665 return;
5666 }
5667 try {
5668 const res = await fetch(deviceAuthBase() + path, {
5669 method: 'POST',
5670 headers: {
5671 Authorization: 'Bearer ' + hubTok,
5672 'Content-Type': 'application/json',
5673 },
5674 credentials: 'omit',
5675 body: JSON.stringify({
5676 user_code: userCode,
5677 vault_id: getCurrentVaultId() || 'default',
5678 }),
5679 });
5680 const data = await res.json().catch(function () { return {}; });
5681 if (!res.ok) {
5682 setDeviceConnectMsg(data.error || ('Request failed (' + res.status + ')'), true);
5683 return;
5684 }
5685 setDeviceConnectMsg(path.indexOf('deny') >= 0 ? 'Denied.' : 'Approved — agent can finish polling.', false);
5686 if (input) input.value = '';
5687 refreshDevicePendingList();
5688 } catch (_) {
5689 setDeviceConnectMsg('Network error talking to device auth endpoint.', true);
5690 }
5691 }
5692
5693 const btnDeviceApprove = el('btn-device-approve');
5694 if (btnDeviceApprove) {
5695 btnDeviceApprove.onclick = function () {
5696 postDeviceApproveOrDeny('/api/v1/auth/device/approve');
5697 };
5698 }
5699 const btnDeviceDeny = el('btn-device-deny');
5700 if (btnDeviceDeny) {
5701 btnDeviceDeny.onclick = function () {
5702 postDeviceApproveOrDeny('/api/v1/auth/device/deny');
5703 };
5704 }
5705 const btnDeviceRefreshPending = el('btn-device-refresh-pending');
5706 if (btnDeviceRefreshPending) {
5707 btnDeviceRefreshPending.onclick = function () {
5708 refreshDevicePendingList();
5709 };
5710 }
5711 const btnCopyCloudSetupPack = el('btn-copy-cloud-setup-pack');
5712 if (btnCopyCloudSetupPack) {
5713 btnCopyCloudSetupPack.onclick = function () {
5714 const pack =
5715 '# Knowtation cloud agent setup (NO SECRETS)\n' +
5716 '# MCP URL: https://mcp.knowtation.store/mcp\n' +
5717 '# Prefer: Hub Settings → Integrations → Connect cloud agent (device code)\n' +
5718 '# Interim (Hostinger Hermes): desktop mcp-remote OAuth → copy ~/.mcp-auth/mcp-remote-* to agent HOME\n' +
5719 '# → Hermes stdio: npx -y mcp-remote https://mcp.knowtation.store/mcp\n' +
5720 '# DO NOT: paste Hub session JWT into always-on .env\n' +
5721 '# DO NOT: use api.knowtation.store/mcp or Netlify /mcp\n' +
5722 '# Full guide: docs/AGENT-INTEGRATION.md (Always-on cloud agents)\n';
5723 if (navigator.clipboard && navigator.clipboard.writeText) {
5724 navigator.clipboard.writeText(pack).then(function () {
5725 setDeviceConnectMsg('Copied non-secret setup pack.', false);
5726 }).catch(function () {
5727 setDeviceConnectMsg('Copy failed', true);
5728 });
5729 } else {
5730 setDeviceConnectMsg('Clipboard not available', true);
5731 }
5732 };
5733 }
5734 try {
5735 var _ucParams = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
5736 var _uc = _ucParams ? _ucParams.get('user_code') : null;
5737 if (!_uc && typeof location !== 'undefined' && location.hash && location.hash.indexOf('user_code=') >= 0) {
5738 var _hq = location.hash.split('?')[1] || '';
5739 _uc = new URLSearchParams(_hq).get('user_code');
5740 }
5741 if (_uc && el('device-user-code-input')) {
5742 el('device-user-code-input').value = String(_uc).toUpperCase();
5743 }
5744 } catch (_) { /* ignore */ }
5745
5746 /** Settings → Integrations → Agent credentials (REST / Paperclip / cron) — Phase C. */
5747 function setAgentCredMsg(text, isErr) {
5748 const msg = el('agent-cred-msg');
5749 if (!msg) return;
5750 msg.textContent = text || '';
5751 msg.className = isErr ? 'settings-msg err' : 'settings-msg';
5752 }
5753
5754 function agentCredAuthHeaders() {
5755 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5756 return {
5757 Authorization: 'Bearer ' + hubTok,
5758 'Content-Type': 'application/json',
5759 Accept: 'application/json',
5760 };
5761 }
5762
5763 function formatAgentCredTs(ms) {
5764 if (ms == null || !Number.isFinite(Number(ms))) return '—';
5765 try {
5766 return new Date(Number(ms)).toISOString().slice(0, 19) + 'Z';
5767 } catch (_) {
5768 return '—';
5769 }
5770 }
5771
5772 /** Populate vault multi-select (freeze §8); default current vault selected. */
5773 function refreshAgentCredVaultSelect() {
5774 const sel = el('agent-cred-vault-select');
5775 if (!sel) return;
5776 const current = String(getCurrentVaultId() || 'default');
5777 const s = lastBackupSettingsPayload;
5778 let allowed = [];
5779 if (s && Array.isArray(s.allowed_vault_ids) && s.allowed_vault_ids.length) {
5780 allowed = s.allowed_vault_ids.map(String).filter(Boolean);
5781 } else if (s && Array.isArray(s.vault_list)) {
5782 allowed = s.vault_list
5783 .map(function (v) {
5784 return v && v.id != null ? String(v.id) : '';
5785 })
5786 .filter(Boolean);
5787 }
5788 if (allowed.length === 0) allowed = [current];
5789 if (allowed.indexOf(current) < 0) allowed = [current].concat(allowed);
5790 const prev = Array.prototype.slice
5791 .call(sel.selectedOptions || [])
5792 .map(function (o) {
5793 return o.value;
5794 });
5795 sel.innerHTML = '';
5796 allowed.forEach(function (vid) {
5797 const opt = document.createElement('option');
5798 opt.value = vid;
5799 opt.textContent = vid;
5800 opt.selected = prev.length ? prev.indexOf(vid) >= 0 : vid === current;
5801 sel.appendChild(opt);
5802 });
5803 if (!sel.selectedOptions || sel.selectedOptions.length === 0) {
5804 const fallback =
5805 Array.prototype.find.call(sel.options, function (o) {
5806 return o.value === current;
5807 }) || sel.options[0];
5808 if (fallback) fallback.selected = true;
5809 }
5810 }
5811
5812 function selectedAgentCredVaultIds() {
5813 const sel = el('agent-cred-vault-select');
5814 if (!sel) return [getCurrentVaultId() || 'default'];
5815 const picked = Array.prototype.slice.call(sel.selectedOptions || []).map(function (o) { return String(o.value || '').trim(); }).filter(Boolean);
5816 if (picked.length) return picked.slice(0, 32);
5817 return [getCurrentVaultId() || 'default'];
5818 }
5819
5820 function syncAgentCredWriteWarn() {
5821 const warn = el('agent-cred-write-warn');
5822 const box = el('agent-cred-scope-write');
5823 if (!warn || !box) return;
5824 warn.style.display = box.checked ? 'block' : 'none';
5825 }
5826
5827 function syncAgentCredStoreBanner(opts) {
5828 const banner = el('agent-cred-store-banner');
5829 if (!banner) return;
5830 const show = Boolean(opts && opts.show);
5831 banner.style.display = show ? 'block' : 'none';
5832 banner.textContent = show && opts.copy ? String(opts.copy) : '';
5833 }
5834
5835 async function refreshAgentCredList() {
5836 const list = el('agent-cred-list');
5837 if (!list) return;
5838 const hubTok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || token || '';
5839 if (!hubTok) {
5840 syncAgentCredStoreBanner({ show: false });
5841 list.innerHTML = '<li>Sign in to manage agent credentials.</li>';
5842 return;
5843 }
5844 try {
5845 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5846 headers: agentCredAuthHeaders(),
5847 credentials: 'omit',
5848 });
5849 const data = await res.json().catch(function () { return {}; });
5850 const code = data && data.code ? String(data.code) : '';
5851 if (!res.ok) {
5852 if (res.status === 503 && code === 'AGENT_CREDENTIAL_STORE_INCONSISTENT') {
5853 syncAgentCredStoreBanner({
5854 show: true,
5855 copy:
5856 'Agent credential store is inconsistent. Do not remint. Existing robots should retry; this is not a dead credential.',
5857 });
5858 } else if (res.status === 503 && code === 'AGENT_CREDENTIAL_STORE_UNAVAILABLE') {
5859 syncAgentCredStoreBanner({
5860 show: true,
5861 copy: 'Agent credential store is temporarily unavailable. Do not remint. Retry.',
5862 });
5863 } else {
5864 syncAgentCredStoreBanner({ show: false });
5865 }
5866 list.innerHTML =
5867 '<li>Agent credentials unavailable on this host (' +
5868 (code || String(res.status)) +
5869 ').</li>';
5870 return;
5871 }
5872 const store = data.store && typeof data.store === 'object' ? data.store : {};
5873 if (store.wipe_required) {
5874 syncAgentCredStoreBanner({
5875 show: true,
5876 copy:
5877 'Operator wipe required on the agent credential store. Robots will fail exchange until reminted after the wipe. This is not a browser session blip.',
5878 });
5879 } else {
5880 syncAgentCredStoreBanner({ show: false });
5881 }
5882 const creds = Array.isArray(data.credentials) ? data.credentials : [];
5883 if (creds.length === 0) {
5884 if (store.wipe_required || store.inconsistent) {
5885 list.innerHTML = '<li>Agent credential store requires operator attention.</li>';
5886 } else {
5887 list.innerHTML = '<li>No agent credentials yet.</li>';
5888 }
5889 return;
5890 }
5891 list.innerHTML = creds
5892 .map(function (c) {
5893 const id = String(c.id || '').replace(/[<>&"]/g, '');
5894 const name = String(c.name || '').replace(/[<>&"]/g, '');
5895 const scopes = (Array.isArray(c.scopes) ? c.scopes : []).join(' ').replace(/[<>&"]/g, '');
5896 const vaults = (Array.isArray(c.vault_ids) ? c.vault_ids : []).join(', ').replace(/[<>&"]/g, '') || '—';
5897 const created = formatAgentCredTs(c.created_at);
5898 const lastSuccess = formatAgentCredTs(c.last_used_at);
5899 const lastFailure = c.last_failure_code
5900 ? String(c.last_failure_code).replace(/[<>&"]/g, '') +
5901 (c.last_failure_at ? ' @ ' + formatAgentCredTs(c.last_failure_at) : '')
5902 : '—';
5903 const revokedAt = c.revoked_at ? formatAgentCredTs(c.revoked_at) : '—';
5904 const expires = formatAgentCredTs(c.expires_at);
5905 return (
5906 '<li><strong>' +
5907 name +
5908 '</strong> — vaults: ' +
5909 vaults +
5910 '; created: ' +
5911 created +
5912 '; last successful exchange: ' +
5913 lastSuccess +
5914 '; last failure code: ' +
5915 lastFailure +
5916 '; revoked-at: ' +
5917 revokedAt +
5918 '; scopes: ' +
5919 scopes +
5920 '; expires: ' +
5921 expires +
5922 (c.revoked ? ' (revoked)' : '') +
5923 ' <button type="button" class="btn-secondary btn-agent-cred-revoke" data-id="' +
5924 id +
5925 '">Revoke</button> <button type="button" class="btn-secondary btn-agent-cred-rotate" data-id="' +
5926 id +
5927 '">Rotate</button></li>'
5928 );
5929 })
5930 .join('');
5931 list.querySelectorAll('.btn-agent-cred-revoke').forEach(function (btn) {
5932 btn.onclick = async function () {
5933 const id = btn.getAttribute('data-id');
5934 const res = await fetch(
5935 String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials/' + encodeURIComponent(id),
5936 { method: 'DELETE', headers: agentCredAuthHeaders(), credentials: 'omit' }
5937 );
5938 setAgentCredMsg(res.ok ? 'Revoked.' : 'Revoke failed', !res.ok);
5939 refreshAgentCredList();
5940 };
5941 });
5942 list.querySelectorAll('.btn-agent-cred-rotate').forEach(function (btn) {
5943 btn.onclick = async function () {
5944 const id = btn.getAttribute('data-id');
5945 const res = await fetch(
5946 String(apiBase || '').replace(/\/$/, '') +
5947 '/api/v1/auth/agent/credentials/' +
5948 encodeURIComponent(id) +
5949 '/rotate',
5950 { method: 'POST', headers: agentCredAuthHeaders(), credentials: 'omit' }
5951 );
5952 const data = await res.json().catch(function () { return {}; });
5953 if (!res.ok) {
5954 setAgentCredMsg(data.error || 'Rotate failed', true);
5955 return;
5956 }
5957 const once = el('agent-cred-once');
5958 const pack =
5959 'KNOWTATION_HUB_URL=' +
5960 String(apiBase || '').replace(/\/$/, '') +
5961 '\nKNOWTATION_HUB_VAULT_ID=' +
5962 (getCurrentVaultId() || 'default') +
5963 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
5964 String(data.credential || '') +
5965 '\n';
5966 if (once) {
5967 once.style.display = 'block';
5968 once.textContent = pack + '\n# Shown once — copy now.';
5969 }
5970 if (navigator.clipboard && navigator.clipboard.writeText) {
5971 navigator.clipboard.writeText(pack).catch(function () {});
5972 }
5973 setAgentCredMsg('Rotated — new secret copied (shown once).', false);
5974 refreshAgentCredList();
5975 };
5976 });
5977 } catch (_) {
5978 syncAgentCredStoreBanner({ show: false });
5979 list.innerHTML = '<li>Could not load agent credentials.</li>';
5980 }
5981 }
5982
5983 const btnAgentCredMint = el('btn-agent-cred-mint');
5984 if (btnAgentCredMint) {
5985 btnAgentCredMint.onclick = async function () {
5986 const nameEl = el('agent-cred-name-input');
5987 const name = nameEl ? String(nameEl.value || '').trim() : '';
5988 if (!name) {
5989 setAgentCredMsg('Enter a name.', true);
5990 return;
5991 }
5992 const scopes = [];
5993 if (el('agent-cred-scope-propose') && el('agent-cred-scope-propose').checked) scopes.push('propose');
5994 if (el('agent-cred-scope-read') && el('agent-cred-scope-read').checked) scopes.push('vault:read');
5995 if (el('agent-cred-scope-ingest') && el('agent-cred-scope-ingest').checked) scopes.push('ingest:automation');
5996 if (el('agent-cred-scope-write') && el('agent-cred-scope-write').checked) scopes.push('vault:write');
5997 try {
5998 const res = await fetch(String(apiBase || '').replace(/\/$/, '') + '/api/v1/auth/agent/credentials', {
5999 method: 'POST',
6000 headers: agentCredAuthHeaders(),
6001 credentials: 'omit',
6002 body: JSON.stringify({
6003 name: name,
6004 vault_ids: selectedAgentCredVaultIds(),
6005 scopes: scopes.length ? scopes : ['propose', 'vault:read'],
6006 }),
6007 });
6008 const data = await res.json().catch(function () { return {}; });
6009 if (!res.ok) {
6010 setAgentCredMsg(data.error || data.code || 'Mint failed', true);
6011 return;
6012 }
6013 const packVault =
6014 (Array.isArray(data.vault_ids) && data.vault_ids[0]) ||
6015 selectedAgentCredVaultIds()[0] ||
6016 getCurrentVaultId() ||
6017 'default';
6018 const pack =
6019 'KNOWTATION_HUB_URL=' +
6020 String(apiBase || '').replace(/\/$/, '') +
6021 '\nKNOWTATION_HUB_VAULT_ID=' +
6022 packVault +
6023 '\nKNOWTATION_HUB_AGENT_CREDENTIAL=' +
6024 String(data.credential || '') +
6025 '\n';
6026 const once = el('agent-cred-once');
6027 if (once) {
6028 once.style.display = 'block';
6029 once.textContent = pack + '\n# Shown once — copy now. Store in Paperclip secrets.';
6030 }
6031 if (navigator.clipboard && navigator.clipboard.writeText) {
6032 await navigator.clipboard.writeText(pack);
6033 }
6034 setAgentCredMsg('Minted — env block copied (secret shown once).', false);
6035 refreshAgentCredList();
6036 } catch (_) {
6037 setAgentCredMsg('Network error minting credential.', true);
6038 }
6039 };
6040 }
6041 const btnAgentCredRefresh = el('btn-agent-cred-refresh');
6042 if (btnAgentCredRefresh) {
6043 btnAgentCredRefresh.onclick = function () {
6044 refreshAgentCredVaultSelect();
6045 refreshAgentCredList();
6046 };
6047 }
6048 const agentCredWriteBox = el('agent-cred-scope-write');
6049 if (agentCredWriteBox) {
6050 agentCredWriteBox.onchange = syncAgentCredWriteWarn;
6051 syncAgentCredWriteWarn();
6052 }
6053 function syncAgentCredIngestWarn() {
6054 const warn = el('agent-cred-ingest-warn');
6055 const box = el('agent-cred-scope-ingest');
6056 if (!warn || !box) return;
6057 warn.style.display = box.checked ? 'block' : 'none';
6058 }
6059 const agentCredIngestBox = el('agent-cred-scope-ingest');
6060 if (agentCredIngestBox) {
6061 agentCredIngestBox.onchange = syncAgentCredIngestWarn;
6062 syncAgentCredIngestWarn();
6063 }
6064 try {
6065 refreshAgentCredVaultSelect();
6066 refreshAgentCredList();
6067 } catch (_) { /* ignore */ }
6068 refreshDevicePendingList();
6069
6070 const btnSettingsMuseSave = el('btn-settings-muse-save');
6071 if (btnSettingsMuseSave && !btnSettingsMuseSave.dataset.knowtationMuseBound) {
6072 btnSettingsMuseSave.dataset.knowtationMuseBound = '1';
6073 btnSettingsMuseSave.addEventListener('click', async () => {
6074 const msg = el('settings-muse-msg');
6075 if (msg) {
6076 msg.textContent = '';
6077 msg.className = 'settings-msg';
6078 }
6079 const input = el('settings-muse-url');
6080 const url = input ? String(input.value || '').trim() : '';
6081 await withButtonBusy(btnSettingsMuseSave, 'Saving…', async () => {
6082 try {
6083 await api('/api/v1/settings/muse', {
6084 method: 'POST',
6085 body: JSON.stringify({ url }),
6086 });
6087 if (msg) {
6088 msg.textContent = 'Saved.';
6089 msg.className = 'settings-msg ok';
6090 }
6091 const s = await api('/api/v1/settings');
6092 applySettingsPayloadToHubChrome(s);
6093 } catch (e) {
6094 if (msg) {
6095 msg.textContent =
6096 e && e.code === 'ENV_CONFLICT'
6097 ? 'MUSE_URL is set on the server; unset it to save from Settings.'
6098 : (e && e.message) || 'Save failed';
6099 msg.className = 'settings-msg err';
6100 }
6101 }
6102 });
6103 });
6104 }
6105
6106 document.querySelectorAll('.settings-tab').forEach((tab) => {
6107 tab.addEventListener('click', () => {
6108 const id = tab.dataset.settingsTab;
6109 document.querySelectorAll('.settings-tab').forEach((t) => {
6110 t.classList.toggle('active', t.dataset.settingsTab === id);
6111 t.setAttribute('aria-selected', t.dataset.settingsTab === id ? 'true' : 'false');
6112 });
6113 document.querySelectorAll('.settings-panel').forEach((p) => {
6114 p.classList.toggle('active', p.id === 'settings-panel-' + id);
6115 });
6116 if (id === 'team') {
6117 loadTeamRolesList();
6118 loadInvitesList();
6119 }
6120 if (id === 'integrations') {
6121 refreshDevicePendingList();
6122 }
6123 if (id === 'vaults') loadVaultsPanel();
6124 if (id === 'billing') loadBillingPanel();
6125 if (id === 'backup') void refreshBulkDeletePresetDropdowns();
6126 if (id === 'consolidation') loadConsolidationSettings();
6127 if (id === 'integrations') applyMuseBridgePanel(lastBackupSettingsPayload);
6128 if (id === 'automation') loadIngestRulesPanel();
6129 });
6130 });
6131
6132 async function loadIngestRulesPanel() {
6133 const tbody = el('ingest-rules-tbody');
6134 const tmplList = el('ingest-templates-list');
6135 const msg = el('ingest-rules-msg');
6136 if (!tbody) return;
6137 try {
6138 const data = await api('/api/v1/automation/ingest-rules');
6139 const rules = Array.isArray(data.rules) ? data.rules : [];
6140 const templates = Array.isArray(data.templates) ? data.templates : [];
6141 if (!rules.length) {
6142 tbody.innerHTML = '<tr><td colspan="6">No rules yet.</td></tr>';
6143 } else {
6144 tbody.innerHTML = rules.map((r) => {
6145 const match = r.match || {};
6146 const summary = ['credential_name', 'path_prefix', 'content_class', 'intent']
6147 .filter((k) => match[k])
6148 .map((k) => k + '=' + match[k])
6149 .join(', ');
6150 return '<tr data-rule-id="' + String(r.rule_id || '') + '">' +
6151 '<td>' + String(r.label || '') + '</td>' +
6152 '<td>' + summary + '</td>' +
6153 '<td>' + String(r.disposition || '') + '</td>' +
6154 '<td>' + (r.enabled ? 'yes' : 'no') + '</td>' +
6155 '<td><input type="number" class="ingest-rule-priority-input settings-input" min="0" max="10000" value="' + String(r.priority ?? 100) + '" data-rule-id="' + String(r.rule_id || '') + '" /></td>' +
6156 '<td><button type="button" class="btn-secondary btn-ingest-toggle" data-rule-id="' + String(r.rule_id || '') + '">' + (r.enabled ? 'Disable' : 'Enable') + '</button> ' +
6157 '<button type="button" class="btn-secondary btn-ingest-delete" data-rule-id="' + String(r.rule_id || '') + '">Delete</button></td></tr>';
6158 }).join('');
6159 }
6160 if (tmplList) {
6161 tmplList.innerHTML = templates.map((t) =>
6162 '<li>' + String(t.label || t.rule_id) + ' <button type="button" class="btn-secondary btn-ingest-from-template" data-template-id="' + String(t.rule_id || '') + '">Add to my rules</button></li>'
6163 ).join('');
6164 }
6165 tbody.querySelectorAll('.btn-ingest-toggle').forEach((btn) => {
6166 btn.onclick = async () => {
6167 const id = btn.getAttribute('data-rule-id');
6168 const next = rules.map((r) => r.rule_id === id ? { ...r, enabled: !r.enabled } : r);
6169 await api('/api/v1/automation/ingest-rules', { method: 'PUT', body: JSON.stringify({ rules: next }) });
6170 loadIngestRulesPanel();
6171 };
6172 });
6173 tbody.querySelectorAll('.btn-ingest-delete').forEach((btn) => {
6174 btn.onclick = async () => {
6175 const id = btn.getAttribute('data-rule-id');
6176 await api('/api/v1/automation/ingest-rules/' + encodeURIComponent(id), { method: 'DELETE' });
6177 loadIngestRulesPanel();
6178 };
6179 });
6180 tbody.querySelectorAll('.ingest-rule-priority-input').forEach((inp) => {
6181 inp.onchange = async () => {
6182 const id = inp.getAttribute('data-rule-id');
6183 const pri = parseInt(inp.value, 10);
6184 const next = rules.map((r) => r.rule_id === id ? { ...r, priority: pri } : r);
6185 await api('/api/v1/automation/ingest-rules', { method: 'PUT', body: JSON.stringify({ rules: next }) });
6186 loadIngestRulesPanel();
6187 };
6188 });
6189 if (tmplList) {
6190 tmplList.querySelectorAll('.btn-ingest-from-template').forEach((btn) => {
6191 btn.onclick = async () => {
6192 const enable = el('ingest-template-enable') && el('ingest-template-enable').checked;
6193 await api('/api/v1/automation/ingest-rules/from-template', {
6194 method: 'POST',
6195 body: JSON.stringify({ template_id: btn.getAttribute('data-template-id'), enable: Boolean(enable) }),
6196 });
6197 loadIngestRulesPanel();
6198 };
6199 });
6200 }
6201 } catch (e) {
6202 if (msg) msg.textContent = (e && e.message) || 'Could not load ingest rules.';
6203 }
6204 }
6205
6206 const btnIngestRuleSave = el('btn-ingest-rule-save');
6207 if (btnIngestRuleSave) {
6208 btnIngestRuleSave.onclick = async () => {
6209 const msg = el('ingest-rules-msg');
6210 try {
6211 await api('/api/v1/automation/ingest-rules', {
6212 method: 'POST',
6213 body: JSON.stringify({
6214 label: el('ingest-rule-label') ? el('ingest-rule-label').value : '',
6215 priority: el('ingest-rule-priority') ? parseInt(el('ingest-rule-priority').value, 10) : 100,
6216 disposition: el('ingest-rule-disposition') ? el('ingest-rule-disposition').value : 'review_queue',
6217 content_class: el('ingest-rule-content-class') && el('ingest-rule-content-class').value ? el('ingest-rule-content-class').value : null,
6218 match: {
6219 credential_name: el('ingest-rule-match-name') && el('ingest-rule-match-name').value ? el('ingest-rule-match-name').value : null,
6220 path_prefix: el('ingest-rule-match-prefix') && el('ingest-rule-match-prefix').value ? el('ingest-rule-match-prefix').value : null,
6221 },
6222 }),
6223 });
6224 if (msg) msg.textContent = 'Saved.';
6225 loadIngestRulesPanel();
6226 } catch (e) {
6227 if (msg) msg.textContent = (e && e.message) || 'Save failed.';
6228 }
6229 };
6230 }
6231
6232 function formatTokenCount(n) {
6233 if (n == null || !Number.isFinite(Number(n))) return '—';
6234 return Number(n).toLocaleString();
6235 }
6236
6237 function formatTokenCountShort(n) {
6238 if (n == null || !Number.isFinite(Number(n))) return '—';
6239 const v = Number(n);
6240 if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
6241 if (v >= 1_000_000) return (v / 1_000_000).toFixed(0) + 'M';
6242 if (v >= 1_000) return (v / 1_000).toFixed(0) + 'K';
6243 return String(v);
6244 }
6245
6246 /**
6247 * Update the token usage progress bar.
6248 * @param {number} used - tokens used this period
6249 * @param {number|null} included - tokens included (null = unlimited)
6250 */
6251 function updateUsageBar(fillId, used, included) {
6252 const fill = el(fillId);
6253 if (!fill) return;
6254 if (included == null) {
6255 fill.style.width = '15%';
6256 fill.className = 'billing-usage-bar-fill';
6257 return;
6258 }
6259 const pct = included > 0 ? Math.min(100, Math.round((used / included) * 100)) : 0;
6260 fill.style.width = pct + '%';
6261 fill.className =
6262 'billing-usage-bar-fill' + (pct >= 100 ? ' over' : pct >= 80 ? ' warn' : '');
6263 }
6264
6265 const TIER_LABELS = {
6266 free: 'Free',
6267 plus: 'Plus',
6268 growth: 'Growth',
6269 pro: 'Pro',
6270 beta: 'Beta',
6271 starter: 'Plus',
6272 team: 'Team',
6273 };
6274
6275 const TIER_CSS_CLASSES = {
6276 free: 'tier-free',
6277 plus: 'tier-plus',
6278 growth: 'tier-growth',
6279 pro: 'tier-pro',
6280 beta: 'tier-beta',
6281 starter: 'tier-plus',
6282 team: 'tier-pro',
6283 };
6284
6285 const TIER_ORDER = ['free', 'plus', 'growth', 'pro'];
6286
6287 const TIER_PLAN_DATA = [
6288 { tier: 'free', price: 'Free', searches: '100 searches/mo', indexJobs: '5 index jobs/mo', notes: '200 notes', consolidations: null },
6289 { tier: 'plus', price: '$9/mo', searches: '2,000 searches/mo', indexJobs: '50 index jobs/mo', notes: '2,000 notes', consolidations: '30 memory consolidations/mo' },
6290 { tier: 'growth', price: '$17/mo', searches: '8,000 searches/mo', indexJobs: '200 index jobs/mo', notes: '5,000 notes', consolidations: '100 memory consolidations/mo' },
6291 { tier: 'pro', price: '$25/mo', searches: 'Unlimited searches', indexJobs: 'Unlimited index jobs', notes: 'Unlimited notes', consolidations: '300 memory consolidations/mo' },
6292 ];
6293
6294 /** Monthly consolidation pass limit by tier (mirrors billing-constants.mjs). */
6295 const CONSOLIDATION_PASSES_BY_TIER = { free: 0, plus: 30, starter: 30, growth: 100, pro: 300, beta: null };
6296
6297 /**
6298 * Render the plan comparison grid into #billing-plan-grid.
6299 * Highlights the current tier, shows upgrade CTAs for higher tiers, no downgrade buttons.
6300 */
6301 function renderBillingPlanGrid(currentTier, hasSub, stripeConfigured) {
6302 const grid = el('billing-plan-grid');
6303 if (!grid) return;
6304
6305 const normalized =
6306 currentTier === 'starter' ? 'plus'
6307 : (currentTier === 'beta' || !TIER_ORDER.includes(currentTier)) ? 'free'
6308 : currentTier;
6309 const currentRank = TIER_ORDER.indexOf(normalized);
6310
6311 const cards = TIER_PLAN_DATA.map(({ tier, price, searches, indexJobs, notes, consolidations }) => {
6312 const rank = TIER_ORDER.indexOf(tier);
6313 const isCurrent = rank === currentRank;
6314 const isUpgrade = rank > currentRank && stripeConfigured && tier !== 'free';
6315
6316 let ctaHtml = '';
6317 if (isCurrent) {
6318 ctaHtml = '<span class="billing-plan-current-badge">Current plan</span>';
6319 } else if (isUpgrade) {
6320 const label = hasSub
6321 ? 'Upgrade to ' + (TIER_LABELS[tier] || tier) + ' \u2192'
6322 : 'Get ' + (TIER_LABELS[tier] || tier) + ' \u2192';
6323 ctaHtml =
6324 '<button type="button" class="billing-plan-upgrade-btn" data-tier="' +
6325 tier + '">' + label + '</button>';
6326 }
6327
6328 const packLine = tier !== 'free' ? '<li>Token packs available</li>' : '';
6329 const consolLine = consolidations ? '<li>' + consolidations + '</li>' : '';
6330
6331 return (
6332 '<div class="billing-plan-card' + (isCurrent ? ' billing-plan-card-active' : '') + '">' +
6333 '<div class="billing-plan-card-header">' +
6334 '<span class="billing-plan-card-name">' + (TIER_LABELS[tier] || tier) + '</span>' +
6335 '<span class="billing-plan-card-price">' + price + '</span>' +
6336 '</div>' +
6337 '<ul class="billing-plan-card-features">' +
6338 '<li>' + searches + '</li>' +
6339 '<li>' + indexJobs + '</li>' +
6340 '<li>' + notes + '</li>' +
6341 consolLine +
6342 packLine +
6343 '</ul>' +
6344 '<div class="billing-plan-card-cta">' + ctaHtml + '</div>' +
6345 '</div>'
6346 );
6347 });
6348
6349 grid.innerHTML = cards.join('');
6350
6351 grid.querySelectorAll('.billing-plan-upgrade-btn[data-tier]').forEach((btn) => {
6352 btn.addEventListener('click', async () => {
6353 const tier = btn.dataset.tier;
6354 setButtonBusy(btn, true, 'Redirecting\u2026');
6355 try {
6356 await redirectToCheckout({ tier });
6357 } catch (e) {
6358 setButtonBusy(btn, false);
6359 const msg = el('billing-panel-msg');
6360 if (msg) { msg.textContent = e?.message || 'Could not start checkout.'; msg.className = 'settings-intro small err'; }
6361 }
6362 });
6363 });
6364 }
6365
6366 /**
6367 * Redirect to Stripe Checkout for the given price_id (or tier shorthand).
6368 * @param {{ price_id?: string, tier?: string }} opts
6369 */
6370 async function redirectToCheckout(opts) {
6371 const resp = await api('/api/v1/billing/checkout', {
6372 method: 'POST',
6373 headers: { 'Content-Type': 'application/json' },
6374 body: JSON.stringify({
6375 ...opts,
6376 success_url: window.location.origin + window.location.pathname + '?open=billing&checkout=success',
6377 cancel_url: window.location.origin + window.location.pathname + '?open=billing',
6378 }),
6379 });
6380 if (resp && resp.url) {
6381 window.location.href = resp.url;
6382 }
6383 }
6384
6385 /**
6386 * Redirect to Stripe Customer Portal.
6387 */
6388 async function redirectToPortal() {
6389 const resp = await api('/api/v1/billing/portal', {
6390 method: 'POST',
6391 headers: { 'Content-Type': 'application/json' },
6392 body: JSON.stringify({
6393 return_url: window.location.origin + window.location.pathname + '?open=billing',
6394 }),
6395 });
6396 const url = resp && typeof resp.url === 'string' ? resp.url.trim() : '';
6397 if (!url) {
6398 throw new Error(
6399 'Billing portal did not return a URL. In Stripe Dashboard → Settings → Customer portal, activate the portal and save.',
6400 );
6401 }
6402 window.location.assign(url);
6403 }
6404
6405 async function loadBillingPanel() {
6406 const msg = el('billing-panel-msg');
6407 const tierEl = el('billing-tier');
6408 const searchesUsedEl = el('billing-searches-used');
6409 const searchesIncEl = el('billing-searches-included');
6410 const indexJobsUsedEl = el('billing-index-jobs-used');
6411 const indexJobsIncEl = el('billing-index-jobs-included');
6412 const packEl = el('billing-pack-balance');
6413 const packRow = el('billing-pack-balance-row');
6414 const periodEl = el('billing-period');
6415 const renewalEl = el('billing-renewal');
6416 const credEl = el('billing-credits-used');
6417 const credRow = el('billing-credits-row');
6418 const polEl = el('billing-indexing-policy');
6419 const noteCap = el('billing-note-cap');
6420 const refreshBtn = el('btn-billing-refresh');
6421 const upgradeBtn = el('btn-billing-upgrade');
6422 const manageBtn = el('btn-billing-manage');
6423 const packSection = el('billing-pack-section');
6424 if (!tierEl || !searchesUsedEl) return;
6425 if (msg) msg.textContent = '';
6426 if (refreshBtn) setButtonBusy(refreshBtn, true, 'Loading…');
6427
6428 const setDash = () => {
6429 tierEl.textContent = '—';
6430 tierEl.className = 'billing-plan-badge tier-beta';
6431 if (searchesUsedEl) searchesUsedEl.textContent = '—';
6432 if (searchesIncEl) searchesIncEl.textContent = '—';
6433 if (indexJobsUsedEl) indexJobsUsedEl.textContent = '—';
6434 if (indexJobsIncEl) indexJobsIncEl.textContent = '—';
6435 if (packEl) packEl.textContent = '0';
6436 if (packRow) packRow.style.display = 'none';
6437 if (periodEl) periodEl.textContent = '—';
6438 if (renewalEl) renewalEl.textContent = '';
6439 if (credEl) credEl.textContent = '—';
6440 if (credRow) credRow.style.display = 'none';
6441 if (polEl) { polEl.textContent = ''; polEl.style.display = 'none'; }
6442 if (noteCap) noteCap.textContent = '—';
6443 if (packSection) packSection.style.display = 'none';
6444 if (upgradeBtn) upgradeBtn.style.display = 'none';
6445 if (manageBtn) manageBtn.style.display = 'none';
6446 updateUsageBar('billing-searches-bar-fill', 0, 0);
6447 updateUsageBar('billing-index-jobs-bar-fill', 0, 0);
6448 updateUsageBar('billing-consol-bar-fill', 0, 0);
6449 const consolUsedReset = el('billing-consol-used');
6450 const consolIncReset = el('billing-consol-included');
6451 if (consolUsedReset) consolUsedReset.textContent = '—';
6452 if (consolIncReset) consolIncReset.textContent = '—';
6453 renderBillingPlanGrid('beta', false, false);
6454 };
6455
6456 if (!token) {
6457 setDash();
6458 if (msg) msg.textContent = 'Sign in to view billing usage.';
6459 if (refreshBtn) setButtonBusy(refreshBtn, false);
6460 return;
6461 }
6462
6463 try {
6464 const d = await api('/api/v1/billing/summary');
6465 const tier = d.tier != null ? String(d.tier) : 'beta';
6466
6467 // Plan badge
6468 tierEl.textContent = TIER_LABELS[tier] || tier;
6469 tierEl.className = 'billing-plan-badge ' + (TIER_CSS_CLASSES[tier] || 'tier-beta');
6470
6471 // Renewal date
6472 if (renewalEl) {
6473 const pe = d.period_end;
6474 renewalEl.textContent = pe ? 'renews ' + String(pe).slice(0, 10) : '';
6475 }
6476
6477 // Plan comparison grid
6478 const hasSub = Boolean(d.has_active_subscription);
6479 const isFreeTier = tier === 'free' || tier === 'beta';
6480 renderBillingPlanGrid(tier, hasSub, Boolean(d.stripe_configured));
6481
6482 // Legacy upgrade button stays hidden (grid handles upgrades now)
6483 if (upgradeBtn) upgradeBtn.style.display = 'none';
6484 // Manage button: visible for active subscribers to reach the Stripe portal
6485 if (manageBtn) manageBtn.style.display = (hasSub && d.stripe_configured) ? '' : 'none';
6486
6487 // Searches usage bar
6488 const searchesUsed = Math.max(0, Math.floor(Number(d.monthly_searches_used) || 0));
6489 const searchesInc = d.monthly_searches_included ?? null;
6490 if (searchesUsedEl) searchesUsedEl.textContent = searchesUsed.toLocaleString();
6491 if (searchesIncEl) searchesIncEl.textContent = searchesInc == null ? 'Unlimited' : searchesInc.toLocaleString();
6492 updateUsageBar('billing-searches-bar-fill', searchesUsed, searchesInc);
6493
6494 // Index jobs usage bar
6495 const indexJobsUsed = Math.max(0, Math.floor(Number(d.monthly_index_jobs_used) || 0));
6496 const indexJobsInc = d.monthly_index_jobs_included ?? null;
6497 if (indexJobsUsedEl) indexJobsUsedEl.textContent = indexJobsUsed.toLocaleString();
6498 if (indexJobsIncEl) indexJobsIncEl.textContent = indexJobsInc == null ? 'Unlimited' : indexJobsInc.toLocaleString();
6499 updateUsageBar('billing-index-jobs-bar-fill', indexJobsUsed, indexJobsInc);
6500
6501 // Consolidation jobs usage bar
6502 const consolUsed = Math.max(0, Math.floor(Number(d.monthly_consolidation_jobs_used) || 0));
6503 const consolInc = d.monthly_consolidation_jobs_included ?? null;
6504 const consolUsedEl = el('billing-consol-used');
6505 const consolIncEl = el('billing-consol-included');
6506 if (consolUsedEl) consolUsedEl.textContent = consolUsed.toLocaleString();
6507 if (consolIncEl) consolIncEl.textContent = consolInc == null ? 'Unlimited' : consolInc.toLocaleString();
6508 updateUsageBar('billing-consol-bar-fill', consolUsed, consolInc);
6509
6510 // Pack balance
6511 const packBal = Math.max(0, Math.floor(Number(d.pack_indexing_tokens_balance) || 0));
6512 const packConsolPasses = Math.max(0, Math.floor(Number(d.pack_consolidation_passes_balance) || 0));
6513 if (packEl) {
6514 // Show token count + equivalent index jobs and searches (50K tokens/job, 1K tokens/search).
6515 const packIndexJobs = Math.floor(packBal / 50_000).toLocaleString();
6516 const packSearches = Math.floor(packBal / 1_000).toLocaleString();
6517 let packText = formatTokenCountShort(packBal) +
6518 ' rollover tokens (\u2248\u00a0' + packIndexJobs + ' index jobs or ' + packSearches + ' searches)';
6519 if (packConsolPasses > 0) {
6520 packText += ' + ' + packConsolPasses.toLocaleString() + ' consolidation pass' + (packConsolPasses === 1 ? '' : 'es');
6521 }
6522 packEl.textContent = packText;
6523 }
6524 if (packRow) packRow.style.display = (packBal > 0 || packConsolPasses > 0) ? '' : 'none';
6525
6526 // Period
6527 if (periodEl) {
6528 const ps = d.period_start;
6529 const pe = d.period_end;
6530 periodEl.textContent = ps && pe ? `${String(ps).slice(0, 10)} → ${String(pe).slice(0, 10)}` : '—';
6531 }
6532
6533 // Note cap
6534 if (noteCap) {
6535 noteCap.textContent = d.note_cap == null ? 'Unlimited' : d.note_cap.toLocaleString() + ' max';
6536 }
6537
6538 // Legacy credits row (only show if non-zero)
6539 const mu = Number(d.monthly_used_cents) || 0;
6540 const mi = Number(d.monthly_included_effective_cents) || 0;
6541 if (credRow) credRow.style.display = 'none'; // legacy cents ledger not surfaced in UI
6542 if (credEl && (mu > 0 || mi > 0)) {
6543 credEl.textContent = `${(mu / 100).toFixed(2)} / ${(mi / 100).toFixed(2)} credits`;
6544 }
6545
6546 // Token policy
6547 if (polEl) {
6548 const pol = d.indexing_tokens_policy;
6549 if (pol && String(pol).trim()) {
6550 polEl.textContent = String(pol).trim();
6551 polEl.style.display = '';
6552 } else {
6553 polEl.style.display = 'none';
6554 }
6555 }
6556
6557 // Pack section: only show pack purchase when Stripe is configured and user has a paid plan
6558 if (packSection) {
6559 const showPacks = d.stripe_configured && !isFreeTier && hasSub;
6560 packSection.style.display = showPacks ? '' : 'none';
6561 }
6562
6563 if (msg) {
6564 msg.textContent = '';
6565 msg.className = 'settings-intro small muted';
6566 }
6567 } catch (e) {
6568 setDash();
6569 const m = e && e.message ? String(e.message) : String(e);
6570 if (msg) {
6571 msg.textContent =
6572 /\b404\b|Not\s*Found/i.test(m) || /cannot (GET|POST)/i.test(m)
6573 ? 'Billing summary is only available on the hosted gateway (not this self-hosted Hub).'
6574 : m;
6575 msg.className = 'settings-intro small err';
6576 }
6577 }
6578 if (refreshBtn) setButtonBusy(refreshBtn, false);
6579 }
6580
6581 const btnBillingRefresh = el('btn-billing-refresh');
6582 if (btnBillingRefresh) {
6583 btnBillingRefresh.addEventListener('click', () => loadBillingPanel());
6584 }
6585
6586 const btnBillingUpgrade = el('btn-billing-upgrade');
6587 if (btnBillingUpgrade) {
6588 btnBillingUpgrade.addEventListener('click', async () => {
6589 setButtonBusy(btnBillingUpgrade, true, 'Redirecting…');
6590 try {
6591 await redirectToCheckout({ tier: 'plus' });
6592 } catch (e) {
6593 setButtonBusy(btnBillingUpgrade, false);
6594 const packMsg = el('billing-panel-msg');
6595 if (packMsg) { packMsg.textContent = e?.message || 'Could not start checkout.'; packMsg.className = 'settings-intro small err'; }
6596 }
6597 });
6598 }
6599
6600 const btnBillingManage = el('btn-billing-manage');
6601 if (btnBillingManage) {
6602 btnBillingManage.addEventListener('click', async () => {
6603 const panelMsg = el('billing-panel-msg');
6604 if (panelMsg) {
6605 panelMsg.textContent = '';
6606 panelMsg.className = 'settings-intro small muted';
6607 }
6608 setButtonBusy(btnBillingManage, true, 'Redirecting…');
6609 try {
6610 await redirectToPortal();
6611 } catch (e) {
6612 setButtonBusy(btnBillingManage, false);
6613 const errText = e?.message || 'Could not open billing portal.';
6614 if (panelMsg) {
6615 panelMsg.textContent = errText;
6616 panelMsg.className = 'settings-intro small err';
6617 panelMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
6618 }
6619 }
6620 });
6621 }
6622
6623 // Token pack purchase buttons
6624 document.querySelectorAll('.billing-pack-card[data-pack]').forEach((btn) => {
6625 btn.addEventListener('click', async () => {
6626 const pack = btn.dataset.pack;
6627 const packMsgEl = el('billing-pack-msg');
6628 setButtonBusy(btn, true, 'Redirecting…');
6629 if (packMsgEl) packMsgEl.textContent = '';
6630 try {
6631 await redirectToCheckout({ pack_size: pack });
6632 } catch (e) {
6633 setButtonBusy(btn, false);
6634 if (packMsgEl) { packMsgEl.textContent = e?.message || 'Could not start checkout.'; }
6635 }
6636 });
6637 });
6638
6639 /** Human-readable vault list (no raw JSON) — full JSON stays under Advanced. */
6640 function buildVaultListSummaryInnerHtml(vaults, isHosted) {
6641 const arr = Array.isArray(vaults) ? vaults : [];
6642 if (arr.length === 0) {
6643 return isHosted
6644 ? '<p class="muted small">No extra cloud vaults yet beyond <code>default</code> until you add another vault id.</p>'
6645 : '<p class="muted small">No vaults yet — use the form below or <strong>Advanced</strong> JSON, then <strong>Save vault list</strong>.</p>';
6646 }
6647 const items = arr
6648 .map((v) => {
6649 if (!v || v.id == null) return '';
6650 const id = escapeHtml(String(v.id).trim());
6651 const lab =
6652 v.label != null && String(v.label).trim()
6653 ? ' <span class="muted">(' + escapeHtml(String(v.label).trim()) + ')</span>'
6654 : '';
6655 const pathRaw = v.path != null && String(v.path).trim() ? String(v.path).trim() : '';
6656 const pathHtml = pathRaw
6657 ? escapeHtml(pathRaw)
6658 : '<span class="muted">—</span>';
6659 return (
6660 '<li class="vaults-summary-item"><div><code class="vaults-summary-code">' +
6661 id +
6662 '</code>' +
6663 lab +
6664 '</div><div class="vaults-summary-path muted small">' +
6665 pathHtml +
6666 '</div></li>'
6667 );
6668 })
6669 .filter(Boolean)
6670 .join('');
6671 return '<ul class="settings-vaults-summary-list">' + items + '</ul>';
6672 }
6673
6674 function collectVaultIdsForAccessForm(vaults, settingsRes) {
6675 const set = new Set(['default']);
6676 const allowed =
6677 settingsRes && Array.isArray(settingsRes.allowed_vault_ids) ? settingsRes.allowed_vault_ids : [];
6678 allowed.forEach((id) => {
6679 if (id != null && String(id).trim()) set.add(String(id).trim());
6680 });
6681 (vaults || []).forEach((v) => {
6682 if (v && v.id != null && String(v.id).trim()) set.add(String(v.id).trim());
6683 });
6684 return Array.from(set).sort((a, b) => {
6685 if (a === 'default') return -1;
6686 if (b === 'default') return 1;
6687 return a.localeCompare(b);
6688 });
6689 }
6690
6691 function populateHostedTeamUserSelect(selectEl, roleIds, currentUserId, emptyLabel) {
6692 if (!selectEl) return;
6693 const uids = new Set();
6694 (roleIds || []).forEach((id) => {
6695 if (id != null && String(id).trim()) uids.add(String(id).trim());
6696 });
6697 if (currentUserId != null && String(currentUserId).trim()) {
6698 uids.add(String(currentUserId).trim());
6699 }
6700 const sorted = Array.from(uids).sort((a, b) => a.localeCompare(b));
6701 let html = '<option value="">' + escapeHtml(emptyLabel || '— Choose —') + '</option>';
6702 sorted.forEach((uid) => {
6703 html += '<option value="' + escapeHtml(uid) + '">' + escapeHtml(uid) + '</option>';
6704 });
6705 html += '<option value="__other__">' + escapeHtml('Someone else (type User ID)…') + '</option>';
6706 selectEl.innerHTML = html;
6707 }
6708
6709 function renderAccessVaultCheckboxes(vaultIds) {
6710 const wrap = el('access-form-vault-checkboxes');
6711 if (!wrap) return;
6712 if (!vaultIds.length) {
6713 wrap.innerHTML =
6714 '<span class="muted small">No vault ids yet — use <code>default</code> or create another vault above.</span>';
6715 return;
6716 }
6717 wrap.innerHTML = vaultIds
6718 .map((id) => {
6719 const idAttr = escapeHtml(id);
6720 return (
6721 '<label><input type="checkbox" name="hub-access-vault" value="' +
6722 idAttr +
6723 '"> <code>' +
6724 idAttr +
6725 '</code></label>'
6726 );
6727 })
6728 .join('');
6729 }
6730
6731 function parseVaultAccessFromTextarea() {
6732 const accessText = el('vault-access-json');
6733 try {
6734 const access = JSON.parse((accessText && accessText.value) || '{}');
6735 return typeof access === 'object' && access !== null && !Array.isArray(access) ? access : {};
6736 } catch (_) {
6737 return {};
6738 }
6739 }
6740
6741 function refreshAccessRulesSummary(access) {
6742 const wrap = el('access-rules-summary');
6743 if (!wrap) return;
6744 if (typeof access !== 'object' || access === null) access = {};
6745 const keys = Object.keys(access);
6746 if (keys.length === 0) {
6747 wrap.innerHTML =
6748 '<li class="muted">No custom rules. Unlisted users only get the <code>default</code> vault.</li>';
6749 return;
6750 }
6751 wrap.innerHTML = keys
6752 .sort((a, b) => a.localeCompare(b))
6753 .map((uid) => {
6754 const arr = access[uid];
6755 const vaults =
6756 Array.isArray(arr) && arr.length
6757 ? arr.map((x) => escapeHtml(String(x))).join(', ')
6758 : '<span class="muted">(invalid)</span>';
6759 return '<li><code>' + escapeHtml(uid) + '</code> → ' + vaults + '</li>';
6760 })
6761 .join('');
6762 }
6763
6764 function accessFormToggleOtherInput() {
6765 const sel = el('access-form-user-select');
6766 const wrap = el('access-form-user-other-wrap');
6767 const other = el('access-form-user-other');
6768 if (!sel || !wrap) return;
6769 const show = sel.value === '__other__';
6770 wrap.classList.toggle('hidden', !show);
6771 if (!show && other) other.value = '';
6772 }
6773
6774 function accessFormSyncCheckboxesFromAccessJson() {
6775 const sel = el('access-form-user-select');
6776 const other = el('access-form-user-other');
6777 if (!sel) return;
6778 let uid = '';
6779 if (sel.value === '__other__') {
6780 uid = ((other && other.value) || '').trim();
6781 } else {
6782 uid = (sel.value || '').trim();
6783 }
6784 const access = parseVaultAccessFromTextarea();
6785 const allowed = uid && Array.isArray(access[uid]) ? access[uid] : [];
6786 document.querySelectorAll('input[name="hub-access-vault"]').forEach((cb) => {
6787 cb.checked = allowed.indexOf(cb.value) !== -1;
6788 });
6789 }
6790
6791 function getAccessFormResolvedUserId() {
6792 const sel = el('access-form-user-select');
6793 const other = el('access-form-user-other');
6794 if (!sel) return '';
6795 if (sel.value === '__other__') return ((other && other.value) || '').trim();
6796 return (sel.value || '').trim();
6797 }
6798
6799 const accessUserSel = el('access-form-user-select');
6800 if (accessUserSel) {
6801 accessUserSel.addEventListener('change', () => {
6802 accessFormToggleOtherInput();
6803 accessFormSyncCheckboxesFromAccessJson();
6804 });
6805 }
6806 const accessUserOther = el('access-form-user-other');
6807 if (accessUserOther) {
6808 accessUserOther.addEventListener('input', () => {
6809 if (el('access-form-user-select') && el('access-form-user-select').value === '__other__') {
6810 accessFormSyncCheckboxesFromAccessJson();
6811 }
6812 });
6813 }
6814 const scopeUserSelInit = el('scope-form-user-select');
6815 if (scopeUserSelInit) {
6816 scopeUserSelInit.addEventListener('change', () => {
6817 const inp = el('scope-form-user-id');
6818 if (scopeUserSelInit.value === '__other__') {
6819 if (inp) inp.focus();
6820 } else if (scopeUserSelInit.value && inp) {
6821 inp.value = scopeUserSelInit.value;
6822 }
6823 });
6824 }
6825
6826 function populateVaultListExistingSelect(vaults) {
6827 const sel = el('vault-list-form-existing');
6828 if (!sel) return;
6829 let html = '<option value="">New vault</option>';
6830 (vaults || []).forEach((v) => {
6831 if (v && v.id != null && String(v.id).trim()) {
6832 const id = String(v.id).trim();
6833 html += '<option value="' + escapeHtml(id) + '">' + escapeHtml(v.label || id) + '</option>';
6834 }
6835 });
6836 sel.innerHTML = html;
6837 }
6838
6839 function parseVaultsJsonArrayFromTextarea() {
6840 const ta = el('vaults-json');
6841 try {
6842 const arr = JSON.parse((ta && ta.value) || '[]');
6843 return Array.isArray(arr) ? arr : [];
6844 } catch (_) {
6845 return null;
6846 }
6847 }
6848
6849 function fillVaultListFormFromExisting() {
6850 const sel = el('vault-list-form-existing');
6851 const idInp = el('vault-list-form-id');
6852 const pathInp = el('vault-list-form-path');
6853 const labelInp = el('vault-list-form-label');
6854 if (!sel) return;
6855 if (!sel.value) {
6856 if (idInp) {
6857 idInp.value = '';
6858 idInp.readOnly = false;
6859 }
6860 if (pathInp) pathInp.value = '';
6861 if (labelInp) labelInp.value = '';
6862 return;
6863 }
6864 const vaults = parseVaultsJsonArrayFromTextarea();
6865 if (!vaults) return;
6866 const v = vaults.find((x) => x && String(x.id) === sel.value);
6867 if (v) {
6868 if (idInp) {
6869 idInp.value = String(v.id);
6870 idInp.readOnly = true;
6871 }
6872 if (pathInp) pathInp.value = v.path != null ? String(v.path) : '';
6873 if (labelInp) labelInp.value = v.label != null ? String(v.label) : '';
6874 }
6875 }
6876
6877 function toggleVaultsInfoPanel(panelId) {
6878 const panel = el(panelId);
6879 const modal = el('modal-settings');
6880 if (!panel || !modal) return;
6881 const wasHidden = panel.classList.contains('hidden');
6882 modal.querySelectorAll('.settings-info-panel').forEach((p) => p.classList.add('hidden'));
6883 if (wasHidden) panel.classList.remove('hidden');
6884 }
6885
6886 const modalSettingsForVaultsInfo = el('modal-settings');
6887 if (modalSettingsForVaultsInfo) {
6888 modalSettingsForVaultsInfo.addEventListener('click', (e) => {
6889 const infoBtn = e.target.closest('.btn-settings-info');
6890 if (infoBtn && modalSettingsForVaultsInfo.contains(infoBtn)) {
6891 e.stopPropagation();
6892 const tid = infoBtn.getAttribute('data-settings-info-target');
6893 if (tid) toggleVaultsInfoPanel(tid);
6894 return;
6895 }
6896 if (
6897 !e.target.closest('.settings-info-panel') &&
6898 !e.target.closest('.btn-settings-info')
6899 ) {
6900 modalSettingsForVaultsInfo.querySelectorAll('.settings-info-panel').forEach((p) => {
6901 p.classList.add('hidden');
6902 });
6903 }
6904 });
6905 }
6906
6907 const vaultListExistingSel = el('vault-list-form-existing');
6908 if (vaultListExistingSel) {
6909 vaultListExistingSel.addEventListener('change', () => {
6910 fillVaultListFormFromExisting();
6911 const msg = el('vault-list-form-msg');
6912 if (msg) msg.textContent = '';
6913 });
6914 }
6915
6916 const btnVaultListFormApply = el('btn-vault-list-form-apply');
6917 if (btnVaultListFormApply) {
6918 btnVaultListFormApply.onclick = () => {
6919 const msg = el('vault-list-form-msg');
6920 const ta = el('vaults-json');
6921 const idInp = el('vault-list-form-id');
6922 const pathInp = el('vault-list-form-path');
6923 const labelInp = el('vault-list-form-label');
6924 const vaults = parseVaultsJsonArrayFromTextarea();
6925 if (!vaults) {
6926 if (msg) {
6927 msg.textContent = 'Fix JSON under Advanced, or reset to [] and try again.';
6928 msg.className = 'settings-msg err';
6929 }
6930 return;
6931 }
6932 const id = ((idInp && idInp.value) || '').trim();
6933 const path = ((pathInp && pathInp.value) || '').trim();
6934 const label = ((labelInp && labelInp.value) || '').trim();
6935 if (!id || !path) {
6936 if (msg) {
6937 msg.textContent = 'Enter vault id and folder path.';
6938 msg.className = 'settings-msg err';
6939 }
6940 return;
6941 }
6942 const entry = { id, path };
6943 if (label) entry.label = label;
6944 const idx = vaults.findIndex((x) => x && String(x.id) === id);
6945 if (idx >= 0) {
6946 vaults[idx] = Object.assign({}, vaults[idx], entry);
6947 } else {
6948 if (idInp && idInp.readOnly) {
6949 if (msg) {
6950 msg.textContent = 'Pick an existing vault from the menu, or New vault for a new id.';
6951 msg.className = 'settings-msg err';
6952 }
6953 return;
6954 }
6955 vaults.push(entry);
6956 }
6957 if (ta) ta.value = JSON.stringify(vaults, null, 2);
6958 populateVaultListExistingSelect(vaults);
6959 const sel = el('vault-list-form-existing');
6960 if (sel) sel.value = '';
6961 fillVaultListFormFromExisting();
6962 const lc = el('vaults-list-container');
6963 if (lc && !isHostedHubFromSettings()) {
6964 lc.innerHTML = buildVaultListSummaryInnerHtml(vaults, false);
6965 }
6966 if (msg) {
6967 msg.textContent = 'Updated. Click Save vault list to persist.';
6968 msg.className = 'settings-msg ok';
6969 }
6970 };
6971 }
6972
6973 async function loadVaultsPanel() {
6974 const listContainer = el('vaults-list-container');
6975 const serverView = el('vaults-server-view');
6976 const vaultsJson = el('vaults-json');
6977 const accessText = el('vault-access-json');
6978 const scopeText = el('scope-json');
6979 const helpHostedBlock = el('vaults-help-hosted-block');
6980 const helpSelfBlock = el('vaults-help-self-block');
6981 const selfHostedEditors = el('vaults-self-hosted-editors');
6982 const yamlOnly = el('vaults-hub-yaml-only');
6983 const hostedCreate = el('vaults-hosted-create');
6984 const workspacePanel = el('vaults-hosted-workspace');
6985 const workspaceInput = el('workspace-owner-input');
6986 const workspaceMsg = el('workspace-save-msg');
6987 if (listContainer) listContainer.textContent = 'Loading…';
6988 if (serverView) serverView.textContent = 'Loading…';
6989 try {
6990 const settingsRes = await api('/api/v1/settings');
6991 const isHosted = String(settingsRes.vault_path_display || '').toLowerCase() === 'canister';
6992 if (helpHostedBlock) helpHostedBlock.classList.toggle('hidden', !isHosted);
6993 if (helpSelfBlock) helpSelfBlock.classList.toggle('hidden', isHosted);
6994 if (selfHostedEditors) selfHostedEditors.classList.remove('hidden');
6995 if (yamlOnly) yamlOnly.classList.toggle('hidden', isHosted);
6996 const ownerFromSettings =
6997 settingsRes.workspace_owner_id != null && String(settingsRes.workspace_owner_id).trim() !== ''
6998 ? String(settingsRes.workspace_owner_id).trim()
6999 : '';
7000 const meFromSettings = settingsRes.user_id != null ? String(settingsRes.user_id) : '';
7001 const nonOwnerInSharedWorkspace = isHosted && ownerFromSettings && meFromSettings !== ownerFromSettings;
7002 if (hostedCreate) hostedCreate.classList.toggle('hidden', !isHosted || nonOwnerInSharedWorkspace);
7003 const hostedNonOwnerMsg = el('vaults-hosted-create-non-owner');
7004 if (hostedNonOwnerMsg) hostedNonOwnerMsg.classList.toggle('hidden', !isHosted || !nonOwnerInSharedWorkspace);
7005 if (workspacePanel) workspacePanel.classList.toggle('hidden', !isHosted);
7006 const hostedCreateMsg = el('vaults-hosted-create-msg');
7007 if (hostedCreateMsg && isHosted) {
7008 hostedCreateMsg.textContent = '';
7009 hostedCreateMsg.className = 'settings-msg';
7010 }
7011 if (workspaceMsg) {
7012 workspaceMsg.textContent = '';
7013 workspaceMsg.className = 'settings-msg';
7014 }
7015
7016 /** @type {{ vaults?: unknown[] }} */
7017 let vRes = { vaults: [] };
7018 try {
7019 vRes = await api('/api/v1/vaults');
7020 } catch (_) {
7021 vRes = { vaults: [] };
7022 }
7023 /** @type {{ access?: Record<string, unknown> }} */
7024 let aRes = { access: {} };
7025 try {
7026 aRes = await api('/api/v1/vault-access');
7027 } catch (_) {
7028 aRes = { access: {} };
7029 }
7030 /** @type {{ scope?: Record<string, unknown> }} */
7031 let sRes = { scope: {} };
7032 try {
7033 sRes = await api('/api/v1/scope');
7034 } catch (_) {
7035 sRes = { scope: {} };
7036 }
7037
7038 if (isHosted && workspaceInput) {
7039 try {
7040 const w = await api('/api/v1/workspace');
7041 workspaceInput.value = w && w.owner_user_id ? String(w.owner_user_id) : '';
7042 } catch (e) {
7043 workspaceInput.value = '';
7044 if (workspaceMsg) {
7045 workspaceMsg.textContent =
7046 (e && e.message) ||
7047 'Could not load workspace owner. On production this needs the bridge (BRIDGE_URL).';
7048 workspaceMsg.className = 'settings-msg err';
7049 }
7050 }
7051 } else if (workspaceInput && !isHosted) {
7052 workspaceInput.value = '';
7053 }
7054 const vaults = vRes.vaults || [];
7055 if (serverView) {
7056 const uid = settingsRes.user_id != null ? String(settingsRes.user_id) : '—';
7057 const allowed = settingsRes.allowed_vault_ids;
7058 const allowedStr = Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '—';
7059 if (isHosted) {
7060 serverView.innerHTML =
7061 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
7062 escapeHtml(uid) +
7063 '</code> · <strong>Vaults:</strong> <code>' +
7064 escapeHtml(allowedStr) +
7065 '</code> · Cloud storage. Team: workspace owner → invites → access → scope. <strong>Vault</strong> menu when ≥2 ids.</span>';
7066 } else {
7067 const dataDir =
7068 settingsRes.data_dir_display != null ? escapeHtml(String(settingsRes.data_dir_display)) : 'data';
7069 serverView.innerHTML =
7070 '<span class="settings-server-view-compact"><strong>You:</strong> <code>' +
7071 escapeHtml(uid) +
7072 '</code> · <strong>Allowed vaults:</strong> <code>' +
7073 escapeHtml(allowedStr) +
7074 '</code> · <strong>Data:</strong> <code>' +
7075 dataDir +
7076 '</code>. Missing a vault in the header? Fix <strong>Vault access</strong> for your user id.</span>';
7077 }
7078 }
7079 if (listContainer) {
7080 listContainer.innerHTML = buildVaultListSummaryInnerHtml(vaults, isHosted);
7081 }
7082 if (vaultsJson) vaultsJson.value = JSON.stringify(vaults, null, 2);
7083 if (accessText) accessText.value = JSON.stringify(aRes.access || {}, null, 2);
7084 if (scopeText) scopeText.value = JSON.stringify(sRes.scope || {}, null, 2);
7085
7086 const vaultListJsonDetails = el('vault-list-json-details');
7087 if (vaultListJsonDetails) vaultListJsonDetails.open = false;
7088 const vaultAccessDetails = el('vault-access-json-details');
7089 if (vaultAccessDetails) vaultAccessDetails.open = false;
7090 const scopeJsonDetails = el('scope-json-details');
7091 if (scopeJsonDetails) scopeJsonDetails.open = false;
7092
7093 let roleIds = [];
7094 try {
7095 const ro = await api('/api/v1/roles');
7096 roleIds = Object.keys(ro.roles || {});
7097 } catch (_) {
7098 roleIds = [];
7099 }
7100 populateHostedTeamUserSelect(
7101 el('access-form-user-select'),
7102 roleIds,
7103 settingsRes.user_id,
7104 '— Choose a person —',
7105 );
7106 populateHostedTeamUserSelect(
7107 el('scope-form-user-select'),
7108 roleIds,
7109 settingsRes.user_id,
7110 '— Choose or type User ID below —',
7111 );
7112 const asel = el('access-form-user-select');
7113 if (asel) asel.value = '';
7114 const ssel = el('scope-form-user-select');
7115 if (ssel) ssel.value = '';
7116 accessFormToggleOtherInput();
7117 const vaultIdsForForm = collectVaultIdsForAccessForm(vaults, settingsRes);
7118 renderAccessVaultCheckboxes(vaultIdsForForm);
7119 accessFormSyncCheckboxesFromAccessJson();
7120 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
7121
7122 const scopeVaultSelect = el('scope-form-vault-id');
7123 if (scopeVaultSelect) {
7124 scopeVaultSelect.innerHTML =
7125 vaults.length === 0
7126 ? '<option value="default">default</option>'
7127 : vaults.map((v) => '<option value="' + escapeHtml(v.id) + '">' + escapeHtml(v.label || v.id) + '</option>').join('');
7128 }
7129
7130 if (!isHosted) {
7131 populateVaultListExistingSelect(vaults);
7132 const vSel = el('vault-list-form-existing');
7133 if (vSel) vSel.value = '';
7134 fillVaultListFormFromExisting();
7135 }
7136 } catch (e) {
7137 if (listContainer) listContainer.textContent = 'Could not load: ' + (e.message || '');
7138 if (serverView) serverView.textContent = 'Could not load server view: ' + (e.message || '');
7139 }
7140 }
7141
7142 /** Align with bridge/canister: [a-zA-Z0-9_-], max 64; disallow default (already exists). */
7143 function sanitizeNewHostedVaultId(raw) {
7144 const t = String(raw || '').trim();
7145 if (!t) return { error: 'Enter a vault id.' };
7146 let s = t.replace(/[^a-zA-Z0-9_-]/g, '_');
7147 s = s.replace(/_+/g, '_').replace(/^_|_$/g, '');
7148 s = s.slice(0, 64);
7149 if (!s) return { error: 'Use letters, numbers, hyphens, or underscores only.' };
7150 if (s === 'default') {
7151 return { error: 'The default vault already exists — pick another id (e.g. work or personal).' };
7152 }
7153 return { id: s };
7154 }
7155
7156 const btnHostedVaultCreate = el('btn-vaults-hosted-create');
7157 if (btnHostedVaultCreate) {
7158 btnHostedVaultCreate.onclick = async () => {
7159 const msgEl = el('vaults-hosted-create-msg');
7160 const inp = el('vaults-hosted-new-id');
7161 const setCreateVaultMsg = (text, isErr) => {
7162 if (!msgEl) return;
7163 msgEl.textContent = text;
7164 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
7165 };
7166 if (!isHostedHubFromSettings()) {
7167 setCreateVaultMsg('This action is only available on hosted Hub.', true);
7168 return;
7169 }
7170 if (!hubUserCanWriteNotes()) {
7171 setCreateVaultMsg('Your role cannot create notes. Ask an admin to change your role.', true);
7172 return;
7173 }
7174 const ws = lastBackupSettingsPayload;
7175 const ownerId =
7176 ws && ws.workspace_owner_id != null && String(ws.workspace_owner_id).trim() !== ''
7177 ? String(ws.workspace_owner_id).trim()
7178 : '';
7179 const me = ws && ws.user_id != null ? String(ws.user_id) : '';
7180 if (ownerId && me && me !== ownerId) {
7181 setCreateVaultMsg(
7182 '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.',
7183 true,
7184 );
7185 return;
7186 }
7187 const parsed = sanitizeNewHostedVaultId(inp && inp.value);
7188 if (parsed.error) {
7189 setCreateVaultMsg(parsed.error, true);
7190 return;
7191 }
7192 const { id } = parsed;
7193 await withButtonBusy(btnHostedVaultCreate, 'Creating vault…', async () => {
7194 setCreateVaultMsg('');
7195 try {
7196 const fresh = await api('/api/v1/settings');
7197 const allowed = fresh.allowed_vault_ids || [];
7198 if (Array.isArray(allowed) && allowed.includes(id)) {
7199 setCreateVaultMsg('That vault id already exists. Use the Vault dropdown in the left rail to switch to it.', true);
7200 return;
7201 }
7202 const path = 'inbox/.knowtation-vault-bootstrap-' + id + '-' + Date.now() + '.md';
7203 await api('/api/v1/notes', {
7204 method: 'POST',
7205 headers: { 'X-Vault-Id': id },
7206 body: JSON.stringify({
7207 path,
7208 body:
7209 'This note was created when you added the "' +
7210 id +
7211 '" vault in Knowtation Hub (hosted). You can edit or delete it.\n',
7212 frontmatter: { title: 'New vault', tags: ['knowtation-setup'] },
7213 }),
7214 });
7215 hubMarkSemanticIndexStaleForVault(id);
7216 const s = await api('/api/v1/settings');
7217 lastBackupSettingsPayload = s;
7218 if (s.role) window.__hubUserRole = String(s.role);
7219 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7220 applyHostedUiFromSettings(s);
7221 setCurrentVaultId(id);
7222 const sel = el('vault-switcher');
7223 if (sel) sel.value = id;
7224 loadFacets();
7225 loadNotes();
7226 loadProposals();
7227 await loadVaultsPanel();
7228 if (inp) inp.value = '';
7229 setCreateVaultMsg('Vault "' + id + '" created. Use the Vault dropdown in the left rail to switch.', false);
7230 } catch (e) {
7231 setCreateVaultMsg(e.message || 'Could not create vault', true);
7232 }
7233 });
7234 };
7235 }
7236
7237 const btnSettingsDeleteVault = el('btn-settings-delete-vault');
7238 if (btnSettingsDeleteVault) {
7239 btnSettingsDeleteVault.onclick = async () => {
7240 const msgEl = el('settings-delete-vault-msg');
7241 const setVaultDelMsg = (text, isErr) => {
7242 if (!msgEl) return;
7243 msgEl.textContent = text;
7244 msgEl.className = 'settings-msg' + (isErr ? ' err' : ' ok');
7245 };
7246 if (!hubUserMayDeleteVault()) {
7247 setVaultDelMsg('You are not allowed to delete vaults.', true);
7248 return;
7249 }
7250 const sel = el('settings-delete-vault-select');
7251 const vaultId = (sel && sel.value) || '';
7252 const vaultIdTrim = String(vaultId).trim();
7253 if (!vaultIdTrim) {
7254 setVaultDelMsg('Choose a vault to delete.', true);
7255 return;
7256 }
7257 if (vaultIdTrim === 'default') {
7258 setVaultDelMsg('The default vault cannot be deleted.', true);
7259 return;
7260 }
7261 const confirmEl = el('settings-delete-vault-confirm');
7262 const confirmVal = String((confirmEl && confirmEl.value) || '').trim();
7263 if (confirmVal !== 'DELETE VAULT') {
7264 setVaultDelMsg('Type DELETE VAULT exactly to confirm.', true);
7265 return;
7266 }
7267 await withButtonBusy(btnSettingsDeleteVault, 'Deleting…', async () => {
7268 setVaultDelMsg('', false);
7269 try {
7270 await api('/api/v1/vaults/' + encodeURIComponent(vaultIdTrim), {
7271 method: 'DELETE',
7272 headers: { 'X-Vault-Id': vaultIdTrim },
7273 });
7274 const wasCurrent = String(getCurrentVaultId()) === vaultIdTrim;
7275 if (wasCurrent) {
7276 setCurrentVaultId('default');
7277 const vSel = el('vault-switcher');
7278 if (vSel) vSel.value = 'default';
7279 }
7280 const s = await api('/api/v1/settings');
7281 lastBackupSettingsPayload = s;
7282 if (s.role) window.__hubUserRole = String(s.role);
7283 updateVaultSwitcher(s.vault_list || [], s.allowed_vault_ids || []);
7284 applyHostedUiFromSettings(s);
7285 refreshDeleteProjectPanelVisibility();
7286 loadFacets();
7287 loadNotes();
7288 loadProposals();
7289 await loadVaultsPanel();
7290 if (confirmEl) confirmEl.value = '';
7291 setVaultDelMsg('Vault "' + vaultIdTrim + '" was deleted.', false);
7292 } catch (e) {
7293 setVaultDelMsg(e.message || 'Could not delete vault', true);
7294 }
7295 });
7296 };
7297 }
7298
7299 const btnScopeFormApply = el('btn-scope-form-apply');
7300 if (btnScopeFormApply) {
7301 btnScopeFormApply.onclick = () => {
7302 const userId = (el('scope-form-user-id') && el('scope-form-user-id').value || '').trim();
7303 const vaultId = (el('scope-form-vault-id') && el('scope-form-vault-id').value) || 'default';
7304 const projectsStr = (el('scope-form-projects') && el('scope-form-projects').value) || '';
7305 const foldersStr = (el('scope-form-folders') && el('scope-form-folders').value) || '';
7306 const msg = el('scope-form-msg');
7307 if (!userId) {
7308 if (msg) { msg.textContent = 'Enter a user ID.'; msg.className = 'settings-msg err'; }
7309 return;
7310 }
7311 const projects = projectsStr.split(',').map((p) => p.trim()).filter(Boolean);
7312 const folders = foldersStr.split(',').map((f) => f.trim()).filter(Boolean);
7313 const scopeText = el('scope-json');
7314 let scope = {};
7315 if (scopeText && scopeText.value) {
7316 try {
7317 scope = JSON.parse(scopeText.value);
7318 if (typeof scope !== 'object' || scope === null) scope = {};
7319 } catch (_) { scope = {}; }
7320 }
7321 if (!scope[userId]) scope[userId] = {};
7322 scope[userId][vaultId] = { projects, folders };
7323 if (scopeText) scopeText.value = JSON.stringify(scope, null, 2);
7324 if (msg) { msg.textContent = 'Added. Click Save scope to persist.'; msg.className = 'settings-msg ok'; }
7325 };
7326 }
7327
7328 function isHostedHubFromSettings() {
7329 const s = lastBackupSettingsPayload;
7330 return s && String(s.vault_path_display || '').toLowerCase() === 'canister';
7331 }
7332
7333 const BULK_PRESET_EMPTY = '';
7334 const BULK_PRESET_CUSTOM = '__custom__';
7335
7336 function fillBulkPresetSelect(sel, items, includeCustom) {
7337 if (!sel) return;
7338 const preserve = sel.value;
7339 sel.innerHTML = '';
7340 const head = document.createElement('option');
7341 head.value = BULK_PRESET_EMPTY;
7342 head.textContent = '— Select or type below —';
7343 sel.appendChild(head);
7344 for (const item of items) {
7345 if (item == null || item === '') continue;
7346 const o = document.createElement('option');
7347 o.value = item;
7348 o.textContent = item;
7349 sel.appendChild(o);
7350 }
7351 if (includeCustom) {
7352 const c = document.createElement('option');
7353 c.value = BULK_PRESET_CUSTOM;
7354 c.textContent = 'Custom (type below)';
7355 sel.appendChild(c);
7356 }
7357 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
7358 else sel.value = BULK_PRESET_EMPTY;
7359 }
7360
7361 function syncBulkPathPresetSelectToInput(selectEl, inputEl) {
7362 if (!selectEl || !inputEl) return;
7363 const p = (inputEl.value || '').trim();
7364 if (!p) {
7365 selectEl.value = BULK_PRESET_EMPTY;
7366 return;
7367 }
7368 let best = BULK_PRESET_CUSTOM;
7369 let bestLen = -1;
7370 for (const opt of selectEl.options) {
7371 const v = opt.value;
7372 if (!v || v === BULK_PRESET_EMPTY || v === BULK_PRESET_CUSTOM) continue;
7373 if (p === v || p.startsWith(v + '/')) {
7374 if (v.length > bestLen) {
7375 best = v;
7376 bestLen = v.length;
7377 }
7378 }
7379 }
7380 selectEl.value = bestLen >= 0 ? best : BULK_PRESET_CUSTOM;
7381 }
7382
7383 function syncBulkSlugPresetSelectToInput(selectEl, inputEl) {
7384 if (!selectEl || !inputEl) return;
7385 const p = (inputEl.value || '').trim();
7386 if (!p) {
7387 selectEl.value = BULK_PRESET_EMPTY;
7388 return;
7389 }
7390 if ([...selectEl.options].some((opt) => opt.value === p)) selectEl.value = p;
7391 else selectEl.value = BULK_PRESET_CUSTOM;
7392 }
7393
7394 function wireBulkPathPresetPair(selectEl, inputEl) {
7395 if (!selectEl || !inputEl) return;
7396 selectEl.addEventListener('change', () => {
7397 const v = selectEl.value;
7398 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7399 });
7400 inputEl.addEventListener('input', () => syncBulkPathPresetSelectToInput(selectEl, inputEl));
7401 }
7402
7403 function wireBulkSlugPresetPair(selectEl, inputEl) {
7404 if (!selectEl || !inputEl) return;
7405 selectEl.addEventListener('change', () => {
7406 const v = selectEl.value;
7407 if (v && v !== BULK_PRESET_EMPTY && v !== BULK_PRESET_CUSTOM) inputEl.value = v;
7408 });
7409 inputEl.addEventListener('input', () => syncBulkSlugPresetSelectToInput(selectEl, inputEl));
7410 }
7411
7412 let bulkPresetDropdownsToken = 0;
7413 async function refreshBulkDeletePresetDropdowns() {
7414 if (!token) return;
7415 const pathSelect = el('settings-bulk-path-prefix-preset');
7416 const delProjSelect = el('settings-bulk-delete-project-preset');
7417 const renameFromSelect = el('settings-bulk-rename-from-preset');
7418 const pathInput = el('settings-delete-prefix');
7419 const delProjInput = el('settings-delete-project-slug');
7420 const renameFromInput = el('settings-rename-project-from');
7421 if (!pathSelect && !delProjSelect && !renameFromSelect) return;
7422 const my = ++bulkPresetDropdownsToken;
7423 let diskFolders = [];
7424 let facets = { projects: [], folders: [] };
7425 try {
7426 const [vf, fc] = await Promise.all([
7427 api('/api/v1/vault/folders'),
7428 api('/api/v1/notes/facets'),
7429 ]);
7430 if (my !== bulkPresetDropdownsToken) return;
7431 diskFolders = vf && Array.isArray(vf.folders) ? vf.folders : [];
7432 facets = fc && typeof fc === 'object' ? fc : { projects: [], folders: [] };
7433 } catch (_) {
7434 if (my !== bulkPresetDropdownsToken) return;
7435 }
7436 const pathSet = new Set();
7437 for (const f of diskFolders) {
7438 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7439 }
7440 for (const f of facets.folders || []) {
7441 if (f && typeof f === 'string') pathSet.add(f.replace(/\/+$/, '').trim());
7442 }
7443 const rest = [...pathSet].filter((x) => x && x !== 'inbox').sort((a, b) => a.localeCompare(b));
7444 const pathPrefixes = ['inbox', ...rest];
7445 const projects = [
7446 ...new Set((facets.projects || []).map((p) => String(p).trim()).filter(Boolean)),
7447 ].sort((a, b) => a.localeCompare(b));
7448
7449 fillBulkPresetSelect(pathSelect, pathPrefixes, true);
7450 fillBulkPresetSelect(delProjSelect, projects, true);
7451 fillBulkPresetSelect(renameFromSelect, projects, true);
7452
7453 syncBulkPathPresetSelectToInput(pathSelect, pathInput);
7454 syncBulkSlugPresetSelectToInput(delProjSelect, delProjInput);
7455 syncBulkSlugPresetSelectToInput(renameFromSelect, renameFromInput);
7456 }
7457
7458 wireBulkPathPresetPair(el('settings-bulk-path-prefix-preset'), el('settings-delete-prefix'));
7459 wireBulkSlugPresetPair(el('settings-bulk-delete-project-preset'), el('settings-delete-project-slug'));
7460 wireBulkSlugPresetPair(el('settings-bulk-rename-from-preset'), el('settings-rename-project-from'));
7461
7462 const btnDeletePrefix = el('btn-settings-delete-prefix');
7463 if (btnDeletePrefix) {
7464 btnDeletePrefix.onclick = async () => {
7465 const msg = el('settings-delete-prefix-msg');
7466 const prefixEl = el('settings-delete-prefix');
7467 const confirmEl = el('settings-delete-confirm');
7468 if (!hubUserCanWriteNotes()) {
7469 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7470 return;
7471 }
7472 const raw = (prefixEl && prefixEl.value) ? prefixEl.value.trim() : '';
7473 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7474 if (!raw) {
7475 if (msg) { msg.textContent = 'Enter a path prefix (vault-relative).'; msg.className = 'settings-msg err'; }
7476 return;
7477 }
7478 if (conf !== 'DELETE') {
7479 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7480 return;
7481 }
7482 await withButtonBusy(btnDeletePrefix, 'Deleting…', async () => {
7483 try {
7484 const out = await api('/api/v1/notes/delete-by-prefix', {
7485 method: 'POST',
7486 headers: { 'Content-Type': 'application/json' },
7487 body: JSON.stringify({ path_prefix: raw }),
7488 });
7489 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7490 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7491 if (confirmEl) confirmEl.value = '';
7492 if (msg) {
7493 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7494 msg.className = 'settings-msg ok';
7495 }
7496 if (typeof showToast === 'function') {
7497 showToast('Deleted ' + n + ' note(s). Run Re-index if you use semantic search.', false);
7498 }
7499 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7500 loadNotes();
7501 loadFacets();
7502 if (typeof loadProposals === 'function') loadProposals();
7503 void refreshBulkDeletePresetDropdowns();
7504 } catch (e) {
7505 const m = e && e.message ? String(e.message) : String(e);
7506 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7507 }
7508 });
7509 };
7510 }
7511
7512 const btnDeleteByProject = el('btn-settings-delete-by-project');
7513 if (btnDeleteByProject) {
7514 btnDeleteByProject.onclick = async () => {
7515 const msg = el('settings-delete-by-project-msg');
7516 const slugEl = el('settings-delete-project-slug');
7517 const confirmEl = el('settings-delete-project-confirm');
7518 if (!hubUserCanWriteNotes()) {
7519 if (msg) { msg.textContent = 'Your role cannot delete notes.'; msg.className = 'settings-msg err'; }
7520 return;
7521 }
7522 const slug = (slugEl && slugEl.value) ? slugEl.value.trim() : '';
7523 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7524 if (!slug) {
7525 if (msg) { msg.textContent = 'Enter a project slug (same as list/search filter).'; msg.className = 'settings-msg err'; }
7526 return;
7527 }
7528 if (conf !== 'DELETE') {
7529 if (msg) { msg.textContent = 'Type DELETE in the confirmation field.'; msg.className = 'settings-msg err'; }
7530 return;
7531 }
7532 await withButtonBusy(btnDeleteByProject, 'Deleting…', async () => {
7533 try {
7534 const out = await api('/api/v1/notes/delete-by-project', {
7535 method: 'POST',
7536 headers: { 'Content-Type': 'application/json' },
7537 body: JSON.stringify({ project: slug }),
7538 });
7539 const n = out && typeof out.deleted === 'number' ? out.deleted : 0;
7540 const pd = out && typeof out.proposals_discarded === 'number' ? out.proposals_discarded : 0;
7541 if (confirmEl) confirmEl.value = '';
7542 if (msg) {
7543 msg.textContent = 'Removed ' + n + ' note(s)' + (pd ? '; ' + pd + ' proposal(s) discarded' : '') + '.';
7544 msg.className = 'settings-msg ok';
7545 }
7546 if (typeof showToast === 'function') {
7547 showToast('Deleted ' + n + ' note(s) in project. Run Re-index if you use semantic search.', false);
7548 }
7549 if (n > 0 || pd > 0) hubMarkSemanticIndexStale();
7550 loadNotes();
7551 loadFacets();
7552 if (typeof loadProposals === 'function') loadProposals();
7553 void refreshBulkDeletePresetDropdowns();
7554 } catch (e) {
7555 const m = e && e.message ? String(e.message) : String(e);
7556 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7557 }
7558 });
7559 };
7560 }
7561
7562 const btnRenameProject = el('btn-settings-rename-project');
7563 if (btnRenameProject) {
7564 btnRenameProject.onclick = async () => {
7565 const msg = el('settings-rename-project-msg');
7566 const fromEl = el('settings-rename-project-from');
7567 const toEl = el('settings-rename-project-to');
7568 const confirmEl = el('settings-rename-project-confirm');
7569 if (!hubUserCanWriteNotes()) {
7570 if (msg) { msg.textContent = 'Your role cannot edit notes.'; msg.className = 'settings-msg err'; }
7571 return;
7572 }
7573 const from = (fromEl && fromEl.value) ? fromEl.value.trim() : '';
7574 const to = (toEl && toEl.value) ? toEl.value.trim() : '';
7575 const conf = (confirmEl && confirmEl.value) ? confirmEl.value.trim() : '';
7576 if (!from || !to) {
7577 if (msg) { msg.textContent = 'Enter both from and to project slugs.'; msg.className = 'settings-msg err'; }
7578 return;
7579 }
7580 if (conf !== 'RENAME') {
7581 if (msg) { msg.textContent = 'Type RENAME in the confirmation field.'; msg.className = 'settings-msg err'; }
7582 return;
7583 }
7584 await withButtonBusy(btnRenameProject, 'Renaming…', async () => {
7585 try {
7586 const out = await api('/api/v1/notes/rename-project', {
7587 method: 'POST',
7588 headers: { 'Content-Type': 'application/json' },
7589 body: JSON.stringify({ from, to }),
7590 });
7591 const n = out && typeof out.updated === 'number' ? out.updated : 0;
7592 if (confirmEl) confirmEl.value = '';
7593 if (msg) {
7594 msg.textContent = 'Updated project slug on ' + n + ' note(s).';
7595 msg.className = 'settings-msg ok';
7596 }
7597 if (typeof showToast === 'function') {
7598 showToast('Renamed project on ' + n + ' note(s).', false);
7599 }
7600 if (n > 0) hubMarkSemanticIndexStale();
7601 loadNotes();
7602 loadFacets();
7603 void refreshBulkDeletePresetDropdowns();
7604 } catch (e) {
7605 const m = e && e.message ? String(e.message) : String(e);
7606 if (msg) { msg.textContent = m; msg.className = 'settings-msg err'; }
7607 }
7608 });
7609 };
7610 }
7611
7612 const btnVaultsSave = el('btn-vaults-save');
7613 if (btnVaultsSave) btnVaultsSave.onclick = async () => {
7614 const msg = el('vaults-save-msg');
7615 if (isHostedHubFromSettings()) {
7616 if (msg) {
7617 msg.textContent =
7618 'Vault list editing is not available on hosted. Use the canister-backed vault ids and X-Vault-Id (see Settings → Vaults intro).';
7619 msg.className = 'settings-msg err';
7620 }
7621 return;
7622 }
7623 await withButtonBusy(btnVaultsSave, 'Saving…', async () => {
7624 const raw = (el('vaults-json') && el('vaults-json').value) || '[]';
7625 try {
7626 const vaults = JSON.parse(raw);
7627 if (!Array.isArray(vaults)) throw new Error('Must be a JSON array');
7628 await api('/api/v1/vaults', { method: 'POST', body: JSON.stringify({ vaults }) });
7629 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7630 try {
7631 const s = await api('/api/v1/settings');
7632 applySettingsPayloadToHubChrome(s);
7633 } catch (_) {}
7634 loadVaultsPanel();
7635 } catch (e) {
7636 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7637 }
7638 });
7639 };
7640 function validateVaultAccess(access) {
7641 if (typeof access !== 'object' || access === null) return 'Must be a JSON object (e.g. {"user_id": ["default", "work"]}).';
7642 for (const [uid, arr] of Object.entries(access)) {
7643 if (!Array.isArray(arr)) return 'Each value must be an array of vault IDs. Key "' + uid + '" is not.';
7644 if (arr.some((v) => typeof v !== 'string' || !v.trim())) return 'Each vault ID must be a non-empty string.';
7645 }
7646 return null;
7647 }
7648 function validateScope(scope) {
7649 if (typeof scope !== 'object' || scope === null) return 'Must be a JSON object.';
7650 for (const [userId, perVault] of Object.entries(scope)) {
7651 if (typeof perVault !== 'object' || perVault === null || Array.isArray(perVault)) return 'Scope for user "' + userId + '" must be an object (vault_id → { projects, folders }).';
7652 for (const [vaultId, entry] of Object.entries(perVault)) {
7653 if (typeof entry !== 'object' || entry === null) continue;
7654 if (entry.projects != null && !Array.isArray(entry.projects)) return 'Scope "' + userId + '" → "' + vaultId + '": projects must be an array.';
7655 if (entry.folders != null && !Array.isArray(entry.folders)) return 'Scope "' + userId + '" → "' + vaultId + '": folders must be an array.';
7656 }
7657 }
7658 return null;
7659 }
7660 const btnVaultAccessSave = el('btn-vault-access-save');
7661 if (btnVaultAccessSave) btnVaultAccessSave.onclick = async () => {
7662 const msg = el('vault-access-save-msg');
7663 await withButtonBusy(btnVaultAccessSave, 'Saving…', async () => {
7664 const raw = (el('vault-access-json') && el('vault-access-json').value) || '{}';
7665 try {
7666 const access = JSON.parse(raw);
7667 const err = validateVaultAccess(access);
7668 if (err) throw new Error(err);
7669 await api('/api/v1/vault-access', { method: 'POST', body: JSON.stringify({ access }) });
7670 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7671 try {
7672 const s = await api('/api/v1/settings');
7673 applySettingsPayloadToHubChrome(s);
7674 } catch (_) {}
7675 refreshAccessRulesSummary(parseVaultAccessFromTextarea());
7676 } catch (e) {
7677 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7678 }
7679 });
7680 };
7681
7682 const btnAccessFormApply = el('btn-access-form-apply');
7683 if (btnAccessFormApply) {
7684 btnAccessFormApply.onclick = () => {
7685 const msg = el('access-form-msg');
7686 const uid = getAccessFormResolvedUserId();
7687 if (!uid) {
7688 if (msg) {
7689 msg.textContent = 'Choose a person or type a User ID under “Someone else”.';
7690 msg.className = 'settings-msg err';
7691 }
7692 return;
7693 }
7694 const checked = Array.from(
7695 document.querySelectorAll('input[name="hub-access-vault"]:checked'),
7696 ).map((c) => c.value);
7697 if (checked.length === 0) {
7698 if (msg) {
7699 msg.textContent = 'Tick at least one vault.';
7700 msg.className = 'settings-msg err';
7701 }
7702 return;
7703 }
7704 const access = parseVaultAccessFromTextarea();
7705 access[uid] = checked;
7706 const ta = el('vault-access-json');
7707 if (ta) ta.value = JSON.stringify(access, null, 2);
7708 refreshAccessRulesSummary(access);
7709 if (msg) {
7710 msg.textContent =
7711 'Rules updated in the form only. Click the outlined Save vault access button below — nothing is stored until you do.';
7712 msg.className = 'settings-msg ok';
7713 }
7714 };
7715 }
7716
7717 const btnAccessFormRemove = el('btn-access-form-remove-user');
7718 if (btnAccessFormRemove) {
7719 btnAccessFormRemove.onclick = () => {
7720 const msg = el('access-form-msg');
7721 const uid = getAccessFormResolvedUserId();
7722 if (!uid) {
7723 if (msg) {
7724 msg.textContent = 'Choose a person to remove.';
7725 msg.className = 'settings-msg err';
7726 }
7727 return;
7728 }
7729 const access = parseVaultAccessFromTextarea();
7730 if (!Object.prototype.hasOwnProperty.call(access, uid)) {
7731 if (msg) {
7732 msg.textContent = 'No rule for that user.';
7733 msg.className = 'settings-msg err';
7734 }
7735 return;
7736 }
7737 delete access[uid];
7738 const ta = el('vault-access-json');
7739 if (ta) ta.value = JSON.stringify(access, null, 2);
7740 refreshAccessRulesSummary(access);
7741 accessFormSyncCheckboxesFromAccessJson();
7742 if (msg) {
7743 msg.textContent =
7744 'Removed from draft rules only. Click Save vault access below to persist (required).';
7745 msg.className = 'settings-msg ok';
7746 }
7747 };
7748 }
7749
7750 const btnScopeSave = el('btn-scope-save');
7751 if (btnScopeSave) btnScopeSave.onclick = async () => {
7752 const msg = el('scope-save-msg');
7753 await withButtonBusy(btnScopeSave, 'Saving…', async () => {
7754 const raw = (el('scope-json') && el('scope-json').value) || '{}';
7755 try {
7756 const scope = JSON.parse(raw);
7757 const err = validateScope(scope);
7758 if (err) throw new Error(err);
7759 await api('/api/v1/scope', { method: 'POST', body: JSON.stringify({ scope }) });
7760 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
7761 } catch (e) {
7762 if (msg) { msg.textContent = e.message || 'Save failed'; msg.className = 'settings-msg err'; }
7763 }
7764 });
7765 };
7766
7767 const btnWorkspaceUseMe = el('btn-workspace-use-me');
7768 if (btnWorkspaceUseMe) {
7769 btnWorkspaceUseMe.onclick = async () => {
7770 const input = el('workspace-owner-input');
7771 const msg = el('workspace-save-msg');
7772 let uid =
7773 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7774 ? String(lastBackupSettingsPayload.user_id)
7775 : '';
7776 if (!uid) {
7777 try {
7778 const s = await api('/api/v1/settings');
7779 lastBackupSettingsPayload = s;
7780 uid = s.user_id != null ? String(s.user_id) : '';
7781 } catch (e) {
7782 if (msg) {
7783 msg.textContent = e.message || 'Could not load your User ID.';
7784 msg.className = 'settings-msg err';
7785 }
7786 return;
7787 }
7788 }
7789 if (input) input.value = uid;
7790 if (msg) {
7791 msg.textContent = 'Filled with your User ID. Click Save workspace owner when ready.';
7792 msg.className = 'settings-msg ok';
7793 }
7794 };
7795 }
7796
7797 const btnWorkspaceSave = el('btn-workspace-save');
7798 if (btnWorkspaceSave) {
7799 btnWorkspaceSave.onclick = async () => {
7800 const msg = el('workspace-save-msg');
7801 const input = el('workspace-owner-input');
7802 await withButtonBusy(btnWorkspaceSave, 'Saving…', async () => {
7803 try {
7804 const raw = (input && input.value) || '';
7805 const trimmed = raw.trim();
7806 const owner_user_id = trimmed === '' ? null : trimmed;
7807 await api('/api/v1/workspace', {
7808 method: 'POST',
7809 body: JSON.stringify({ owner_user_id }),
7810 });
7811 if (msg) {
7812 msg.textContent = 'Saved.';
7813 msg.className = 'settings-msg ok';
7814 }
7815 } catch (e) {
7816 if (msg) {
7817 msg.textContent = e.message || 'Save failed';
7818 msg.className = 'settings-msg err';
7819 }
7820 }
7821 });
7822 };
7823 }
7824
7825 const btnWorkspaceClear = el('btn-workspace-clear');
7826 if (btnWorkspaceClear) {
7827 btnWorkspaceClear.onclick = async () => {
7828 const msg = el('workspace-save-msg');
7829 const input = el('workspace-owner-input');
7830 await withButtonBusy(btnWorkspaceClear, 'Clearing…', async () => {
7831 try {
7832 await api('/api/v1/workspace', {
7833 method: 'POST',
7834 body: JSON.stringify({ owner_user_id: null }),
7835 });
7836 if (input) input.value = '';
7837 if (msg) {
7838 msg.textContent = 'Cleared — each person uses their own cloud space.';
7839 msg.className = 'settings-msg ok';
7840 }
7841 } catch (e) {
7842 if (msg) {
7843 msg.textContent = e.message || 'Clear failed';
7844 msg.className = 'settings-msg err';
7845 }
7846 }
7847 });
7848 };
7849 }
7850
7851 async function loadInvitesList() {
7852 const listEl = el('invites-pending-list');
7853 if (!listEl) return;
7854 listEl.textContent = 'Loading…';
7855 try {
7856 const out = await api('/api/v1/invites');
7857 const invites = out.invites || [];
7858 if (invites.length === 0) {
7859 listEl.textContent = 'No pending invites. Create a link above.';
7860 } else {
7861 listEl.innerHTML = invites.map((inv) => {
7862 const tokenShort = inv.token.slice(0, 12) + '…';
7863 const exp = inv.expires_at ? inv.expires_at.slice(0, 10) : '';
7864 return '<div class="team-role-row invite-row">' +
7865 '<span>' + escapeHtml(inv.role) + ' · ' + escapeHtml(tokenShort) + (exp ? ' · expires ' + escapeHtml(exp) : '') + '</span>' +
7866 '<button type="button" class="btn-revoke-invite btn-secondary small" data-token="' + escapeHtml(inv.token) + '">Revoke</button>' +
7867 '</div>';
7868 }).join('');
7869 listEl.querySelectorAll('.btn-revoke-invite').forEach((btn) => {
7870 btn.onclick = async () => {
7871 const t = btn.dataset.token;
7872 if (!t) return;
7873 try {
7874 await api('/api/v1/invites/' + encodeURIComponent(t), { method: 'DELETE' });
7875 loadInvitesList();
7876 } catch (e) {
7877 if (typeof showToast === 'function') showToast(e.message || 'Revoke failed', true);
7878 }
7879 };
7880 });
7881 }
7882 } catch (e) {
7883 listEl.textContent = 'Could not load: ' + (e.message || '');
7884 }
7885 }
7886
7887 const btnInviteCreate = el('btn-invite-create');
7888 const inviteLinkBlock = el('invite-link-block');
7889 const inviteLinkUrl = el('invite-link-url');
7890 const inviteCreateMsg = el('invite-create-msg');
7891 if (btnInviteCreate) {
7892 btnInviteCreate.onclick = async () => {
7893 const roleSelect = el('invite-role');
7894 const role = (roleSelect && roleSelect.value) || 'editor';
7895 if (inviteCreateMsg) { inviteCreateMsg.textContent = ''; inviteCreateMsg.className = 'settings-msg'; }
7896 await withButtonBusy(btnInviteCreate, 'Creating…', async () => {
7897 try {
7898 const out = await api('/api/v1/invites', { method: 'POST', body: JSON.stringify({ role }) });
7899 if (inviteLinkUrl) inviteLinkUrl.value = out.invite_url || '';
7900 if (inviteLinkBlock) inviteLinkBlock.classList.remove('hidden');
7901 if (inviteCreateMsg) { inviteCreateMsg.textContent = 'Link created. Copy and share.'; inviteCreateMsg.className = 'settings-msg ok'; }
7902 loadInvitesList();
7903 } catch (e) {
7904 if (inviteCreateMsg) { inviteCreateMsg.textContent = e.message || 'Failed'; inviteCreateMsg.className = 'settings-msg err'; }
7905 }
7906 });
7907 };
7908 }
7909 const btnInviteCopy = el('btn-invite-copy');
7910 if (btnInviteCopy && inviteLinkUrl) {
7911 btnInviteCopy.onclick = () => {
7912 inviteLinkUrl.select();
7913 if (navigator.clipboard && navigator.clipboard.writeText) {
7914 navigator.clipboard.writeText(inviteLinkUrl.value).then(() => {
7915 if (typeof showToast === 'function') showToast('Link copied.');
7916 }).catch(() => {});
7917 }
7918 };
7919 }
7920
7921 function syncTeamAddEvaluatorMayApproveVisibility() {
7922 const wrap = el('team-add-evaluator-may-approve-wrap');
7923 const sel = el('team-role');
7924 if (!wrap || !sel) return;
7925 wrap.classList.toggle('hidden', sel.value !== 'evaluator');
7926 }
7927 const teamRoleSelect = el('team-role');
7928 if (teamRoleSelect) {
7929 teamRoleSelect.addEventListener('change', syncTeamAddEvaluatorMayApproveVisibility);
7930 syncTeamAddEvaluatorMayApproveVisibility();
7931 }
7932
7933 async function loadTeamRolesList() {
7934 const listEl = el('team-roles-list');
7935 if (!listEl) return;
7936 listEl.textContent = 'Loading…';
7937 try {
7938 const out = await api('/api/v1/roles');
7939 const roles = out.roles || {};
7940 const mayMap = out.evaluator_may_approve && typeof out.evaluator_may_approve === 'object' ? out.evaluator_may_approve : {};
7941 const entries = Object.entries(roles);
7942 listEl.innerHTML = '';
7943 if (entries.length === 0) {
7944 listEl.textContent = 'No roles assigned yet. When you add one above, it appears here.';
7945 return;
7946 }
7947 for (const [uid, role] of entries) {
7948 const row = document.createElement('div');
7949 row.className = 'team-role-row team-role-row-flex';
7950 const label = document.createElement('span');
7951 label.innerHTML = escapeHtml(uid) + ' → ' + escapeHtml(role);
7952 row.appendChild(label);
7953 if (role === 'evaluator') {
7954 const explicit = Object.prototype.hasOwnProperty.call(mayMap, uid);
7955 const chk = document.createElement('input');
7956 chk.type = 'checkbox';
7957 chk.title = 'May approve proposals';
7958 chk.checked = Boolean(mayMap[uid]);
7959 chk.addEventListener('change', async () => {
7960 chk.disabled = true;
7961 try {
7962 await api('/api/v1/roles/evaluator-may-approve', {
7963 method: 'POST',
7964 body: JSON.stringify({ user_id: uid, evaluator_may_approve: chk.checked }),
7965 });
7966 } catch (err) {
7967 chk.checked = !chk.checked;
7968 if (typeof showToast === 'function') showToast(err.message || 'Save failed');
7969 } finally {
7970 chk.disabled = false;
7971 }
7972 });
7973 const lab = document.createElement('label');
7974 lab.className = 'team-evaluator-approve-inline';
7975 lab.appendChild(chk);
7976 const sp = document.createElement('span');
7977 sp.textContent = explicit ? ' May approve' : ' May approve (unset: host default if any)';
7978 lab.appendChild(sp);
7979 row.appendChild(lab);
7980 }
7981 listEl.appendChild(row);
7982 }
7983 } catch (e) {
7984 listEl.textContent = 'Could not load: ' + (e.message || '');
7985 }
7986 }
7987
7988 const btnTeamUserUseMe = el('btn-team-user-use-me');
7989 if (btnTeamUserUseMe) {
7990 btnTeamUserUseMe.onclick = async () => {
7991 const userIdInput = el('team-user-id');
7992 const msgEl = el('team-save-msg');
7993 let uid =
7994 lastBackupSettingsPayload && lastBackupSettingsPayload.user_id != null
7995 ? String(lastBackupSettingsPayload.user_id)
7996 : '';
7997 if (!uid) {
7998 try {
7999 const s = await api('/api/v1/settings');
8000 lastBackupSettingsPayload = s;
8001 uid = s.user_id != null ? String(s.user_id) : '';
8002 } catch (e) {
8003 if (msgEl) {
8004 msgEl.textContent = e.message || 'Could not load your User ID.';
8005 msgEl.className = 'settings-msg err';
8006 }
8007 return;
8008 }
8009 }
8010 if (userIdInput) userIdInput.value = uid;
8011 if (msgEl) {
8012 msgEl.textContent = 'Filled with your User ID. Pick a role, then Add / update role.';
8013 msgEl.className = 'settings-msg';
8014 }
8015 };
8016 }
8017
8018 const btnTeamSave = el('btn-team-save');
8019 if (btnTeamSave) {
8020 btnTeamSave.onclick = async () => {
8021 const userIdInput = el('team-user-id');
8022 const roleSelect = el('team-role');
8023 const msgEl = el('team-save-msg');
8024 const userId = (userIdInput && userIdInput.value || '').trim();
8025 const role = (roleSelect && roleSelect.value) || 'editor';
8026 if (!userId) {
8027 if (msgEl) { msgEl.textContent = 'Enter a User ID.'; msgEl.className = 'settings-msg err'; }
8028 return;
8029 }
8030 if (msgEl) msgEl.textContent = '';
8031 await withButtonBusy(btnTeamSave, 'Saving…', async () => {
8032 try {
8033 const body = { user_id: userId, role };
8034 if (role === 'evaluator') {
8035 const cb = el('team-add-evaluator-may-approve');
8036 body.evaluator_may_approve = Boolean(cb && cb.checked);
8037 }
8038 await api('/api/v1/roles', { method: 'POST', body: JSON.stringify(body) });
8039 if (msgEl) { msgEl.textContent = 'Saved. They have role: ' + role + '.'; msgEl.className = 'settings-msg'; }
8040 userIdInput.value = '';
8041 loadTeamRolesList();
8042 } catch (e) {
8043 if (msgEl) { msgEl.textContent = e.message || 'Failed'; msgEl.className = 'settings-msg err'; }
8044 }
8045 });
8046 };
8047 }
8048
8049 const currentAccent = () => {
8050 const inline = document.documentElement.style.getPropertyValue('--accent').trim();
8051 if (inline) return inline;
8052 const fromCss = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
8053 return fromCss || DEFAULT_ACCENT;
8054 };
8055 function accentStringToHex6(str) {
8056 if (!str || typeof str !== 'string') return DEFAULT_ACCENT;
8057 const t = str.trim();
8058 if (/^#[0-9A-Fa-f]{6}$/.test(t)) return t.toLowerCase();
8059 if (/^#[0-9A-Fa-f]{3}$/.test(t)) {
8060 const a = t.slice(1);
8061 return ('#' + a[0] + a[0] + a[1] + a[1] + a[2] + a[2]).toLowerCase();
8062 }
8063 const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(t);
8064 if (m) {
8065 return (
8066 '#' +
8067 [1, 2, 3]
8068 .map((i) => Number(m[i]).toString(16).padStart(2, '0'))
8069 .join('')
8070 ).toLowerCase();
8071 }
8072 return DEFAULT_ACCENT;
8073 }
8074 function updateAccentCustomHexLabel(hex6) {
8075 const out = el('accent-custom-hex');
8076 if (out && hex6) out.textContent = String(hex6).toUpperCase();
8077 }
8078 function setAccentRuntimeOnly(hex) {
8079 if (!hex) return;
8080 document.documentElement.style.setProperty('--accent', hex);
8081 updateAccentCustomHexLabel(accentStringToHex6(hex));
8082 }
8083 let accentIroPicker = null;
8084 let accentIroSuppressChange = false;
8085 function ensureAccentIroPicker() {
8086 if (accentIroPicker) return accentIroPicker;
8087 const mount = el('accent-iro-root');
8088 const Iro = typeof window !== 'undefined' && window.iro;
8089 if (!mount || !Iro || !Iro.ColorPicker) return null;
8090 const brRaw = getComputedStyle(document.documentElement).getPropertyValue('--border');
8091 const br = (brRaw && brRaw.trim()) || '';
8092 const borderColor = br && (br[0] === '#' || br.startsWith('rgb')) ? br : '#2a3f5c';
8093 accentIroPicker = new Iro.ColorPicker(mount, {
8094 width: 280,
8095 color: accentStringToHex6(currentAccent()),
8096 borderWidth: 1,
8097 borderColor,
8098 layout: [
8099 { component: Iro.ui.Box, options: {} },
8100 { component: Iro.ui.Slider, options: { sliderType: 'hue' } },
8101 ],
8102 });
8103 accentIroPicker.on('color:change', (color) => {
8104 if (accentIroSuppressChange) return;
8105 setAccentRuntimeOnly(color.hexString);
8106 document.querySelectorAll('.accent-swatch').forEach((b) => b.classList.remove('active'));
8107 });
8108 accentIroPicker.on('input:end', () => {
8109 if (accentIroSuppressChange) return;
8110 const h = accentIroPicker.color.hexString;
8111 if (h) applyAccent(h);
8112 });
8113 return accentIroPicker;
8114 }
8115 /** iro.js v5 ColorPicker has no `setColor`; use `picker.color.set(hex)`. Kept optional `setColor` for compatibility. */
8116 function setAccentPickerColor(picker, hexNorm) {
8117 if (!picker || !hexNorm) return;
8118 const col = picker.color;
8119 if (col && typeof col.set === 'function') {
8120 col.set(hexNorm);
8121 return;
8122 }
8123 if (typeof picker.setColor === 'function') {
8124 try {
8125 picker.setColor(hexNorm, { silent: true });
8126 } catch (_) {
8127 picker.setColor(hexNorm);
8128 }
8129 }
8130 }
8131 function paintAccentSwatches() {
8132 document.querySelectorAll('.accent-swatch').forEach((btn) => {
8133 const hex = btn.dataset.accent;
8134 if (hex) btn.style.backgroundColor = hex;
8135 });
8136 }
8137 paintAccentSwatches();
8138 document.querySelectorAll('.accent-swatch').forEach((btn) => {
8139 btn.addEventListener('click', () => {
8140 const hex = btn.dataset.accent;
8141 if (hex) {
8142 applyAccent(hex);
8143 const norm = accentStringToHex6(hex);
8144 document.querySelectorAll('.accent-swatch').forEach((b) => {
8145 const bh = b.dataset.accent;
8146 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
8147 });
8148 ensureAccentIroPicker();
8149 if (accentIroPicker) {
8150 accentIroSuppressChange = true;
8151 try {
8152 setAccentPickerColor(accentIroPicker, norm);
8153 } finally {
8154 accentIroSuppressChange = false;
8155 }
8156 }
8157 updateAccentCustomHexLabel(norm);
8158 }
8159 });
8160 });
8161 ensureAccentIroPicker();
8162 function syncAccentUI() {
8163 const norm = accentStringToHex6(currentAccent());
8164 document.querySelectorAll('.accent-swatch').forEach((b) => {
8165 const bh = b.dataset.accent;
8166 b.classList.toggle('active', Boolean(bh) && accentStringToHex6(bh) === norm);
8167 });
8168 ensureAccentIroPicker();
8169 if (accentIroPicker) {
8170 accentIroSuppressChange = true;
8171 try {
8172 setAccentPickerColor(accentIroPicker, norm);
8173 } finally {
8174 accentIroSuppressChange = false;
8175 }
8176 }
8177 updateAccentCustomHexLabel(norm);
8178 }
8179 function currentTheme() {
8180 return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
8181 }
8182 function syncThemeUI() {
8183 const theme = currentTheme();
8184 document.querySelectorAll('.theme-btn').forEach((btn) => {
8185 btn.setAttribute('aria-pressed', btn.dataset.theme === theme ? 'true' : 'false');
8186 });
8187 }
8188 function syncColorPaletteUI() {
8189 const p = currentColorPalette();
8190 document.querySelectorAll('.dashboard-theme-card').forEach((btn) => {
8191 const id = btn.dataset.palette || DEFAULT_COLOR_PALETTE;
8192 btn.setAttribute('aria-checked', id === p ? 'true' : 'false');
8193 });
8194 }
8195 const dashboardThemeGrid = el('dashboard-theme-grid');
8196 if (dashboardThemeGrid) {
8197 dashboardThemeGrid.addEventListener('click', (ev) => {
8198 const card = ev.target && ev.target.closest && ev.target.closest('.dashboard-theme-card');
8199 if (!card || !dashboardThemeGrid.contains(card)) return;
8200 const pid = card.dataset.palette;
8201 if (pid == null) return;
8202 applyColorPalette(pid);
8203 syncColorPaletteUI();
8204 });
8205 }
8206 document.querySelectorAll('.theme-btn').forEach((btn) => {
8207 btn.addEventListener('click', () => {
8208 const theme = btn.dataset.theme;
8209 if (theme) {
8210 applyTheme(theme);
8211 syncThemeUI();
8212 }
8213 });
8214 });
8215 const scrollDashColorsBtn = el('btn-scroll-dashboard-color-theme');
8216 if (scrollDashColorsBtn) {
8217 scrollDashColorsBtn.addEventListener('click', () => {
8218 const target = el('settings-dashboard-color-theme');
8219 if (target && target.scrollIntoView) {
8220 target.scrollIntoView({ behavior: 'smooth', block: 'start' });
8221 }
8222 });
8223 }
8224
8225 el('btn-settings-sync').onclick = async () => {
8226 const syncBtn = el('btn-settings-sync');
8227 const msg = el('settings-sync-msg');
8228 msg.textContent = 'Syncing…';
8229 msg.className = 'settings-msg';
8230 const s = lastBackupSettingsPayload;
8231 const isHosted = s && (String(s.vault_path_display || '').toLowerCase() === 'canister');
8232 const hostedPath = isHosted && s.github_connect_available;
8233 let opts = { method: 'POST' };
8234 if (hostedPath) {
8235 const slug =
8236 normalizeGithubRepoSlug(el('settings-hosted-repo') && el('settings-hosted-repo').value) ||
8237 normalizeGithubRepoSlug(localStorage.getItem(HOSTED_BACKUP_REPO_LS)) ||
8238 normalizeGithubRepoSlug(s.repo);
8239 if (!slug) {
8240 msg.textContent = 'Enter backup repo as owner/repo (e.g. myuser/my-notes).';
8241 msg.className = 'settings-msg err';
8242 return;
8243 }
8244 localStorage.setItem(HOSTED_BACKUP_REPO_LS, slug);
8245 opts.body = JSON.stringify({ repo: slug });
8246 }
8247 setButtonBusy(syncBtn, true, 'Backing up…');
8248 try {
8249 const result = await api('/api/v1/vault/sync', opts);
8250 msg.textContent = result.message || 'Done.';
8251 const initBtnOk = el('btn-vault-git-init');
8252 if (initBtnOk) initBtnOk.classList.add('hidden');
8253 if (hostedPath && s) {
8254 const refreshed = await api('/api/v1/settings');
8255 lastBackupSettingsPayload = refreshed;
8256 const vg = refreshed.vault_git || {};
8257 let gitText = 'Not configured';
8258 if (vg.enabled && vg.has_remote) {
8259 gitText = 'Configured';
8260 if (vg.auto_commit) gitText += ' (auto-commit on)';
8261 if (vg.auto_push) gitText += ', auto-push on';
8262 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8263 el('settings-git-status').textContent = gitText;
8264 const step4 = document.getElementById('setup-step-4');
8265 if (step4) {
8266 const done = !!(vg.enabled && vg.has_remote);
8267 step4.classList.toggle('setup-step-done', done);
8268 const icon = step4.querySelector('.setup-step-icon');
8269 if (icon) icon.textContent = done ? '✓' : '';
8270 }
8271 }
8272 } catch (e) {
8273 msg.textContent = e.message || 'Sync failed';
8274 msg.className = 'settings-msg err';
8275 const initBtn = el('btn-vault-git-init');
8276 if (initBtn) {
8277 const st = lastBackupSettingsPayload;
8278 const hosted =
8279 st && String(st.vault_path_display || '').toLowerCase() === 'canister';
8280 const needInit =
8281 e.code === 'GIT_NOT_INITIALIZED' ||
8282 /not a Git repository/i.test(e.message || '');
8283 initBtn.classList.toggle('hidden', hosted || !needInit);
8284 }
8285 } finally {
8286 setButtonBusy(syncBtn, false);
8287 const st = lastBackupSettingsPayload;
8288 if (syncBtn && st) {
8289 const vg = st.vault_git || {};
8290 const vd = st.vault_path_display || '';
8291 const ih = (vd + '').toLowerCase() === 'canister';
8292 syncBtn.disabled = settingsSyncDisabled(st, vg, ih);
8293 }
8294 }
8295 };
8296 const btnVaultGitInit = el('btn-vault-git-init');
8297 if (btnVaultGitInit) {
8298 btnVaultGitInit.onclick = async () => {
8299 const msg = el('settings-sync-msg');
8300 msg.textContent = 'Initializing Git…';
8301 msg.className = 'settings-msg';
8302 await withButtonBusy(btnVaultGitInit, 'Initializing…', async () => {
8303 try {
8304 const out = await api('/api/v1/vault/git-init', { method: 'POST' });
8305 msg.textContent = out.message || 'Git initialized. Try Back up now.';
8306 msg.className = 'settings-msg ok';
8307 btnVaultGitInit.classList.add('hidden');
8308 } catch (e) {
8309 msg.textContent = e.message || 'Git init failed';
8310 msg.className = 'settings-msg err';
8311 }
8312 });
8313 };
8314 }
8315 const saveSetupBtn = el('btn-settings-save');
8316 if (saveSetupBtn) {
8317 saveSetupBtn.onclick = async () => {
8318 const msg = el('settings-save-msg');
8319 if (msg) {
8320 msg.textContent = 'Saving…';
8321 msg.className = 'settings-msg';
8322 }
8323 const vault_path = (el('setup-vault-path') && el('setup-vault-path').value.trim()) || undefined;
8324 const enabled = el('setup-git-enabled') && el('setup-git-enabled').checked;
8325 const remote = (el('setup-git-remote') && el('setup-git-remote').value.trim()) || '';
8326 await withButtonBusy(saveSetupBtn, 'Saving…', async () => {
8327 try {
8328 await api('/api/v1/setup', {
8329 method: 'POST',
8330 body: JSON.stringify({
8331 vault_path: vault_path || undefined,
8332 vault_git: { enabled, remote: remote || undefined },
8333 }),
8334 });
8335 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.' : '');
8336 if (msg) {
8337 msg.textContent = successText;
8338 msg.className = 'settings-msg ok';
8339 }
8340 if (typeof showToast === 'function') showToast('Setup saved.');
8341 api('/api/v1/settings').then((s) => {
8342 const vd = s.vault_path_display || '—';
8343 const isHostedNow = (vd + '').toLowerCase() === 'canister';
8344 if (el('settings-mode-display')) el('settings-mode-display').textContent = isHostedNow ? 'Hosted (beta)' : 'Self-hosted';
8345 el('settings-vault-display').textContent = vd;
8346 const configureSection = el('settings-configure-backup-section');
8347 const configureHr = el('settings-hr-configure');
8348 if (configureSection) configureSection.style.display = isHostedNow ? 'none' : '';
8349 if (configureHr) configureHr.style.display = isHostedNow ? 'none' : '';
8350 const vg = s.vault_git || {};
8351 let gitText = 'Not configured';
8352 if (vg.enabled && vg.has_remote) {
8353 gitText = 'Configured';
8354 if (vg.auto_commit) gitText += ' (auto-commit on)';
8355 if (vg.auto_push) gitText += ', auto-push on';
8356 } else if (vg.enabled) gitText = 'Enabled but no remote set';
8357 el('settings-git-status').textContent = gitText;
8358 const syncBtn = el('btn-settings-sync');
8359 const isAdmin = s.role === 'admin';
8360 if (syncBtn) syncBtn.disabled = settingsSyncDisabled(s, vg, isHostedNow);
8361 if (msg) {
8362 msg.textContent = successText;
8363 msg.className = 'settings-msg ok';
8364 }
8365 }).catch(() => {});
8366 } catch (e) {
8367 const errMsg = e.message || 'Save failed';
8368 if (msg) {
8369 msg.textContent = errMsg.includes('different role') || errMsg.includes('FORBIDDEN')
8370 ? 'Only admins can save setup. Your role is shown under Status above.'
8371 : errMsg;
8372 msg.className = 'settings-msg err';
8373 }
8374 if (typeof showToast === 'function') showToast(errMsg.includes('different role') || errMsg.includes('FORBIDDEN') ? 'Only admins can save setup.' : errMsg, true);
8375 }
8376 });
8377 };
8378 }
8379
8380 function defaultFullPath() {
8381 const sel = el('full-path-folder');
8382 const folder =
8383 sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8384 return folder + '/note-' + Date.now() + '.md';
8385 }
8386
8387 let fullPathFolderLoadToken = 0;
8388 async function refreshFullPathFolderSelect() {
8389 const sel = el('full-path-folder');
8390 if (!sel || !token) return;
8391 const my = ++fullPathFolderLoadToken;
8392 let folders = ['inbox'];
8393 try {
8394 const data = await api('/api/v1/vault/folders');
8395 if (my !== fullPathFolderLoadToken) return;
8396 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8397 } catch (_) {
8398 if (my !== fullPathFolderLoadToken) return;
8399 }
8400 lastVaultFoldersForCreate = folders.slice();
8401 const preserve = sel.value;
8402 sel.innerHTML = '';
8403 for (const f of folders) {
8404 const o = document.createElement('option');
8405 o.value = f;
8406 o.textContent = f;
8407 sel.appendChild(o);
8408 }
8409 const custom = document.createElement('option');
8410 custom.value = '__custom__';
8411 custom.textContent = 'Custom (type path below)';
8412 sel.appendChild(custom);
8413 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8414 else sel.value = folders[0] || 'inbox';
8415 refreshFullCreateSubrootSelect();
8416 if (el('import-create-project-slug')) refreshImportCreateSubrootSelect();
8417 }
8418
8419 let importVaultFolderLoadToken = 0;
8420 async function refreshImportVaultFolderSelect() {
8421 const sel = el('import-vault-folder');
8422 if (!sel || !token) return;
8423 const my = ++importVaultFolderLoadToken;
8424 let folders = ['inbox'];
8425 try {
8426 const data = await api('/api/v1/vault/folders');
8427 if (my !== importVaultFolderLoadToken) return;
8428 if (data && Array.isArray(data.folders) && data.folders.length) folders = data.folders;
8429 } catch (_) {
8430 if (my !== importVaultFolderLoadToken) return;
8431 }
8432 lastVaultFoldersForCreate = folders.slice();
8433 const preserve = sel.value;
8434 sel.innerHTML = '';
8435 for (const f of folders) {
8436 const o = document.createElement('option');
8437 o.value = f;
8438 o.textContent = f;
8439 sel.appendChild(o);
8440 }
8441 const custom = document.createElement('option');
8442 custom.value = '__custom__';
8443 custom.textContent = 'Custom (type path below)';
8444 sel.appendChild(custom);
8445 if (preserve && [...sel.options].some((opt) => opt.value === preserve)) sel.value = preserve;
8446 else sel.value = folders[0] || 'inbox';
8447 refreshImportCreateSubrootSelect();
8448 if (el('full-create-project-slug')) refreshFullCreateSubrootSelect();
8449 }
8450
8451 function syncFolderSelectToPathInput() {
8452 const pathInput = el('full-path');
8453 const sel = el('full-path-folder');
8454 if (!pathInput || !sel) return;
8455 const p = pathInput.value.trim();
8456 if (!p) return;
8457 let best = '__custom__';
8458 let bestLen = -1;
8459 for (const opt of sel.options) {
8460 const v = opt.value;
8461 if (v === '__custom__') continue;
8462 if (p === v || p.startsWith(v + '/')) {
8463 if (v.length > bestLen) {
8464 best = v;
8465 bestLen = v.length;
8466 }
8467 }
8468 }
8469 sel.value = bestLen >= 0 ? best : '__custom__';
8470 }
8471
8472 /** Keep Project (slug) aligned with projects/<slug>/… vault paths when creating a note. */
8473 function syncFullProjectFromPath() {
8474 const pi = el('full-path');
8475 const fp = el('full-project');
8476 if (!pi || !fp) return;
8477 const slug = projectSlugFromProjectsPath(pi.value.trim());
8478 if (slug) {
8479 fp.value = slug;
8480 fp.readOnly = true;
8481 fp.title = 'Derived from vault path projects/' + slug + '/';
8482 } else {
8483 fp.readOnly = false;
8484 fp.removeAttribute('title');
8485 }
8486 updateFullPathProjectTypoHint();
8487 }
8488
8489 function updateFullPathProjectTypoHint() {
8490 const pi = el('full-path');
8491 const hint = el('full-path-project-typo-hint');
8492 const fixBtn = el('btn-full-path-fix-typo');
8493 if (!pi || !hint) return;
8494 const raw = pi.value.trim();
8495 const sug = projectsPathTypoSuggestion(raw);
8496 if (sug) {
8497 hint.textContent =
8498 'This looks like project/ instead of projects/. Use the plural prefix for the standard layout. Suggested path: ' + sug;
8499 hint.className = 'muted small detail-project-hint warn';
8500 hint.classList.remove('hidden');
8501 if (fixBtn) {
8502 fixBtn.classList.remove('hidden');
8503 fixBtn.onclick = () => {
8504 pi.value = sug;
8505 syncFolderSelectToPathInput();
8506 syncFullCreatePickersFromPath();
8507 syncFullProjectFromPath();
8508 scheduleFullCreateSimilarHint();
8509 };
8510 }
8511 } else {
8512 hint.textContent = '';
8513 hint.className = 'muted small detail-project-hint hidden';
8514 hint.classList.add('hidden');
8515 if (fixBtn) {
8516 fixBtn.classList.add('hidden');
8517 fixBtn.onclick = null;
8518 }
8519 }
8520 }
8521
8522 const fullPathFolderEl = () => el('full-path-folder');
8523 const fullPathInputEl = () => el('full-path');
8524 if (fullPathFolderEl() && fullPathInputEl()) {
8525 fullPathFolderEl().addEventListener('change', () => {
8526 const sel = fullPathFolderEl();
8527 if (!sel || sel.value === '__custom__') return;
8528 fullPathInputEl().value = sel.value + '/note-' + Date.now() + '.md';
8529 syncFullCreatePickersFromPath();
8530 syncFullProjectFromPath();
8531 updateFullPathProjectTypoHint();
8532 scheduleFullCreateSimilarHint();
8533 });
8534 fullPathInputEl().addEventListener('input', () => {
8535 syncFolderSelectToPathInput();
8536 syncFullCreatePickersFromPath();
8537 syncFullProjectFromPath();
8538 updateFullPathProjectTypoHint();
8539 scheduleFullCreateSimilarHint();
8540 });
8541 fullPathInputEl().addEventListener('change', () => {
8542 syncFullCreatePickersFromPath();
8543 updateFullCreateSimilarInlineHint();
8544 });
8545 }
8546
8547 const fullCreateProjectSlugEl = el('full-create-project-slug');
8548 const fullCreateProjectSubEl = el('full-create-project-subroot');
8549 if (fullCreateProjectSlugEl) {
8550 fullCreateProjectSlugEl.addEventListener('change', () => {
8551 refreshFullCreateSubrootSelect();
8552 updateFullCreatePathLayoutVisibility();
8553 const v = fullCreateProjectSlugEl.value;
8554 const pi = el('full-path');
8555 if (v && v !== '__custom__') composeFullPathFromCreatePickers();
8556 else if (v === '' && pi && /^projects\//.test(pi.value.trim())) pi.value = defaultFullPath();
8557 syncFolderSelectToPathInput();
8558 syncFullProjectFromPath();
8559 updateFullPathProjectTypoHint();
8560 scheduleFullCreateSimilarHint();
8561 });
8562 }
8563 if (fullCreateProjectSubEl) {
8564 fullCreateProjectSubEl.addEventListener('change', () => {
8565 composeFullPathFromCreatePickers();
8566 syncFolderSelectToPathInput();
8567 syncFullProjectFromPath();
8568 updateFullPathProjectTypoHint();
8569 scheduleFullCreateSimilarHint();
8570 });
8571 }
8572
8573 const importCreateProjectSlugEl = el('import-create-project-slug');
8574 const importCreateProjectSubEl = el('import-create-project-subroot');
8575 const importVaultFolderEl = el('import-vault-folder');
8576 const importOutputDirEl = el('import-output-dir');
8577 if (importVaultFolderEl) {
8578 importVaultFolderEl.addEventListener('change', () => {
8579 const sel = importVaultFolderEl;
8580 const out = el('import-output-dir');
8581 if (!sel || !out || sel.value === '__custom__') return;
8582 out.value = sel.value;
8583 syncImportPickersFromOutputDir();
8584 });
8585 }
8586 if (importOutputDirEl) {
8587 importOutputDirEl.addEventListener('input', () => {
8588 syncImportFolderSelectToOutputDir();
8589 syncImportPickersFromOutputDir();
8590 });
8591 }
8592 if (importCreateProjectSlugEl) {
8593 importCreateProjectSlugEl.addEventListener('change', () => {
8594 refreshImportCreateSubrootSelect();
8595 updateImportPathLayoutVisibility();
8596 const v = importCreateProjectSlugEl.value;
8597 const out = el('import-output-dir');
8598 if (v && v !== '__custom__') composeImportOutputDirFromPickers();
8599 else if (v === '' && out && /^projects\//.test(out.value.trim())) {
8600 const sel = el('import-vault-folder');
8601 out.value = sel && sel.value && sel.value !== '__custom__' ? sel.value : 'inbox';
8602 }
8603 syncImportFolderSelectToOutputDir();
8604 syncImportPickersFromOutputDir();
8605 });
8606 }
8607 if (importCreateProjectSubEl) {
8608 importCreateProjectSubEl.addEventListener('change', () => {
8609 composeImportOutputDirFromPickers();
8610 syncImportFolderSelectToOutputDir();
8611 syncImportPickersFromOutputDir();
8612 });
8613 }
8614
8615 document.querySelectorAll('.modal-tab').forEach((t) => {
8616 t.onclick = () => {
8617 document.querySelectorAll('.modal-tab').forEach((x) => x.classList.remove('active'));
8618 t.classList.add('active');
8619 const tab = t.dataset.createTab;
8620 el('create-quick').classList.toggle('hidden', tab !== 'quick');
8621 el('create-full').classList.toggle('hidden', tab !== 'full');
8622 if (tab === 'full') {
8623 if (el('full-date') && !el('full-date').value) el('full-date').value = ymd(new Date());
8624 void (async () => {
8625 await refreshFullPathFolderSelect();
8626 if (!lastHubFacets) {
8627 try {
8628 lastHubFacets = await fetchFacetsResolved();
8629 } catch (_) {}
8630 }
8631 hydrateFullCreateProjectSlugSelect(lastHubFacets);
8632 const pi = el('full-path');
8633 if (pi && !pi.value.trim()) pi.value = defaultFullPath();
8634 else syncFolderSelectToPathInput();
8635 syncFullCreatePickersFromPath();
8636 syncFullProjectFromPath();
8637 updateFullPathProjectTypoHint();
8638 updateFullCreateSimilarInlineHint();
8639 })();
8640 }
8641 };
8642 });
8643
8644 el('btn-quick-save').onclick = async () => {
8645 const quickBtn = el('btn-quick-save');
8646 const body = el('quick-body').value.trim();
8647 const msg = el('create-msg-quick');
8648 if (!body) {
8649 msg.textContent = 'Enter some text.';
8650 msg.className = 'create-msg err';
8651 return;
8652 }
8653 const projectRaw = el('quick-project').value.trim();
8654 const pslug = normSlug(projectRaw);
8655 const today = ymd(new Date());
8656 const slug = 'hub_' + Date.now();
8657 const path = pslug ? 'projects/' + pslug + '/inbox/' + slug + '.md' : 'inbox/' + slug + '.md';
8658 const title = body.split('\n')[0].slice(0, 80) || 'Quick capture';
8659 await withButtonBusy(quickBtn, 'Saving…', async () => {
8660 try {
8661 await api('/api/v1/notes', {
8662 method: 'POST',
8663 body: stringifyNotePostPayload(path, body, {
8664 source: 'hub',
8665 date: today,
8666 title,
8667 ...(pslug && { project: pslug }),
8668 }),
8669 });
8670 hubMarkSemanticIndexStale();
8671 msg.textContent = 'Saved: ' + path;
8672 msg.className = 'create-msg ok';
8673 el('quick-body').value = '';
8674 loadFacets();
8675 loadNotes();
8676 closeCreateModal();
8677 } catch (e) {
8678 msg.textContent = e.message;
8679 msg.className = 'create-msg err';
8680 }
8681 });
8682 };
8683
8684 async function submitFullCreateNote() {
8685 const fullBtn = el('btn-full-save');
8686 const notePath = el('full-path').value.trim();
8687 const pathProjFull = projectSlugFromProjectsPath(notePath);
8688 const msg = el('create-msg-full');
8689 if (!notePath) {
8690 msg.textContent = 'Enter a vault path (e.g. inbox/idea.md).';
8691 msg.className = 'create-msg err';
8692 return;
8693 }
8694 const pathTypoSug = projectsPathTypoSuggestion(notePath);
8695 if (pathTypoSug) {
8696 msg.textContent =
8697 'Path uses project/ but the standard prefix is projects/ (plural). Edit the path or click “Use suggested path” under the path field. Suggested: ' +
8698 pathTypoSug;
8699 msg.className = 'create-msg err';
8700 return;
8701 }
8702 if (!notePath.endsWith('.md')) {
8703 msg.textContent = 'Path must end in .md (e.g. inbox/idea.md)';
8704 msg.className = 'create-msg err';
8705 return;
8706 }
8707 if (pendingDuplicateDeleteSource && pendingDuplicateDeleteSource.path) {
8708 const src = String(pendingDuplicateDeleteSource.path).replace(/\\/g, '/');
8709 const dest = notePath.replace(/\\/g, '/');
8710 if (src === dest) {
8711 msg.textContent =
8712 'When duplicating, pick a different path than the original (same path would overwrite the original).';
8713 msg.className = 'create-msg err';
8714 return;
8715 }
8716 }
8717 const slugFromPath = projectSlugFromProjectsPath(notePath);
8718 const projectsForSimilar = (lastHubFacets && lastHubFacets.projects) || [];
8719 const similarGuess =
8720 !fullCreateSimilarOverrideOnce && slugFromPath && notePath.startsWith('projects/')
8721 ? findSimilarFacetProject(slugFromPath, projectsForSimilar)
8722 : null;
8723 if (similarGuess) {
8724 openFullCreateSimilarModal(notePath, similarGuess);
8725 return;
8726 }
8727 fullCreateSimilarOverrideOnce = false;
8728 const title = el('full-title').value.trim();
8729 const body = el('full-body').value;
8730 const project = pathProjFull || el('full-project').value.trim();
8731 const tags = el('full-tags').value.trim();
8732 const dateVal = el('full-date') && el('full-date').value ? el('full-date').value.trim() : ymd(new Date());
8733 const causalChain = el('full-causal-chain') && el('full-causal-chain').value.trim();
8734 const entityRaw = el('full-entity') && el('full-entity').value.trim();
8735 const entity = entityRaw ? entityRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
8736 const episode = el('full-episode') && el('full-episode').value.trim();
8737 const followsRaw = el('full-follows') && el('full-follows').value.trim();
8738 const follows = followsRaw ? (followsRaw.includes(',') ? followsRaw.split(',').map((s) => s.trim()).filter(Boolean) : followsRaw) : undefined;
8739 const fm = {
8740 date: dateVal,
8741 ...(title && { title }),
8742 ...(project && { project }),
8743 ...(tags && { tags }),
8744 ...(causalChain && { causal_chain_id: causalChain }),
8745 ...(entity && entity.length && { entity }),
8746 ...(episode && { episode_id: episode }),
8747 ...(follows && { follows }),
8748 };
8749 const savingLabel = pendingDuplicateDeleteSource ? 'Saving duplicate…' : 'Creating…';
8750 await withButtonBusy(fullBtn, savingLabel, async () => {
8751 try {
8752 await api('/api/v1/notes', { method: 'POST', body: stringifyNotePostPayload(notePath, body, fm) });
8753 hubMarkSemanticIndexStale();
8754 msg.textContent = pendingDuplicateDeleteSource ? 'Saved duplicate: ' + notePath : 'Created: ' + notePath;
8755 msg.className = 'create-msg ok';
8756 const dupSrc = pendingDuplicateDeleteSource;
8757 const delChk = el('duplicate-delete-after-save');
8758 const shouldDeleteOriginal =
8759 dupSrc &&
8760 dupSrc.path &&
8761 delChk &&
8762 delChk.checked &&
8763 String(dupSrc.path).replace(/\\/g, '/') !== notePath.replace(/\\/g, '/');
8764 if (shouldDeleteOriginal) {
8765 try {
8766 await api('/api/v1/notes/' + encodeURIComponent(dupSrc.path), { method: 'DELETE' });
8767 if (typeof showToast === 'function') showToast('Original note deleted');
8768 if (currentOpenNote && currentOpenNote.path === dupSrc.path) closeDetailPanel();
8769 const bcb = el('btn-detail-copy-body');
8770 if (bcb) bcb.classList.add('hidden');
8771 } catch (delErr) {
8772 if (typeof showToast === 'function') {
8773 showToast(
8774 'Duplicate saved but could not delete the original: ' + (delErr.message || String(delErr)),
8775 true,
8776 );
8777 }
8778 }
8779 }
8780 void refreshFullPathFolderSelect().then(() => {
8781 el('full-path').value = defaultFullPath();
8782 syncFolderSelectToPathInput();
8783 syncFullCreatePickersFromPath();
8784 syncFullProjectFromPath();
8785 updateFullCreateSimilarInlineHint();
8786 });
8787 el('full-title').value = '';
8788 el('full-body').value = '';
8789 el('full-project').value = '';
8790 el('full-tags').value = '';
8791 if (el('full-date')) el('full-date').value = '';
8792 if (el('full-causal-chain')) el('full-causal-chain').value = '';
8793 if (el('full-entity')) el('full-entity').value = '';
8794 if (el('full-episode')) el('full-episode').value = '';
8795 if (el('full-follows')) el('full-follows').value = '';
8796 loadFacets();
8797 loadNotes();
8798 closeCreateModal();
8799 } catch (e) {
8800 msg.textContent = e.message;
8801 msg.className = 'create-msg err';
8802 }
8803 });
8804 }
8805
8806 el('btn-full-save').onclick = () => {
8807 void submitFullCreateNote();
8808 };
8809
8810 const modalSimilarBackdrop = el('modal-create-similar-project-backdrop');
8811 const modalSimilarClose = el('modal-create-similar-project-close');
8812 const btnSimilarUseExisting = el('btn-modal-create-similar-use-existing');
8813 const btnSimilarKeep = el('btn-modal-create-similar-keep');
8814 if (modalSimilarBackdrop) modalSimilarBackdrop.onclick = closeFullCreateSimilarModal;
8815 if (modalSimilarClose) modalSimilarClose.onclick = closeFullCreateSimilarModal;
8816 if (btnSimilarUseExisting) {
8817 btnSimilarUseExisting.onclick = () => {
8818 const path = fullCreateSimilarModalPendingPath;
8819 const slug = fullCreateSimilarModalSuggestedSlug;
8820 closeFullCreateSimilarModal();
8821 if (path && slug) {
8822 const pi = el('full-path');
8823 if (pi) {
8824 pi.value = path.replace(/^projects\/[^/]+/, 'projects/' + slug);
8825 syncFolderSelectToPathInput();
8826 syncFullCreatePickersFromPath();
8827 syncFullProjectFromPath();
8828 updateFullPathProjectTypoHint();
8829 updateFullCreateSimilarInlineHint();
8830 }
8831 }
8832 fullCreateSimilarOverrideOnce = false;
8833 void submitFullCreateNote();
8834 };
8835 }
8836 if (btnSimilarKeep) {
8837 btnSimilarKeep.onclick = () => {
8838 closeFullCreateSimilarModal();
8839 fullCreateSimilarOverrideOnce = true;
8840 void submitFullCreateNote();
8841 };
8842 }
8843
8844 function formatDetailReadBody(body, fm) {
8845 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8846 const keys = Object.keys(o);
8847 let text = (body || '') + '\n\n---\n' + JSON.stringify(keys.length ? o : {}, null, 2);
8848 if (keys.length === 0 && hubUserCanWriteNotes()) {
8849 text +=
8850 '\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).';
8851 }
8852 return text;
8853 }
8854
8855 var VIDEO_URL_RE = /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim;
8856 var VIDEO_MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' };
8857
8858 function videoExtToMime(url) {
8859 try {
8860 var ext = new URL(url).pathname.split('.').pop().toLowerCase();
8861 return VIDEO_MIME_MAP[ext] || 'video/mp4';
8862 } catch (_) {
8863 var clean = url.split('?')[0].split('#')[0];
8864 var ext2 = clean.split('.').pop().toLowerCase();
8865 return VIDEO_MIME_MAP[ext2] || 'video/mp4';
8866 }
8867 }
8868
8869 /**
8870 * Ensure standalone video URL lines are surrounded by blank lines in the raw
8871 * markdown BEFORE it is fed to marked. Without this, marked's `breaks: true`
8872 * mode joins adjacent lines (e.g. a video URL followed immediately by image
8873 * markdown) into a single <p>, which prevents the video-URL regex from matching.
8874 */
8875 function isolateVideoUrlLines(md) {
8876 // Match any line whose entire content is a bare https video URL.
8877 // The `m` flag makes ^ / $ match per-line. Insert a blank line before
8878 // and after so marked always puts the URL in its own paragraph.
8879 return md.replace(
8880 /^([ \t]*)(https?:\/\/[^\s]+\.(?:mp4|webm|mov)(?:\?[^\s]*)?)[ \t]*$/gim,
8881 '\n$1$2\n'
8882 );
8883 }
8884
8885 /**
8886 * Replace bare video URLs (on their own line) with <video> elements.
8887 * Handles two forms that marked produces for a bare URL on its own paragraph:
8888 * 1. GFM autolink: <p><a href="URL">URL</a></p>
8889 * 2. Plain text: <p>URL</p>
8890 * Runs before DOMPurify so the sanitiser validates the output.
8891 */
8892 function expandVideoUrls(html) {
8893 var VIDEO_EXT_PAT = /\.(?:mp4|webm|mov)(?:\?[^\s"<#]*)?(?:#[^\s"<]*)?$/i;
8894
8895 // GFM autolink form: <p><a href="URL">...</a></p>
8896 var result = html.replace(
8897 /<p>\s*<a\s+href="(https?:\/\/[^\s"<]+)"[^>]*>[^<]*<\/a>\s*<\/p>/gi,
8898 function (match, url) {
8899 if (!VIDEO_EXT_PAT.test(url)) return match;
8900 var mime = videoExtToMime(url);
8901 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8902 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8903 'Your browser does not support embedded video.</video>';
8904 }
8905 );
8906
8907 // Plain text form: <p>URL</p>
8908 result = result.replace(
8909 /<p>\s*(https?:\/\/[^\s<]+)\s*<\/p>/gi,
8910 function (match, url) {
8911 if (!VIDEO_EXT_PAT.test(url)) return match;
8912 var mime = videoExtToMime(url);
8913 return '<video controls preload="metadata" style="max-width:100%;border-radius:6px">' +
8914 '<source src="' + url.replace(/"/g, '&quot;') + '" type="' + mime + '">' +
8915 'Your browser does not support embedded video.</video>';
8916 }
8917 );
8918
8919 return result;
8920 }
8921
8922 var SANITIZE_OPTS_NOTE = {
8923 ADD_TAGS: ['details', 'summary', 'video', 'source'],
8924 ADD_ATTR: ['controls', 'preload', 'type'],
8925 FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'autoplay'],
8926 ALLOWED_URI_REGEXP: /^(?:https?|mailto|ftp):/i,
8927 };
8928
8929 /**
8930 * Render markdown text as sanitised HTML.
8931 * Uses marked + DOMPurify (both loaded in index.html). Falls back to escaped plain text.
8932 * Blocks javascript: and data: URIs; allows standard https:// image and link URLs.
8933 * Phase 18: bare video URLs (.mp4/.webm/.mov) become inline <video> players.
8934 */
8935 var _imageProxyToken = null;
8936 var _imageProxyTokenExp = 0;
8937
8938 async function getImageProxyToken() {
8939 if (_imageProxyToken && Date.now() < _imageProxyTokenExp) return _imageProxyToken;
8940 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8941 var jwt = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8942 if (!jwt) return '';
8943 try {
8944 var res = await fetch(proxyBase + '/api/v1/vault/image-proxy-token', {
8945 headers: { authorization: 'Bearer ' + jwt },
8946 });
8947 if (!res.ok) return '';
8948 var data = await res.json();
8949 _imageProxyToken = data.token || '';
8950 _imageProxyTokenExp = Date.now() + ((data.expires_in || 240) - 30) * 1000;
8951 return _imageProxyToken;
8952 } catch (_) { return ''; }
8953 }
8954
8955 /**
8956 * Rewrite raw.githubusercontent.com <img> src attributes to go through the
8957 * Hub's image proxy. Uses a short-lived HMAC-signed token (not the session JWT).
8958 * Falls back to no rewrite if no cached image token is available yet.
8959 */
8960 function rewriteGitHubImageUrls(html) {
8961 var tok = _imageProxyToken || '';
8962 if (!tok) {
8963 // Fallback: use session JWT — gateway accepts it via backward-compat path.
8964 tok = (typeof localStorage !== 'undefined' && localStorage.getItem('hub_token')) || '';
8965 }
8966 if (!tok) return html;
8967 var encodedTok = encodeURIComponent(tok);
8968 var proxyBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
8969 return html.replace(
8970 /(<img\b[^>]*?\ssrc=")https?:\/\/raw\.githubusercontent\.com\/([^"]+)"/gi,
8971 function (match, pre, rest) {
8972 var encoded = encodeURIComponent('https://raw.githubusercontent.com/' + rest);
8973 return pre + proxyBase + '/api/v1/vault/image-proxy?url=' + encoded + '&token=' + encodedTok + '"';
8974 }
8975 );
8976 }
8977
8978 function renderNoteMarkdownHtml(md) {
8979 try {
8980 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
8981 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
8982 var withVideo = expandVideoUrls(raw);
8983 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
8984 return rewriteGitHubImageUrls(sanitised);
8985 }
8986 } catch (_) { /* fall through */ }
8987 return '<pre class="note-body-fallback">' + escapeHtml(md || '') + '</pre>';
8988 }
8989
8990 /**
8991 * Build the full read-view HTML for a note: rendered markdown body + collapsible metadata block.
8992 */
8993 function buildNoteReadHtml(body, fm) {
8994 const o = fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : {};
8995 const keys = Object.keys(o);
8996 const bodyHtml = renderNoteMarkdownHtml(body || '');
8997 const metaJson = escapeHtml(JSON.stringify(keys.length ? o : {}, null, 2));
8998 const emptyNote = keys.length === 0 && hubUserCanWriteNotes()
8999 ? '<p class="note-meta-hint">No metadata yet — Edit → Save once to populate tags, date, and provenance.</p>'
9000 : '';
9001 return (
9002 bodyHtml +
9003 '<details class="note-meta-block">' +
9004 '<summary>Metadata</summary>' +
9005 '<pre class="note-meta-pre">' + metaJson + '</pre>' +
9006 emptyNote +
9007 '</details>'
9008 );
9009 }
9010
9011 const SECTION_SOURCE_SCHEMA = 'knowtation.section_source/v0';
9012 const SECTION_SOURCE_FORBIDDEN_KEYS = new Set([
9013 'absolute_path',
9014 'body',
9015 'body_length',
9016 'byte_offset',
9017 'byte_offsets',
9018 'frontmatter',
9019 'line_range',
9020 'line_ranges',
9021 'mcp_resource_uri',
9022 'provider_payload',
9023 'raw_canister_payload',
9024 'resource_uri',
9025 'section_body',
9026 'section_body_length',
9027 'snippet',
9028 'snippets',
9029 ]);
9030
9031 function normalizeSectionSourcePathForUi(path) {
9032 const value = String(path || '').trim();
9033 if (!value) return '';
9034 if (value.includes('\\') || value.includes('\0')) return '';
9035 if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return '';
9036 if (value.split('/').some((part) => part === '..')) return '';
9037 return value;
9038 }
9039
9040 function sectionSourceEndpointForPath(path) {
9041 return '/api/v1/section-source?path=' + encodeURIComponent(path);
9042 }
9043
9044 function sectionSourcePayloadHasForbiddenKeys(value) {
9045 if (!value || typeof value !== 'object') return false;
9046 if (Array.isArray(value)) return value.some((item) => sectionSourcePayloadHasForbiddenKeys(item));
9047 for (const [key, child] of Object.entries(value)) {
9048 if (SECTION_SOURCE_FORBIDDEN_KEYS.has(key)) return true;
9049 if (sectionSourcePayloadHasForbiddenKeys(child)) return true;
9050 }
9051 return false;
9052 }
9053
9054 function normalizeSectionSourceForRender(data) {
9055 if (!data || typeof data !== 'object' || Array.isArray(data)) {
9056 throw new Error('INVALID_SECTION_SOURCE');
9057 }
9058 if (sectionSourcePayloadHasForbiddenKeys(data)) {
9059 throw new Error('INVALID_SECTION_SOURCE');
9060 }
9061 if (data.schema !== SECTION_SOURCE_SCHEMA || !Array.isArray(data.sections)) {
9062 throw new Error('INVALID_SECTION_SOURCE');
9063 }
9064 return {
9065 schema: SECTION_SOURCE_SCHEMA,
9066 path: String(data.path || ''),
9067 title: String(data.title || ''),
9068 truncated: data.truncated === true,
9069 sections: data.sections.map((section) => {
9070 const item = section && typeof section === 'object' && !Array.isArray(section) ? section : {};
9071 const normalized = {
9072 section_id: String(item.section_id || ''),
9073 heading_id: String(item.heading_id || ''),
9074 level: Number.isInteger(item.level) ? item.level : Number.parseInt(String(item.level || '0'), 10) || 0,
9075 heading_path: Array.isArray(item.heading_path) ? item.heading_path.map((part) => String(part)) : [],
9076 heading_text: String(item.heading_text || ''),
9077 child_section_ids: Array.isArray(item.child_section_ids)
9078 ? item.child_section_ids.map((childId) => String(childId))
9079 : [],
9080 body_available: item.body_available === true,
9081 body_returned: item.body_returned === true,
9082 snippet_returned: item.snippet_returned === true,
9083 };
9084 if (normalized.body_returned || normalized.snippet_returned) {
9085 throw new Error('INVALID_SECTION_SOURCE');
9086 }
9087 return normalized;
9088 }),
9089 };
9090 }
9091
9092 function resetDetailSectionSourceState() {
9093 hubSectionSourceSeq += 1;
9094 document.querySelectorAll('[data-section-source-panel]').forEach((panel) => panel.remove());
9095 }
9096
9097 function setSectionSourcePanelState(panel, state, message) {
9098 panel.className = 'section-source-panel section-source-panel-' + state;
9099 panel.setAttribute('role', state === 'error' ? 'alert' : 'region');
9100 panel.setAttribute('aria-label', 'Body-free section list');
9101 panel.setAttribute('aria-live', 'polite');
9102 panel.replaceChildren();
9103 const text = document.createElement('p');
9104 text.className = 'section-source-state';
9105 text.textContent = message;
9106 panel.appendChild(text);
9107 }
9108
9109 function sectionSourceErrorMessage(error) {
9110 const code = error && error.code ? String(error.code) : '';
9111 const message = error && error.message ? String(error.message) : '';
9112 if (code === 'INVALID_PATH') return 'Sections are unavailable for this note path.';
9113 if (code === 'NOT_FOUND') return 'Sections are unavailable because the note was not found.';
9114 if (code === 'FORBIDDEN') return 'Sections are unavailable for this session.';
9115 if (message === 'Unauthorized') return 'Sign in to view sections.';
9116 return 'Sections are unavailable right now.';
9117 }
9118
9119 function appendSectionSourceDebugRow(list, labelText, valueText) {
9120 const label = document.createElement('dt');
9121 label.textContent = labelText;
9122 const value = document.createElement('dd');
9123 value.textContent = valueText;
9124 list.append(label, value);
9125 }
9126
9127 function renderSectionSourceData(panel, source) {
9128 panel.className = 'section-source-panel';
9129 panel.setAttribute('role', 'region');
9130 panel.setAttribute('aria-label', 'Body-free section list');
9131 panel.setAttribute('aria-live', 'polite');
9132 panel.replaceChildren();
9133
9134 const header = document.createElement('div');
9135 header.className = 'section-source-header';
9136 const title = document.createElement('h3');
9137 title.textContent = 'Sections';
9138 const meta = document.createElement('p');
9139 meta.className = 'muted small';
9140 meta.textContent = source.title ? source.title + ' · ' + source.path : source.path;
9141 header.append(title, meta);
9142 panel.appendChild(header);
9143
9144 if (source.truncated) {
9145 const truncated = document.createElement('p');
9146 truncated.className = 'section-source-state section-source-truncated';
9147 truncated.textContent = 'Section list is capped for display.';
9148 panel.appendChild(truncated);
9149 }
9150
9151 if (source.sections.length === 0) {
9152 const empty = document.createElement('p');
9153 empty.className = 'section-source-state';
9154 empty.textContent = 'No headings are available for this note.';
9155 panel.appendChild(empty);
9156 return;
9157 }
9158
9159 const list = document.createElement('ol');
9160 list.className = 'section-source-list';
9161 for (const section of source.sections) {
9162 const item = document.createElement('li');
9163 item.className = 'section-source-item section-source-level-' + Math.min(Math.max(section.level, 1), 6);
9164
9165 const heading = document.createElement('p');
9166 heading.className = 'section-source-heading';
9167 const levelBadge = document.createElement('span');
9168 levelBadge.className = 'section-source-level-label';
9169 levelBadge.textContent = 'H' + section.level;
9170 const headingText = document.createElement('span');
9171 headingText.className = 'section-source-heading-text';
9172 headingText.textContent = section.heading_text || '(Untitled section)';
9173 heading.append(levelBadge, headingText);
9174 item.appendChild(heading);
9175
9176 const detail = document.createElement('p');
9177 detail.className = 'section-source-detail muted small';
9178 detail.textContent = 'Heading level: H' + section.level;
9179 item.appendChild(detail);
9180
9181 const pathLine = document.createElement('p');
9182 pathLine.className = 'section-source-path muted small';
9183 pathLine.textContent =
9184 'Heading path: ' +
9185 (section.heading_path.length > 0 ? section.heading_path.join(' / ') : section.heading_text || '(Untitled section)');
9186 item.appendChild(pathLine);
9187
9188 const childLine = document.createElement('p');
9189 childLine.className = 'section-source-children muted small';
9190 childLine.textContent = 'Child sections: ' + section.child_section_ids.length;
9191 item.appendChild(childLine);
9192
9193 const debugDetails = document.createElement('details');
9194 debugDetails.className = 'section-source-debug muted small';
9195 const debugSummary = document.createElement('summary');
9196 debugSummary.textContent = 'IDs';
9197 const debugList = document.createElement('dl');
9198 debugList.className = 'section-source-debug-list';
9199 appendSectionSourceDebugRow(debugList, 'Section ID', section.section_id || 'Unavailable');
9200 appendSectionSourceDebugRow(debugList, 'Heading ID', section.heading_id || 'Unavailable');
9201 appendSectionSourceDebugRow(
9202 debugList,
9203 'Child IDs',
9204 section.child_section_ids.length > 0 ? section.child_section_ids.join(', ') : 'None',
9205 );
9206 debugDetails.append(debugSummary, debugList);
9207 item.appendChild(debugDetails);
9208
9209 list.appendChild(item);
9210 }
9211 panel.appendChild(list);
9212 }
9213
9214 async function loadSectionSourceForCurrentNote(actionsEl, button) {
9215 let panel = actionsEl.querySelector('[data-section-source-panel]');
9216 if (!panel) {
9217 panel = document.createElement('div');
9218 panel.dataset.sectionSourcePanel = 'true';
9219 actionsEl.appendChild(panel);
9220 }
9221 const path = normalizeSectionSourcePathForUi(currentOpenNote && currentOpenNote.path);
9222 if (!path) {
9223 setSectionSourcePanelState(panel, 'error', 'Sections are unavailable for this note path.');
9224 return;
9225 }
9226 const seq = ++hubSectionSourceSeq;
9227 const openPath = currentOpenNote.path;
9228 setSectionSourcePanelState(panel, 'loading', 'Loading sections...');
9229 if (button) {
9230 button.disabled = true;
9231 button.setAttribute('aria-expanded', 'true');
9232 }
9233 try {
9234 const data = await api(sectionSourceEndpointForPath(path), { method: 'GET' });
9235 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9236 renderSectionSourceData(panel, normalizeSectionSourceForRender(data));
9237 } catch (error) {
9238 if (seq !== hubSectionSourceSeq || !currentOpenNote || currentOpenNote.path !== openPath) return;
9239 setSectionSourcePanelState(panel, 'error', sectionSourceErrorMessage(error));
9240 } finally {
9241 if (button && currentOpenNote && currentOpenNote.path === openPath) {
9242 button.disabled = false;
9243 }
9244 }
9245 }
9246
9247 function toggleSectionSourcePanel(actionsEl, button) {
9248 const panel = actionsEl.querySelector('[data-section-source-panel]');
9249 if (panel) {
9250 hubSectionSourceSeq += 1;
9251 panel.remove();
9252 if (button) button.setAttribute('aria-expanded', 'false');
9253 return;
9254 }
9255 void loadSectionSourceForCurrentNote(actionsEl, button);
9256 }
9257
9258 function createSectionSourceButton(actionsEl) {
9259 const sectionBtn = document.createElement('button');
9260 sectionBtn.type = 'button';
9261 sectionBtn.textContent = 'Sections';
9262 sectionBtn.className = 'btn-section-source';
9263 sectionBtn.setAttribute('aria-expanded', 'false');
9264 sectionBtn.setAttribute('aria-controls', 'detail-actions');
9265 sectionBtn.title = 'Show body-free section headings for this note';
9266 sectionBtn.onclick = () => toggleSectionSourcePanel(actionsEl, sectionBtn);
9267 return sectionBtn;
9268 }
9269
9270 function switchNoteToReadMode() {
9271 if (!currentOpenNote) return;
9272 resetDetailSectionSourceState();
9273 teardownDetailEditBodyLayout();
9274 const bodyEl = el('detail-body');
9275 const actionsEl = el('detail-actions');
9276 bodyEl.innerHTML = buildNoteReadHtml(currentOpenNote.body, currentOpenNote.frontmatter);
9277 bodyEl.className = 'note-rendered-body';
9278 actionsEl.innerHTML = '';
9279 attachNoteDetailReadActions(actionsEl);
9280 const bcbRead = el('btn-detail-copy-body');
9281 if (bcbRead) bcbRead.classList.remove('hidden');
9282 }
9283
9284 async function deleteOpenNote() {
9285 if (!currentOpenNote) return;
9286 if (!confirm('Permanently delete this note from the vault? This cannot be undone.')) return;
9287 const p = currentOpenNote.path;
9288 try {
9289 await api('/api/v1/notes/' + encodeURIComponent(p), { method: 'DELETE' });
9290 if (typeof showToast === 'function') showToast('Note deleted');
9291 hubMarkSemanticIndexStale();
9292 currentOpenNote = null;
9293 currentNotePathForCopy = '';
9294 resetDetailSectionSourceState();
9295 teardownDetailEditBodyLayout();
9296 hideDetailPanelChrome();
9297 el('btn-copy-path').classList.add('hidden');
9298 const bcbDel = el('btn-detail-copy-body');
9299 if (bcbDel) bcbDel.classList.add('hidden');
9300 loadNotes();
9301 loadFacets();
9302 } catch (e) {
9303 if (typeof showToast === 'function') showToast('Delete failed: ' + (e.message || String(e)), true);
9304 }
9305 }
9306
9307 function attachNoteDetailReadActions(actionsEl) {
9308 const exportBtn = document.createElement('button');
9309 exportBtn.type = 'button';
9310 exportBtn.textContent = 'Export';
9311 exportBtn.onclick = () => exportCurrentNote('md');
9312 const sectionBtn = createSectionSourceButton(actionsEl);
9313
9314 if (hubUserCanWriteNotes()) {
9315 const editBtn = document.createElement('button');
9316 editBtn.type = 'button';
9317 editBtn.textContent = 'Edit';
9318 editBtn.onclick = () => switchNoteToEditMode();
9319 const dupBtn = document.createElement('button');
9320 dupBtn.type = 'button';
9321 dupBtn.textContent = 'Duplicate…';
9322 dupBtn.title =
9323 'Open New note (full) with this content and a suggested new path; optional delete of the original after save.';
9324 dupBtn.onclick = () => {
9325 void openDuplicateNoteModal();
9326 };
9327 const proposeBtn = document.createElement('button');
9328 proposeBtn.type = 'button';
9329 proposeBtn.textContent = 'Propose change';
9330 proposeBtn.onclick = () => {
9331 if (!currentOpenNote) return;
9332 openCreateProposalModal({
9333 path: currentOpenNote.path,
9334 body: currentOpenNote.body || '',
9335 fromNote: true,
9336 });
9337 };
9338 const delBtn = document.createElement('button');
9339 delBtn.type = 'button';
9340 delBtn.textContent = 'Delete';
9341 delBtn.onclick = () => deleteOpenNote();
9342 if (hubHasMultipleVaultsForCopy()) {
9343 const copyVaultBtn = document.createElement('button');
9344 copyVaultBtn.type = 'button';
9345 copyVaultBtn.textContent = 'Copy to vault…';
9346 copyVaultBtn.onclick = () => openCopyNoteToVaultModal();
9347 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, copyVaultBtn, exportBtn);
9348 } else {
9349 actionsEl.append(editBtn, dupBtn, proposeBtn, sectionBtn, delBtn, exportBtn);
9350 }
9351 return;
9352 }
9353
9354 if (hubUserMayProposeFromNote()) {
9355 const proposeBtn = document.createElement('button');
9356 proposeBtn.type = 'button';
9357 proposeBtn.textContent = 'Propose change';
9358 proposeBtn.onclick = () => {
9359 if (!currentOpenNote) return;
9360 openCreateProposalModal({
9361 path: currentOpenNote.path,
9362 body: currentOpenNote.body || '',
9363 fromNote: true,
9364 });
9365 };
9366 actionsEl.appendChild(proposeBtn);
9367 }
9368 actionsEl.appendChild(sectionBtn);
9369 if (hubUserCanExportNote()) {
9370 actionsEl.appendChild(exportBtn);
9371 }
9372 if (window.__hubUserRole === 'viewer' && hubUserCanExportNote()) {
9373 const hint = document.createElement('p');
9374 hint.className = 'muted small';
9375 hint.style.marginTop = '0.5rem';
9376 hint.textContent =
9377 'Viewer access: you can read and export. Ask a workspace admin for editor access to change notes directly.';
9378 actionsEl.appendChild(hint);
9379 }
9380 }
9381
9382 function openCopyNoteToVaultModal() {
9383 if (!currentOpenNote || !token) return;
9384 if (!hubHasMultipleVaultsForCopy()) {
9385 if (typeof showToast === 'function') showToast('At least two vaults are required.', true);
9386 return;
9387 }
9388 const existing = document.getElementById('modal-copy-note-vault');
9389 if (existing) existing.remove();
9390 const s = lastBackupSettingsPayload;
9391 const allowed = new Set((s.allowed_vault_ids || []).map(String));
9392 const vaultList = (s.vault_list || []).filter((v) => v && v.id != null && allowed.has(String(v.id)));
9393 const fromId = String(getCurrentVaultId() || 'default');
9394 const targets = vaultList.filter((v) => String(v.id) !== fromId);
9395 if (targets.length === 0) {
9396 if (typeof showToast === 'function') showToast('No other vaults available to copy into.', true);
9397 return;
9398 }
9399 const wrap = document.createElement('div');
9400 wrap.id = 'modal-copy-note-vault';
9401 wrap.className = 'modal';
9402 wrap.setAttribute('role', 'dialog');
9403 wrap.setAttribute('aria-modal', 'true');
9404 wrap.setAttribute('aria-label', 'Copy note to another vault');
9405 const backdrop = document.createElement('div');
9406 backdrop.className = 'modal-backdrop';
9407 const card = document.createElement('div');
9408 card.className = 'modal-card';
9409 card.style.maxWidth = '480px';
9410 const header = document.createElement('div');
9411 header.className = 'modal-header';
9412 const h2 = document.createElement('h2');
9413 h2.textContent = 'Copy to vault';
9414 const btnClose = document.createElement('button');
9415 btnClose.type = 'button';
9416 btnClose.className = 'modal-close';
9417 btnClose.textContent = '×';
9418 btnClose.setAttribute('aria-label', 'Close');
9419 header.appendChild(h2);
9420 header.appendChild(btnClose);
9421 const body = document.createElement('div');
9422 body.style.padding = '1rem 1.25rem';
9423 const lbl = document.createElement('label');
9424 lbl.className = 'detail-field-label';
9425 lbl.textContent = 'Target vault';
9426 lbl.setAttribute('for', 'copy-note-vault-select');
9427 const sel = document.createElement('select');
9428 sel.id = 'copy-note-vault-select';
9429 sel.className = 'vault-switcher-select';
9430 sel.style.width = '100%';
9431 sel.style.marginTop = '0.35rem';
9432 for (const v of targets) {
9433 const id = String(v.id);
9434 const opt = document.createElement('option');
9435 opt.value = id;
9436 opt.textContent = v.label != null && String(v.label).trim() !== '' ? String(v.label) : id;
9437 sel.appendChild(opt);
9438 }
9439 const moveRow = document.createElement('label');
9440 moveRow.style.display = 'flex';
9441 moveRow.style.alignItems = 'center';
9442 moveRow.style.gap = '0.5rem';
9443 moveRow.style.marginTop = '1rem';
9444 moveRow.style.cursor = 'pointer';
9445 const moveChk = document.createElement('input');
9446 moveChk.type = 'checkbox';
9447 moveChk.id = 'copy-note-delete-source';
9448 const moveSpan = document.createElement('span');
9449 moveSpan.textContent = 'Delete from this vault (move)';
9450 moveRow.appendChild(moveChk);
9451 moveRow.appendChild(moveSpan);
9452 const hint = document.createElement('p');
9453 hint.className = 'muted small';
9454 hint.style.marginTop = '0.75rem';
9455 hint.style.fontSize = '0.85rem';
9456 hint.textContent =
9457 '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).';
9458 const actions = document.createElement('div');
9459 actions.style.display = 'flex';
9460 actions.style.justifyContent = 'flex-end';
9461 actions.style.gap = '0.5rem';
9462 actions.style.marginTop = '1.25rem';
9463 const btnCancel = document.createElement('button');
9464 btnCancel.type = 'button';
9465 btnCancel.className = 'btn-secondary';
9466 btnCancel.textContent = 'Cancel';
9467 const btnGo = document.createElement('button');
9468 btnGo.type = 'button';
9469 btnGo.className = 'btn-primary';
9470 btnGo.textContent = 'Copy';
9471 actions.appendChild(btnCancel);
9472 actions.appendChild(btnGo);
9473 body.appendChild(lbl);
9474 body.appendChild(sel);
9475 body.appendChild(moveRow);
9476 body.appendChild(hint);
9477 body.appendChild(actions);
9478 card.appendChild(header);
9479 card.appendChild(body);
9480 wrap.appendChild(backdrop);
9481 wrap.appendChild(card);
9482 function close() {
9483 wrap.remove();
9484 }
9485 backdrop.onclick = close;
9486 btnClose.onclick = close;
9487 btnCancel.onclick = close;
9488 btnGo.onclick = async () => {
9489 const toId = sel.value;
9490 if (!toId || !currentOpenNote) return;
9491 await withButtonBusy(btnGo, 'Copying…', async () => {
9492 try {
9493 const res = await api('/api/v1/notes/copy', {
9494 method: 'POST',
9495 body: JSON.stringify({
9496 from_vault_id: fromId,
9497 to_vault_id: toId,
9498 path: currentOpenNote.path,
9499 delete_source: moveChk.checked,
9500 }),
9501 });
9502 hubMarkSemanticIndexStaleForVault(toId);
9503 if (res.moved) hubMarkSemanticIndexStaleForVault(fromId);
9504 close();
9505 if (typeof showToast === 'function') {
9506 showToast(res.moved ? 'Note moved to ' + toId : 'Note copied to ' + toId);
9507 }
9508 if (res.moved) {
9509 currentOpenNote = null;
9510 currentNotePathForCopy = '';
9511 resetDetailSectionSourceState();
9512 hideDetailPanelChrome();
9513 const bcp = el('btn-copy-path');
9514 if (bcp) bcp.classList.add('hidden');
9515 loadNotes();
9516 loadFacets();
9517 }
9518 } catch (e) {
9519 if (typeof showToast === 'function') showToast(e.message || String(e), true);
9520 }
9521 });
9522 };
9523 document.body.appendChild(wrap);
9524 }
9525
9526 async function exportCurrentNote(format) {
9527 if (!currentOpenNote) return;
9528 try {
9529 const res = await api('/api/v1/export', { method: 'POST', body: JSON.stringify({ path: currentOpenNote.path, format: format || 'md' }) });
9530 const blob = new Blob([res.content], { type: format === 'html' ? 'text/html' : 'text/markdown' });
9531 const a = document.createElement('a');
9532 a.href = URL.createObjectURL(blob);
9533 a.download = res.filename || 'export.md';
9534 a.click();
9535 URL.revokeObjectURL(a.href);
9536 if (typeof showToast === 'function') showToast('Exported ' + (res.filename || 'note'));
9537 } catch (e) {
9538 if (typeof showToast === 'function') showToast('Export failed: ' + (e.message || String(e)), true);
9539 }
9540 }
9541
9542 var MEDIA_IMAGE_EXTS = /\.(jpe?g|png|gif|webp)(\?|#|$)/i;
9543 var MEDIA_VIDEO_EXTS = /\.(mp4|webm|mov)(\?|#|$)/i;
9544 var MEDIA_URL_SAFE = /^https?:\/\//i;
9545
9546 function teardownDetailEditBodyLayout() {
9547 if (detailEditBodyLayoutAbort) {
9548 detailEditBodyLayoutAbort.abort();
9549 detailEditBodyLayoutAbort = null;
9550 }
9551 }
9552
9553 function detailEditBodyMaxTextareaPx() {
9554 var wrap = el('detail-edit-body-wrap');
9555 var ta = el('detail-edit-body');
9556 if (!wrap || !ta) return 400;
9557 var toolbar = el('media-toolbar');
9558 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9559 var tb = toolbar ? toolbar.offsetHeight : 0;
9560 var gh = grip ? grip.offsetHeight : 0;
9561 var slack = 10;
9562 var hard = Math.min(520, Math.floor(window.innerHeight * 0.55));
9563 var fallback = Math.round(window.innerHeight * 0.28);
9564 var wr = wrap.getBoundingClientRect();
9565 var next = wrap.nextElementSibling;
9566 var slice = 0;
9567 if (next && next.nodeType === 1) {
9568 var nr = next.getBoundingClientRect();
9569 slice = Math.floor(nr.top - wr.top - slack - tb - gh);
9570 } else {
9571 var body = el('detail-body');
9572 if (body) {
9573 var br = body.getBoundingClientRect();
9574 slice = Math.floor(br.bottom - wr.top - slack - tb - gh);
9575 }
9576 }
9577 if (!Number.isFinite(slice) || slice < 120) {
9578 slice = fallback;
9579 }
9580 return Math.max(160, Math.min(hard, slice));
9581 }
9582
9583 function sizeDetailEditBodyToFill() {
9584 var ta = el('detail-edit-body');
9585 if (!ta) return;
9586 ta.style.removeProperty('height');
9587 }
9588
9589 function wireDetailEditBodyLayout() {
9590 teardownDetailEditBodyLayout();
9591 var ta = el('detail-edit-body');
9592 var wrap = el('detail-edit-body-wrap');
9593 if (!ta || !wrap) return;
9594 var grip = wrap.querySelector('.detail-edit-body-resize-handle');
9595 if (!grip) {
9596 grip = document.createElement('div');
9597 grip.className = 'detail-edit-body-resize-handle';
9598 grip.setAttribute('role', 'separator');
9599 grip.setAttribute('aria-orientation', 'horizontal');
9600 grip.setAttribute('aria-label', 'Resize editor height');
9601 var next = ta.nextSibling;
9602 if (next && next.id === 'media-toolbar') {
9603 wrap.insertBefore(grip, next);
9604 } else {
9605 wrap.appendChild(grip);
9606 }
9607 }
9608 if (grip.dataset.wired !== '1') {
9609 grip.dataset.wired = '1';
9610 function startDrag(clientY) {
9611 var startY = clientY;
9612 var startH = ta.offsetHeight;
9613 document.body.style.userSelect = 'none';
9614 function onMove(e2) {
9615 if (e2.touches && e2.cancelable) e2.preventDefault();
9616 var y = e2.touches ? e2.touches[0].clientY : e2.clientY;
9617 var dy = y - startY;
9618 var cap = detailEditBodyMaxTextareaPx();
9619 var nh = Math.max(160, Math.min(cap, startH + dy));
9620 ta.style.height = nh + 'px';
9621 }
9622 function onUp() {
9623 document.body.style.userSelect = '';
9624 document.removeEventListener('mousemove', onMove);
9625 document.removeEventListener('mouseup', onUp);
9626 document.removeEventListener('touchmove', onMove);
9627 document.removeEventListener('touchend', onUp);
9628 }
9629 document.addEventListener('mousemove', onMove);
9630 document.addEventListener('mouseup', onUp);
9631 document.addEventListener('touchmove', onMove, { passive: false });
9632 document.addEventListener('touchend', onUp);
9633 }
9634 grip.addEventListener('mousedown', function (e) {
9635 e.preventDefault();
9636 startDrag(e.clientY);
9637 });
9638 grip.addEventListener('touchstart', function (e) {
9639 if (!e.touches || !e.touches[0]) return;
9640 e.preventDefault();
9641 startDrag(e.touches[0].clientY);
9642 }, { passive: false });
9643 }
9644 window.requestAnimationFrame(function () {
9645 sizeDetailEditBodyToFill();
9646 });
9647 detailEditBodyLayoutAbort = new AbortController();
9648 window.addEventListener(
9649 'resize',
9650 function () {
9651 if (!el('detail-edit-body-wrap')) return;
9652 sizeDetailEditBodyToFill();
9653 },
9654 { signal: detailEditBodyLayoutAbort.signal }
9655 );
9656 }
9657
9658 function attachMediaToolbar() {
9659 var textarea = el('detail-edit-body');
9660 if (!textarea) return;
9661 var existing = document.getElementById('media-toolbar');
9662 if (existing) existing.remove();
9663
9664 var toolbar = document.createElement('div');
9665 toolbar.id = 'media-toolbar';
9666 toolbar.className = 'media-toolbar';
9667
9668 var insertBtn = document.createElement('button');
9669 insertBtn.type = 'button';
9670 insertBtn.textContent = 'Insert Media URL';
9671 insertBtn.className = 'btn-small';
9672 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.';
9673 insertBtn.onclick = function () { toggleMediaUrlDialog(toolbar, textarea); };
9674 toolbar.appendChild(insertBtn);
9675
9676 var s = lastBackupSettingsPayload;
9677 if (s && s.github_connected && hubUserCanWriteNotes()) {
9678 var uploadBtn = document.createElement('button');
9679 uploadBtn.type = 'button';
9680 uploadBtn.textContent = 'Upload Image';
9681 uploadBtn.className = 'btn-small';
9682 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.';
9683 uploadBtn.onclick = function () { triggerImageUpload(textarea); };
9684 toolbar.appendChild(uploadBtn);
9685 } else if (s && s.github_connect_available && hubUserCanWriteNotes()) {
9686 var connectHint = document.createElement('span');
9687 connectHint.className = 'media-toolbar-hint';
9688 connectHint.title = 'Connect GitHub in Settings → Backup to enable image uploads.';
9689 connectHint.textContent = 'Connect GitHub to upload images';
9690 toolbar.appendChild(connectHint);
9691 }
9692
9693 textarea.parentNode.insertBefore(toolbar, textarea.nextSibling);
9694 }
9695
9696 function toggleMediaUrlDialog(toolbar, textarea) {
9697 var existing = document.getElementById('media-url-dialog');
9698 if (existing) { existing.remove(); return; }
9699
9700 var dialog = document.createElement('div');
9701 dialog.id = 'media-url-dialog';
9702 dialog.className = 'media-url-dialog';
9703
9704 var input = document.createElement('input');
9705 input.type = 'text';
9706 input.placeholder = 'Paste image or video URL (https://...)';
9707 input.className = 'media-url-input';
9708
9709 var preview = document.createElement('div');
9710 preview.className = 'media-preview';
9711
9712 var actions = document.createElement('div');
9713 actions.className = 'media-url-actions';
9714
9715 var doInsert = document.createElement('button');
9716 doInsert.type = 'button';
9717 doInsert.textContent = 'Insert';
9718 doInsert.className = 'btn-primary btn-small';
9719 doInsert.disabled = true;
9720
9721 var doCancel = document.createElement('button');
9722 doCancel.type = 'button';
9723 doCancel.textContent = 'Cancel';
9724 doCancel.className = 'btn-small';
9725 doCancel.onclick = function () { dialog.remove(); };
9726
9727 actions.appendChild(doInsert);
9728 actions.appendChild(doCancel);
9729
9730 var detectedType = null;
9731
9732 function onUrlChange() {
9733 var url = input.value.trim();
9734 preview.innerHTML = '';
9735 doInsert.disabled = true;
9736 detectedType = null;
9737 if (!url || !MEDIA_URL_SAFE.test(url)) return;
9738 if (MEDIA_IMAGE_EXTS.test(url)) {
9739 detectedType = 'image';
9740 var img = document.createElement('img');
9741 img.src = url;
9742 img.style.maxHeight = '200px';
9743 img.style.maxWidth = '100%';
9744 img.crossOrigin = 'anonymous';
9745 img.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9746 preview.appendChild(img);
9747 doInsert.disabled = false;
9748 } else if (MEDIA_VIDEO_EXTS.test(url)) {
9749 detectedType = 'video';
9750 var vid = document.createElement('video');
9751 vid.controls = true;
9752 vid.preload = 'metadata';
9753 vid.style.maxHeight = '200px';
9754 vid.style.maxWidth = '100%';
9755 vid.src = url;
9756 vid.onerror = function () { preview.innerHTML = '<span class="muted small">Could not load preview.</span>'; };
9757 preview.appendChild(vid);
9758 doInsert.disabled = false;
9759 } else {
9760 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>';
9761 }
9762 }
9763
9764 input.addEventListener('input', onUrlChange);
9765 input.addEventListener('paste', function () { setTimeout(onUrlChange, 50); });
9766
9767 doInsert.onclick = function () {
9768 var url = input.value.trim();
9769 if (!url) return;
9770 var insertion = detectedType === 'image' ? '![image](' + url + ')' : url;
9771 insertAtCursor(textarea, insertion);
9772 dialog.remove();
9773 };
9774
9775 dialog.appendChild(input);
9776 dialog.appendChild(preview);
9777 dialog.appendChild(actions);
9778 toolbar.parentNode.insertBefore(dialog, toolbar.nextSibling);
9779 input.focus();
9780 }
9781
9782 function insertAtCursor(textarea, text) {
9783 var start = textarea.selectionStart;
9784 var end = textarea.selectionEnd;
9785 var val = textarea.value;
9786 var before = val.substring(0, start);
9787 var needsNewline = before.length > 0 && !before.endsWith('\n');
9788 var insertion = (needsNewline ? '\n' : '') + text + '\n';
9789 textarea.value = before + insertion + val.substring(end);
9790 var newPos = start + insertion.length;
9791 textarea.setSelectionRange(newPos, newPos);
9792 textarea.focus();
9793 }
9794
9795 /**
9796 * Compress an image File/Blob using the Canvas API so it fits within the
9797 * Netlify Lambda 6 MB payload limit (~4.5 MB binary after base64 overhead).
9798 * Target: longest side ≤ 2048 px, JPEG quality 0.82, result ≤ 3 MB.
9799 * Falls back to the original file if Canvas is unavailable or the image is
9800 * already small enough.
9801 */
9802 function compressImageIfNeeded(file) {
9803 var MAX_BYTES = 3 * 1024 * 1024; // 3 MB ceiling
9804 var MAX_DIM = 2048;
9805 return new Promise(function (resolve) {
9806 if (!file.type.startsWith('image/') || file.size <= MAX_BYTES) {
9807 return resolve(file);
9808 }
9809 if (typeof window === 'undefined' || !window.HTMLCanvasElement) {
9810 return resolve(file);
9811 }
9812 var img = new window.Image();
9813 var objectUrl = URL.createObjectURL(file);
9814 img.onload = function () {
9815 URL.revokeObjectURL(objectUrl);
9816 var ratio = Math.min(MAX_DIM / img.width, MAX_DIM / img.height, 1);
9817 var w = Math.round(img.width * ratio);
9818 var h = Math.round(img.height * ratio);
9819 var canvas = document.createElement('canvas');
9820 canvas.width = w;
9821 canvas.height = h;
9822 var ctx = canvas.getContext('2d');
9823 ctx.drawImage(img, 0, 0, w, h);
9824 var tryQuality = function (quality, attempt) {
9825 canvas.toBlob(function (blob) {
9826 if (!blob) return resolve(file); // Canvas failed — upload original
9827 if (blob.size <= MAX_BYTES || quality <= 0.4 || attempt >= 3) {
9828 var outName = file.name.replace(/\.[^.]+$/, '.jpg');
9829 resolve(new window.File([blob], outName, { type: 'image/jpeg' }));
9830 } else {
9831 tryQuality(quality - 0.2, attempt + 1);
9832 }
9833 }, 'image/jpeg', quality);
9834 };
9835 tryQuality(0.82, 0);
9836 };
9837 img.onerror = function () {
9838 URL.revokeObjectURL(objectUrl);
9839 resolve(file);
9840 };
9841 img.src = objectUrl;
9842 });
9843 }
9844
9845 function triggerImageUpload(textarea) {
9846 var fileInput = document.createElement('input');
9847 fileInput.type = 'file';
9848 fileInput.accept = 'image/jpeg,image/png,image/gif,image/webp';
9849 fileInput.onchange = async function () {
9850 var file = fileInput.files && fileInput.files[0];
9851 if (!file || !currentOpenNote) return;
9852 try {
9853 if (typeof showToast === 'function') showToast('Uploading image…');
9854 // Compress before uploading to stay within the Netlify Lambda 6 MB
9855 // payload limit (~4.5 MB binary after base64 overhead).
9856 var uploadFile = await compressImageIfNeeded(file);
9857 var form = new FormData();
9858 form.append('image', uploadFile);
9859 var notePath = encodeURIComponent(currentOpenNote.path);
9860 var vaultIdParam = '';
9861 try { vaultIdParam = '?vault_id=' + encodeURIComponent(getCurrentVaultId()); } catch (_) {}
9862 // Build auth headers from the shared helper (omit Content-Type so the
9863 // browser sets the correct multipart/form-data boundary automatically).
9864 var uploadHeaders = headers();
9865 delete uploadHeaders['Content-Type'];
9866 // Use apiBase so this request reaches the gateway when the frontend is served
9867 // from a different origin (e.g. knowtation.store → ICP canister, read-only).
9868 var uploadBase = (typeof apiBase !== 'undefined' ? apiBase : '').replace(/\/$/, '');
9869 var res = await fetch(uploadBase + '/api/v1/notes/' + notePath + '/upload-image' + vaultIdParam, {
9870 method: 'POST',
9871 headers: uploadHeaders,
9872 body: form,
9873 });
9874 if (!res.ok) {
9875 var errData = await res.json().catch(function () { return {}; });
9876 throw new Error(errData.error || 'Upload failed (HTTP ' + res.status + ')');
9877 }
9878 var data = await res.json();
9879 insertAtCursor(textarea, data.inserted_markdown || '![image](' + data.url + ')');
9880 if (typeof showToast === 'function') showToast('Image uploaded and inserted');
9881 } catch (e) {
9882 if (typeof showToast === 'function') showToast('Upload failed: ' + (e.message || String(e)), true);
9883 }
9884 };
9885 fileInput.click();
9886 }
9887
9888 function switchNoteToEditMode() {
9889 if (!currentOpenNote) return;
9890 closeCreateModal();
9891 resetDetailSectionSourceState();
9892 const bcbEdit = el('btn-detail-copy-body');
9893 if (bcbEdit) bcbEdit.classList.add('hidden');
9894 const bodyEl = el('detail-body');
9895 const actionsEl = el('detail-actions');
9896 const fm = stripReservedHubFm(materializeFrontmatter(currentOpenNote.frontmatter));
9897 bodyEl.className = 'detail-edit-container create-panel';
9898 bodyEl.innerHTML =
9899 '<p class="muted small">Path (read-only): <code id="detail-edit-path-display"></code></p>' +
9900 '<p id="detail-edit-path-typo-hint" class="muted small detail-project-hint hidden" role="status"></p>' +
9901 '<label for="detail-edit-title">Title</label>' +
9902 '<input type="text" id="detail-edit-title" placeholder="Note title" />' +
9903 '<label for="detail-edit-body">Body (Markdown)</label>' +
9904 '<div id="detail-edit-body-wrap" class="detail-edit-body-wrap">' +
9905 '<textarea id="detail-edit-body" class="detail-edit-body" rows="14" placeholder="Content…"></textarea>' +
9906 '</div>' +
9907 '<label for="detail-edit-date">Date</label>' +
9908 '<input type="date" id="detail-edit-date" />' +
9909 '<label for="detail-edit-project">Project (slug)</label>' +
9910 '<input type="text" id="detail-edit-project" placeholder="slug" />' +
9911 '<p id="detail-edit-project-hint" class="muted small detail-project-hint hidden" style="margin-top:-0.35rem;margin-bottom:0.5rem;"></p>' +
9912 '<label for="detail-edit-tags">Tags (comma-separated)</label>' +
9913 '<input type="text" id="detail-edit-tags" placeholder="tag1, tag2" />' +
9914 '<p class="muted small" style="margin-top:0.5rem;">Temporal and hierarchical (optional):</p>' +
9915 '<label for="detail-edit-causal-chain">Causal chain ID</label>' +
9916 '<input type="text" id="detail-edit-causal-chain" placeholder="e.g. auth-decisions" />' +
9917 '<label for="detail-edit-entity">Entity (comma-separated)</label>' +
9918 '<input type="text" id="detail-edit-entity" placeholder="e.g. alice, auth" />' +
9919 '<label for="detail-edit-episode">Episode ID</label>' +
9920 '<input type="text" id="detail-edit-episode" placeholder="e.g. planning-2025-03" />' +
9921 '<label for="detail-edit-follows">Follows (vault path)</label>' +
9922 '<input type="text" id="detail-edit-follows" placeholder="e.g. inbox/prior-note.md" />';
9923 const pathDisp = el('detail-edit-path-display');
9924 if (pathDisp) pathDisp.textContent = currentOpenNote.path;
9925 fillDetailEditFieldsFromFrontmatter(fm);
9926 attachMediaToolbar();
9927 wireDetailEditBodyLayout();
9928 actionsEl.innerHTML = '';
9929 const saveBtn = document.createElement('button');
9930 saveBtn.textContent = 'Save';
9931 saveBtn.className = 'btn-primary';
9932 saveBtn.onclick = async () => {
9933 closeCreateModal();
9934 const body = (el('detail-edit-body') && el('detail-edit-body').value) || '';
9935 const frontmatter = mergedFrontmatterForDetailSave();
9936 await withButtonBusy(saveBtn, 'Saving…', async () => {
9937 try {
9938 await api('/api/v1/notes', {
9939 method: 'POST',
9940 body: stringifyNotePostPayload(currentOpenNote.path, body, frontmatter),
9941 });
9942 hubMarkSemanticIndexStale();
9943 if (typeof showToast === 'function') showToast('Note saved');
9944 const refreshed = await api('/api/v1/notes/' + encodeURIComponent(currentOpenNote.path));
9945 const nfm = materializeFrontmatter(refreshed.frontmatter);
9946 currentOpenNote = { path: currentOpenNote.path, body: refreshed.body || '', frontmatter: nfm };
9947 switchNoteToReadMode();
9948 if (typeof loadNotes === 'function') loadNotes();
9949 if (typeof loadFacets === 'function') loadFacets();
9950 } catch (e) {
9951 if (typeof showToast === 'function') showToast('Save failed: ' + (e.message || String(e)), true);
9952 }
9953 });
9954 };
9955 const cancelBtn = document.createElement('button');
9956 cancelBtn.textContent = 'Cancel';
9957 cancelBtn.onclick = () => switchNoteToReadMode();
9958 const delBtn = document.createElement('button');
9959 delBtn.type = 'button';
9960 delBtn.textContent = 'Delete';
9961 delBtn.onclick = () => deleteOpenNote();
9962 actionsEl.append(saveBtn, delBtn, cancelBtn);
9963 }
9964
9965 function openNote(path) {
9966 const seq = ++hubOpenNoteSeq;
9967 resetDetailSectionSourceState();
9968 teardownDetailEditBodyLayout();
9969 closeCreateModal();
9970 clearReviewSplitPosition();
9971 currentNotePathForCopy = path;
9972 currentOpenNote = null;
9973 const panel = el('detail-panel');
9974 panel.classList.remove('detail-panel-proposal-wide');
9975 // Reset any prior resize so notes open at the CSS half-page default.
9976 panel.style.width = '';
9977 const title = el('detail-title');
9978 const bodyEl = el('detail-body');
9979 const actionsEl = el('detail-actions');
9980 const btnCopy = el('btn-copy-path');
9981 const btnCopyBody = el('btn-detail-copy-body');
9982 if (btnCopyBody) btnCopyBody.classList.add('hidden');
9983 title.textContent = path;
9984 bodyEl.textContent = 'Loading…';
9985 bodyEl.className = '';
9986 actionsEl.innerHTML = '';
9987 btnCopy.classList.remove('hidden');
9988 panel.classList.remove('hidden');
9989 api('/api/v1/notes/' + encodeURIComponent(path))
9990 .then((note) => {
9991 if (seq !== hubOpenNoteSeq) return;
9992 const fm = materializeFrontmatter(note.frontmatter);
9993 currentOpenNote = { path, body: note.body || '', frontmatter: fm };
9994 bodyEl.innerHTML = buildNoteReadHtml(note.body, fm);
9995 bodyEl.className = 'note-rendered-body';
9996 actionsEl.innerHTML = '';
9997 attachNoteDetailReadActions(actionsEl);
9998 if (btnCopyBody) btnCopyBody.classList.remove('hidden');
9999 })
10000 .catch((e) => {
10001 if (seq !== hubOpenNoteSeq) return;
10002 bodyEl.textContent = 'Error: ' + e.message;
10003 bodyEl.className = '';
10004 if (btnCopyBody) btnCopyBody.classList.add('hidden');
10005 });
10006 }
10007
10008 el('btn-copy-path').onclick = () => {
10009 if (currentNotePathForCopy) navigator.clipboard.writeText(currentNotePathForCopy);
10010 };
10011
10012 const btnDetailCopyBody = el('btn-detail-copy-body');
10013 if (btnDetailCopyBody) {
10014 btnDetailCopyBody.onclick = () => {
10015 if (!currentOpenNote) {
10016 if (typeof showToast === 'function') showToast('Open a note first.', true);
10017 return;
10018 }
10019 const text = currentOpenNote.body != null ? String(currentOpenNote.body) : '';
10020 if (navigator.clipboard && navigator.clipboard.writeText) {
10021 navigator.clipboard.writeText(text).then(
10022 () => {
10023 if (typeof showToast === 'function') showToast('Note body copied (Markdown).');
10024 },
10025 () => {
10026 if (typeof showToast === 'function') showToast('Could not copy to clipboard.', true);
10027 },
10028 );
10029 } else if (typeof showToast === 'function') {
10030 showToast('Clipboard not available in this browser.', true);
10031 }
10032 };
10033 }
10034
10035 const btnCopyUserId = el('btn-copy-user-id');
10036 if (btnCopyUserId) {
10037 btnCopyUserId.onclick = () => {
10038 const idEl = el('settings-user-id');
10039 const text = idEl && idEl.textContent && idEl.textContent !== '—' ? idEl.textContent : '';
10040 if (text && navigator.clipboard && navigator.clipboard.writeText) {
10041 navigator.clipboard.writeText(text).then(() => {
10042 if (typeof showToast === 'function') showToast('User ID copied.');
10043 }).catch(() => {});
10044 }
10045 };
10046 }
10047 const btnCopyAgentceptionEnv = el('btn-copy-agentception-env');
10048 if (btnCopyAgentceptionEnv) {
10049 btnCopyAgentceptionEnv.onclick = () => {
10050 const envEl = el('integrations-agentception-env');
10051 const text = envEl && envEl.textContent ? envEl.textContent.trim() : '';
10052 if (text && navigator.clipboard && navigator.clipboard.writeText) {
10053 navigator.clipboard.writeText(text).then(() => {
10054 if (typeof showToast === 'function') showToast('Env snippet copied.');
10055 }).catch(() => {});
10056 }
10057 };
10058 }
10059 const btnIntegrationsHowToAgentception = el('btn-integrations-how-to-agentception');
10060 if (btnIntegrationsHowToAgentception) {
10061 btnIntegrationsHowToAgentception.onclick = () => {
10062 closeSettings();
10063 openHowToUse('setup');
10064 };
10065 }
10066 const btnHowToFlexibleNetwork = el('btn-how-to-flexible-network');
10067 if (btnHowToFlexibleNetwork) {
10068 btnHowToFlexibleNetwork.onclick = () => {
10069 closeSettings();
10070 openHowToUse('setup', 'how-to-flexible-network');
10071 };
10072 }
10073
10074 function renderProposalMarkdownHtml(md) {
10075 try {
10076 if (typeof marked !== 'undefined' && marked.parse && typeof DOMPurify !== 'undefined') {
10077 var raw = marked.parse(isolateVideoUrlLines(md || ''), { breaks: true });
10078 var withVideo = expandVideoUrls(raw);
10079 var sanitised = DOMPurify.sanitize(withVideo, SANITIZE_OPTS_NOTE);
10080 return rewriteGitHubImageUrls(sanitised);
10081 }
10082 } catch (_) {
10083 /* fall through */
10084 }
10085 return escapeHtml(md || '');
10086 }
10087
10088 /** Canister stores checklist as JSON text; Node may return an array. */
10089 function parseProposalEvaluationChecklist(raw) {
10090 if (Array.isArray(raw)) return raw;
10091 if (raw == null || raw === '') return [];
10092 const s = String(raw).trim();
10093 if (!s) return [];
10094 try {
10095 const j = JSON.parse(s);
10096 return Array.isArray(j) ? j : [];
10097 } catch (_) {
10098 return [];
10099 }
10100 }
10101
10102 /**
10103 * Shown when reopening approved/discarded proposals (editable eval UI only exists for proposed).
10104 */
10105 function buildProposalEvaluationRecordHtml(p, rubricItems) {
10106 const st = p.status;
10107 if (st !== 'approved' && st !== 'discarded') return '';
10108 const checklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
10109 const es = p.evaluation_status != null ? String(p.evaluation_status).trim() : '';
10110 const comment = p.evaluation_comment != null ? String(p.evaluation_comment).trim() : '';
10111 const grade = p.evaluation_grade != null ? String(p.evaluation_grade).trim() : '';
10112 const meaningfulStatus = es && es !== 'none';
10113 let waiverText = '';
10114 const w = p.evaluation_waiver;
10115 if (w != null && w !== '') {
10116 try {
10117 const o = typeof w === 'object' && w !== null ? w : JSON.parse(String(w));
10118 if (o && typeof o === 'object') {
10119 const r1 = o.reason != null ? String(o.reason).trim() : '';
10120 const r2 = o.waiver_reason != null ? String(o.waiver_reason).trim() : '';
10121 waiverText = r1 || r2;
10122 }
10123 } catch (_) {
10124 /* ignore */
10125 }
10126 }
10127 if (!meaningfulStatus && !comment && !grade && checklist.length === 0 && !waiverText) return '';
10128 const rubricById = new Map(
10129 (Array.isArray(rubricItems) ? rubricItems : []).map((it) => [
10130 String(it.id || '').trim(),
10131 String(it.label || it.id || '').trim(),
10132 ]),
10133 );
10134 const rows = checklist
10135 .map((c) => {
10136 const rid = c && c.id != null ? String(c.id) : '';
10137 const lab = (rubricById.get(rid) || rid || 'item').trim() || 'item';
10138 const pass = c && c.passed === true;
10139 return '<li class="small">' + escapeHtml(lab) + ': <strong>' + (pass ? 'pass' : 'not pass') + '</strong></li>';
10140 })
10141 .join('');
10142 return (
10143 '<div class="proposal-eval proposal-eval-readonly">' +
10144 '<h4 class="proposal-md-heading">Evaluation record</h4>' +
10145 '<p class="small">' +
10146 (meaningfulStatus ? '<strong>Outcome</strong>: ' + escapeHtml(es) : '<strong>Outcome</strong>: —') +
10147 (grade ? ' · <strong>Grade</strong>: ' + escapeHtml(grade) : '') +
10148 (p.evaluated_by ? ' · <strong>By</strong>: ' + escapeHtml(String(p.evaluated_by)) : '') +
10149 (p.evaluated_at
10150 ? ' · <span class="muted">' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) + '</span>'
10151 : '') +
10152 '</p>' +
10153 (comment ? '<p class="small proposal-eval-record-comment">' + escapeHtml(comment) + '</p>' : '') +
10154 (rows ? '<ul class="proposal-eval-readonly-list">' + rows + '</ul>' : '') +
10155 (waiverText ? '<p class="small"><strong>Approve waiver</strong>: ' + escapeHtml(waiverText) + '</p>' : '') +
10156 '</div>'
10157 );
10158 }
10159
10160 function openProposal(id) {
10161 resetDetailSectionSourceState();
10162 currentNotePathForCopy = '';
10163 currentOpenNote = null;
10164 el('btn-copy-path').classList.add('hidden');
10165 const bcbProp = el('btn-detail-copy-body');
10166 if (bcbProp) bcbProp.classList.add('hidden');
10167 const panel = el('detail-panel');
10168 panel.classList.add('detail-panel-proposal-wide');
10169 const title = el('detail-title');
10170 const body = el('detail-body');
10171 const actions = el('detail-actions');
10172 body.className = 'detail-body-proposal';
10173 panel.classList.remove('hidden');
10174 body.innerHTML = '<p class="muted">Loading…</p>';
10175 actions.innerHTML = '';
10176 const pathEnc = (pth) => encodeURIComponent(String(pth || '').replace(/\\/g, '/'));
10177 api('/api/v1/proposals/' + encodeURIComponent(id))
10178 .then((p) =>
10179 api('/api/v1/notes/' + pathEnc(p.path)).then(
10180 (note) => ({ p, note }),
10181 () => ({ p, note: null }),
10182 ),
10183 )
10184 .then(({ p, note }) => {
10185 title.textContent = p.path + ' (' + p.status + ')';
10186 const pFm = materializeFrontmatter(p.frontmatter);
10187 const currentBlock = note
10188 ? formatDetailReadBody(note.body || '', materializeFrontmatter(note.frontmatter))
10189 : '(No note at this path in the vault yet — Approve will create or overwrite this path.)';
10190 const proposedBlock = formatDetailReadBody(p.body || '', pFm);
10191 const mdHtml = renderProposalMarkdownHtml(p.body || '');
10192 const chips = [];
10193 if (p.proposed_by) chips.push('<span class="proposal-chip">by ' + escapeHtml(String(p.proposed_by)) + '</span>');
10194 if (p.source) chips.push('<span class="proposal-chip">' + escapeHtml(String(p.source)) + '</span>');
10195 (Array.isArray(p.labels) ? p.labels : []).forEach((x) => {
10196 chips.push('<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>');
10197 });
10198 if (p.external_ref) {
10199 chips.push('<span class="proposal-chip">ref ' + escapeHtml(String(p.external_ref).slice(0, 40)) + '</span>');
10200 }
10201 const role = window.__hubUserRole || 'member';
10202 const isAdmin = role === 'admin';
10203 const isEvaluator = role === 'evaluator';
10204 const canEvaluate = isAdmin || isEvaluator;
10205 const canApprove = isAdmin || (isEvaluator && window.__hubEvaluatorMayApprove);
10206 const canDiscard = isAdmin;
10207 const rubricItems = Array.isArray(window.__hubProposalRubricItems) ? window.__hubProposalRubricItems : [];
10208 const prevChecklist = parseProposalEvaluationChecklist(p.evaluation_checklist);
10209 const evalRecordHtml = buildProposalEvaluationRecordHtml(p, rubricItems);
10210 function prevEvalPassed(rid) {
10211 const row = prevChecklist.find((c) => c && c.id === rid);
10212 return Boolean(row && row.passed === true);
10213 }
10214 let evalHtml = '';
10215 let waiverHtml = '';
10216 if (canEvaluate && p.status === 'proposed') {
10217 const es = p.evaluation_status || 'none';
10218 let evalIntro = '';
10219 if (es && es !== 'none' && es !== 'pending') {
10220 evalIntro =
10221 '<div class="proposal-eval-summary"><strong>Recorded evaluation</strong>: ' +
10222 escapeHtml(es) +
10223 (p.evaluation_grade ? ' · grade ' + escapeHtml(String(p.evaluation_grade)) : '') +
10224 (p.evaluated_at ? ' · ' + escapeHtml(String(p.evaluated_at).slice(0, 19).replace('T', ' ')) : '') +
10225 (p.evaluation_comment
10226 ? '<p class="small">' + escapeHtml(String(p.evaluation_comment)) + '</p>'
10227 : '') +
10228 '</div>';
10229 } else if (es === 'pending' || window.__hubProposalEvaluationRequired) {
10230 evalIntro =
10231 '<p class="small muted">Human evaluation is required before approve, unless you use an approve waiver reason below.</p>';
10232 }
10233 const checks = rubricItems.length
10234 ? rubricItems
10235 .map((it) => {
10236 const rid = String(it.id || '').trim();
10237 if (!rid) return '';
10238 const lab = String(it.label || rid);
10239 const ck = prevEvalPassed(rid) ? ' checked' : '';
10240 return (
10241 '<label class="proposal-eval-check"><input type="checkbox" data-proposal-eval-id="' +
10242 escapeHtml(rid) +
10243 '"' +
10244 ck +
10245 ' /> ' +
10246 escapeHtml(lab) +
10247 '</label>'
10248 );
10249 })
10250 .join('')
10251 : '<p class="small muted">No rubric items loaded. Defaults ship in-repo; optional override: <code>data/hub_proposal_rubric.json</code>.</p>';
10252 const gradeVal = p.evaluation_grade != null ? escapeHtml(String(p.evaluation_grade)) : '';
10253 evalHtml =
10254 '<div class="proposal-eval">' +
10255 '<h4 class="proposal-md-heading">Evaluation</h4>' +
10256 evalIntro +
10257 '<label class="proposal-eval-field">Outcome <select id="proposal-eval-outcome">' +
10258 '<option value="pass">Pass</option>' +
10259 '<option value="fail">Fail</option>' +
10260 '<option value="needs_changes">Needs changes</option>' +
10261 '</select></label>' +
10262 '<label class="proposal-eval-field">Grade (optional) <input type="text" id="proposal-eval-grade" maxlength="32" value="' +
10263 gradeVal +
10264 '" placeholder="e.g. A or 4" /></label>' +
10265 '<div class="proposal-eval-checklist">' +
10266 checks +
10267 '</div>' +
10268 '<label class="proposal-eval-field">Comment <textarea id="proposal-eval-comment" rows="3" placeholder="Required for fail / needs changes">' +
10269 escapeHtml(p.evaluation_comment != null ? String(p.evaluation_comment) : '') +
10270 '</textarea></label>' +
10271 '<button type="button" class="btn-secondary" id="proposal-eval-save">Save evaluation</button>' +
10272 '</div>';
10273 }
10274 if (canApprove && p.status === 'proposed') {
10275 waiverHtml =
10276 '<div class="proposal-eval-waiver">' +
10277 '<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>' +
10278 '</div>';
10279 }
10280 let autoFlagHtml = '';
10281 if (Array.isArray(p.auto_flag_reasons) && p.auto_flag_reasons.length) {
10282 autoFlagHtml =
10283 '<p class="small muted">Auto-flagged: ' +
10284 p.auto_flag_reasons.map((x) => escapeHtml(String(x))).join(', ') +
10285 '</p>';
10286 } else if (p.auto_flag_reasons_json != null && String(p.auto_flag_reasons_json).trim()) {
10287 try {
10288 const ar = JSON.parse(String(p.auto_flag_reasons_json));
10289 if (Array.isArray(ar) && ar.length) {
10290 autoFlagHtml =
10291 '<p class="small muted">Auto-flagged: ' + ar.map((x) => escapeHtml(String(x))).join(', ') + '</p>';
10292 }
10293 } catch (_) {
10294 /* ignore */
10295 }
10296 }
10297 let hintsHtml = '';
10298 if (p.review_hints) {
10299 hintsHtml =
10300 '<div class="proposal-review-hints"><strong>Review hints</strong>' +
10301 (p.review_hints_model
10302 ? ' <span class="muted">(' + escapeHtml(String(p.review_hints_model)) + ')</span>'
10303 : '') +
10304 (p.review_hints_at
10305 ? ' <span class="muted">' + escapeHtml(String(p.review_hints_at).slice(0, 19)) + '</span>'
10306 : '') +
10307 '<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>' +
10308 '<pre class="proposal-pre">' +
10309 escapeHtml(String(p.review_hints)) +
10310 '</pre><p class="small muted">Hints are machine-generated and untrusted — humans decide evaluation outcome.</p></div>';
10311 }
10312 let assistantHtml = '';
10313 if (p.assistant_notes) {
10314 const sug = (Array.isArray(p.suggested_labels) ? p.suggested_labels : [])
10315 .map((x) => '<span class="proposal-chip">' + escapeHtml(String(x)) + '</span>')
10316 .join('');
10317 assistantHtml =
10318 '<div class="proposal-assistant"><strong>Assistant</strong>' +
10319 (p.assistant_model ? ' <span class="muted">(' + escapeHtml(String(p.assistant_model)) + ')</span>' : '') +
10320 (p.assistant_at ? ' <span class="muted">' + escapeHtml(String(p.assistant_at).slice(0, 19)) + '</span>' : '') +
10321 '<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>' +
10322 '<p>' +
10323 escapeHtml(String(p.assistant_notes)) +
10324 '</p>' +
10325 (sug ? '<div class="proposal-meta-chips">' + sug + '</div>' : '') +
10326 '</div>';
10327 }
10328 let suggestedFmHtml = '';
10329 {
10330 let fm = p.assistant_suggested_frontmatter;
10331 if (typeof fm === 'string') {
10332 try {
10333 fm = JSON.parse(fm);
10334 } catch {
10335 fm = null;
10336 }
10337 }
10338 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
10339 const keys = Object.keys(fm).filter((k) => {
10340 const v = fm[k];
10341 return v !== undefined && v !== null && v !== '';
10342 });
10343 if (keys.length) {
10344 const rows = keys
10345 .map((k) => {
10346 const v = fm[k];
10347 let cell;
10348 if (Array.isArray(v)) cell = v.map((x) => String(x)).join(', ');
10349 else if (v !== null && typeof v === 'object') cell = JSON.stringify(v);
10350 else cell = String(v);
10351 return (
10352 '<tr><th scope="row">' +
10353 escapeHtml(k) +
10354 '</th><td>' +
10355 escapeHtml(cell) +
10356 '</td></tr>'
10357 );
10358 })
10359 .join('');
10360 suggestedFmHtml =
10361 '<div class="proposal-suggested-fm">' +
10362 '<strong>Suggested frontmatter</strong> ' +
10363 '<button type="button" class="btn-link btn-link-small" id="proposal-suggested-fm-copy">Copy JSON</button>' +
10364 '<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>' +
10365 '<table class="proposal-suggested-fm-table"><tbody>' +
10366 rows +
10367 '</tbody></table></div>';
10368 }
10369 }
10370 }
10371 const openVaultNoteLine = note
10372 ? '<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>'
10373 : '<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>';
10374 const primaryEvalBlock =
10375 evalHtml || waiverHtml
10376 ? '<div class="proposal-primary-eval">' + evalHtml + waiverHtml + '</div>'
10377 : '';
10378 body.innerHTML =
10379 (chips.length ? '<div class="proposal-meta-chips">' + chips.join('') + '</div>' : '') +
10380 autoFlagHtml +
10381 '<p class="small muted">Intent: ' +
10382 escapeHtml(p.intent || '—') +
10383 ' · base_state_id: ' +
10384 escapeHtml(p.base_state_id || '—') +
10385 (p.evaluation_status ? ' · evaluation: ' + escapeHtml(String(p.evaluation_status)) : '') +
10386 (p.review_queue ? ' · queue: ' + escapeHtml(String(p.review_queue)) : '') +
10387 (p.review_severity ? ' · severity: ' + escapeHtml(String(p.review_severity)) : '') +
10388 '</p>' +
10389 openVaultNoteLine +
10390 primaryEvalBlock +
10391 '<div class="proposal-diff-grid">' +
10392 '<div><h4>Current vault</h4><pre class="proposal-pre">' +
10393 escapeHtml(currentBlock) +
10394 '</pre></div>' +
10395 '<div><h4>Proposed</h4><pre class="proposal-pre">' +
10396 escapeHtml(proposedBlock) +
10397 '</pre></div>' +
10398 '</div>' +
10399 '<h4 class="proposal-md-heading">Proposed body (rendered)</h4>' +
10400 '<div class="proposal-md">' +
10401 mdHtml +
10402 '</div>' +
10403 evalRecordHtml +
10404 assistantHtml +
10405 suggestedFmHtml +
10406 hintsHtml;
10407 actions.innerHTML = '';
10408 {
10409 const idx = proposalListIds.indexOf(String(id));
10410 if (idx >= 0 && proposalListIds.length > 0) {
10411 proposalListSelectedIndex = idx;
10412 setReviewSplitPosition(idx + 1, proposalListIds.length);
10413 const c = getActiveProposalListContainer();
10414 if (c) updateProposalListSelection(c);
10415 } else {
10416 clearReviewSplitPosition();
10417 }
10418 }
10419 const openNoteBtn = body.querySelector('#proposal-open-note-btn');
10420 if (openNoteBtn && note && p.path) {
10421 openNoteBtn.onclick = () => openNote(String(p.path));
10422 }
10423 const copyFmBtn = body.querySelector('#proposal-suggested-fm-copy');
10424 if (copyFmBtn) {
10425 let fmForCopy = p.assistant_suggested_frontmatter;
10426 if (typeof fmForCopy === 'string') {
10427 try {
10428 fmForCopy = JSON.parse(fmForCopy);
10429 } catch {
10430 fmForCopy = null;
10431 }
10432 }
10433 if (fmForCopy && typeof fmForCopy === 'object' && !Array.isArray(fmForCopy)) {
10434 copyFmBtn.onclick = async () => {
10435 try {
10436 await navigator.clipboard.writeText(JSON.stringify(fmForCopy, null, 2));
10437 showToast('Copied suggested frontmatter JSON.');
10438 } catch (err) {
10439 showToast(err.message || 'Copy failed', true);
10440 }
10441 };
10442 }
10443 }
10444 const saveEvalBtn = body.querySelector('#proposal-eval-save');
10445 if (saveEvalBtn) {
10446 saveEvalBtn.onclick = async () => {
10447 const outcomeEl = body.querySelector('#proposal-eval-outcome');
10448 const outcome = outcomeEl ? String(outcomeEl.value || 'pass') : 'pass';
10449 const gradeEl = body.querySelector('#proposal-eval-grade');
10450 const grade = gradeEl ? String(gradeEl.value || '').trim() : '';
10451 const commentEl = body.querySelector('#proposal-eval-comment');
10452 const comment = commentEl ? String(commentEl.value || '').trim() : '';
10453 const checklist = [];
10454 body.querySelectorAll('input[data-proposal-eval-id]').forEach((inp) => {
10455 checklist.push({ id: inp.getAttribute('data-proposal-eval-id'), passed: Boolean(inp.checked) });
10456 });
10457 try {
10458 await withButtonBusy(saveEvalBtn, 'Saving…', async () => {
10459 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/evaluation', {
10460 method: 'POST',
10461 body: JSON.stringify({
10462 outcome,
10463 grade: grade || undefined,
10464 comment: comment || undefined,
10465 checklist,
10466 }),
10467 });
10468 });
10469 showToast('Evaluation saved.');
10470 openProposal(id);
10471 loadProposals();
10472 } catch (err) {
10473 showToast(err.message || 'Evaluation failed', true);
10474 }
10475 };
10476 }
10477 if (p.status === 'proposed') {
10478 if (canApprove) {
10479 const approveBtn = document.createElement('button');
10480 approveBtn.textContent = 'Approve';
10481 approveBtn.onclick = () => approveProposal(id, panel, approveBtn);
10482 actions.append(approveBtn);
10483 }
10484 if (canDiscard) {
10485 const discardBtn = document.createElement('button');
10486 discardBtn.textContent = 'Discard';
10487 discardBtn.onclick = () => discardProposal(id, panel, discardBtn);
10488 actions.append(discardBtn);
10489 }
10490 if (canEvaluate && window.__hubProposalEnrich && hubUserMayEnrichProposal()) {
10491 const enrichBtn = document.createElement('button');
10492 enrichBtn.type = 'button';
10493 enrichBtn.className = 'btn-secondary';
10494 enrichBtn.textContent = 'Enrich (AI)';
10495 enrichBtn.onclick = () => enrichProposal(id, panel, enrichBtn);
10496 actions.append(enrichBtn);
10497 }
10498 if (isEvaluator && !canApprove) {
10499 const hintEv = document.createElement('p');
10500 hintEv.className = 'muted small';
10501 hintEv.textContent =
10502 'You can record evaluation; approve needs permission (admin, or evaluator with “may approve” in Team / host default). Discard is admin-only.';
10503 actions.append(hintEv);
10504 } else if (!canEvaluate) {
10505 const hint = document.createElement('p');
10506 hint.className = 'muted small';
10507 hint.textContent =
10508 'Your role cannot record evaluation here. Admins and evaluators evaluate; approve/discard follows Hub policy.';
10509 actions.append(hint);
10510 }
10511 }
10512 })
10513 .catch((e) => {
10514 body.className = 'detail-body-proposal';
10515 body.innerHTML = '<p class="muted">Error: ' + escapeHtml(e.message) + '</p>';
10516 });
10517 }
10518
10519 async function enrichProposal(id, panel, btn) {
10520 try {
10521 await withButtonBusy(btn, 'Enriching…', async () => {
10522 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/enrich', { method: 'POST', body: '{}' });
10523 });
10524 showToast('Proposal enriched.');
10525 openProposal(id);
10526 loadProposals();
10527 // Scroll the detail panel to the top so enriched content (labels, frontmatter, hints)
10528 // is visible instead of the browser staying at whatever scroll position it was at.
10529 const scrollHost = el('detail-body');
10530 if (scrollHost) requestAnimationFrame(() => scrollHost.scrollTo({ top: 0, behavior: 'smooth' }));
10531 // Also highlight the matching row in the Review/Activity list so the user can see which
10532 // proposal was enriched.
10533 requestAnimationFrame(() => {
10534 const row = document.querySelector('[data-id="' + CSS.escape(id) + '"]');
10535 if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
10536 });
10537 } catch (e) {
10538 showToast(e.message || 'Enrich failed', true);
10539 }
10540 }
10541
10542 async function approveProposal(id, panel, btn) {
10543 try {
10544 const db = el('detail-body');
10545 const waiverEl = db && db.querySelector ? db.querySelector('#proposal-waiver-reason') : null;
10546 const waiver_reason = waiverEl && waiverEl.value ? String(waiverEl.value).trim() : '';
10547 const approveBody = {};
10548 if (waiver_reason) approveBody.waiver_reason = waiver_reason;
10549 let approveOut = null;
10550 await withButtonBusy(btn, 'Approving…', async () => {
10551 approveOut = await api('/api/v1/proposals/' + encodeURIComponent(id) + '/approve', {
10552 method: 'POST',
10553 body: JSON.stringify(approveBody),
10554 });
10555 });
10556 if (approveOut && approveOut.approval_log_written === false) {
10557 showToast(
10558 approveOut.approval_log_error
10559 ? 'Approved, but approval log failed: ' + String(approveOut.approval_log_error).slice(0, 120)
10560 : 'Approved, but approval log was not written. Check server logs and re-index.',
10561 true,
10562 );
10563 }
10564 hideDetailPanelChrome();
10565 hubMarkSemanticIndexStale();
10566 loadProposals();
10567 loadNotes();
10568 loadActivity();
10569 } catch (e) {
10570 const msg = e.message || String(e);
10571 showToast('Approve failed: ' + msg, true);
10572 }
10573 }
10574
10575 async function discardProposal(id, panel, btn) {
10576 try {
10577 await withButtonBusy(btn, 'Discarding…', async () => {
10578 await api('/api/v1/proposals/' + encodeURIComponent(id) + '/discard', { method: 'POST' });
10579 });
10580 hideDetailPanelChrome();
10581 loadProposals();
10582 loadActivity();
10583 } catch (e) {
10584 const msg = e.message || String(e);
10585 showToast('Discard failed: ' + msg, true);
10586 }
10587 }
10588
10589 el('detail-close').onclick = () => closeDetailPanel();
10590 // Footer close must be resolved inside #detail-panel only: note/proposal HTML can inject ids
10591 // (e.g. markdown heading ids) that collide with getElementById and steal the handler.
10592 (function wireDetailPanelFooterClose() {
10593 const panel = el('detail-panel');
10594 const footBtn = panel && panel.querySelector('button[data-hub-detail-close]');
10595 if (footBtn) footBtn.addEventListener('click', () => closeDetailPanel());
10596 })();
10597
10598 (function initHubHeaderOffsetSync() {
10599 syncHubHeaderOffset();
10600 window.addEventListener('resize', () => syncHubHeaderOffset());
10601 if (typeof ResizeObserver !== 'undefined') {
10602 const header = document.querySelector('.hub-header');
10603 if (header) {
10604 const ro = new ResizeObserver(() => syncHubHeaderOffset());
10605 ro.observe(header);
10606 }
10607 }
10608 })();
10609
10610 // Resizable detail panel — drag the left edge to widen/narrow.
10611 (function initDetailPanelResize() {
10612 const panel = el('detail-panel');
10613 if (!panel) return;
10614 const handle = document.createElement('div');
10615 handle.className = 'detail-resize-handle';
10616 handle.title = 'Drag to resize panel';
10617 panel.prepend(handle);
10618 const MIN_W = 280;
10619 const MAX_W = Math.round(window.innerWidth * 0.92);
10620 let startX = 0, startW = 0, dragging = false;
10621 const onMove = (e) => {
10622 if (!dragging) return;
10623 const clientX = e.touches ? e.touches[0].clientX : e.clientX;
10624 const delta = startX - clientX;
10625 const newW = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
10626 panel.style.width = newW + 'px';
10627 };
10628 const onUp = () => {
10629 if (!dragging) return;
10630 dragging = false;
10631 handle.classList.remove('dragging');
10632 document.removeEventListener('mousemove', onMove);
10633 document.removeEventListener('mouseup', onUp);
10634 document.removeEventListener('touchmove', onMove);
10635 document.removeEventListener('touchend', onUp);
10636 document.body.style.userSelect = '';
10637 };
10638 handle.addEventListener('mousedown', (e) => {
10639 e.preventDefault();
10640 dragging = true;
10641 startX = e.clientX;
10642 startW = panel.offsetWidth;
10643 handle.classList.add('dragging');
10644 document.body.style.userSelect = 'none';
10645 document.addEventListener('mousemove', onMove);
10646 document.addEventListener('mouseup', onUp);
10647 });
10648 handle.addEventListener('touchstart', (e) => {
10649 dragging = true;
10650 startX = e.touches[0].clientX;
10651 startW = panel.offsetWidth;
10652 handle.classList.add('dragging');
10653 document.addEventListener('touchmove', onMove, { passive: true });
10654 document.addEventListener('touchend', onUp);
10655 });
10656 })();
10657
10658 document.addEventListener('keydown', (e) => {
10659 const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
10660 if (e.key === 'Escape') {
10661 if (el('detail-panel') && !el('detail-panel').classList.contains('hidden')) {
10662 closeDetailPanel();
10663 e.preventDefault();
10664 /* Close the topmost modal first (later in DOM stacks above onboarding when both are open). */
10665 } else if (el('modal-how-to-use') && !el('modal-how-to-use').classList.contains('hidden')) {
10666 closeHowToUse();
10667 e.preventDefault();
10668 } else if (el('modal-integ-guide') && !el('modal-integ-guide').classList.contains('hidden')) {
10669 closeIntegGuideModal();
10670 e.preventDefault();
10671 } else if (el('modal-settings') && !el('modal-settings').classList.contains('hidden')) {
10672 closeSettings();
10673 e.preventDefault();
10674 } else if (el('modal-projects-help') && !el('modal-projects-help').classList.contains('hidden')) {
10675 closeProjectsHelpModal();
10676 e.preventDefault();
10677 } else if (el('modal-onboarding') && !el('modal-onboarding').classList.contains('hidden')) {
10678 closeOnboardingWizardResume();
10679 e.preventDefault();
10680 } else if (el('modal-import') && !el('modal-import').classList.contains('hidden')) {
10681 closeImportModal();
10682 e.preventDefault();
10683 } else if (el('modal-create-similar-project') && !el('modal-create-similar-project').classList.contains('hidden')) {
10684 closeFullCreateSimilarModal();
10685 e.preventDefault();
10686 } else if (el('modal-create-proposal') && !el('modal-create-proposal').classList.contains('hidden')) {
10687 closeCreateProposalModal();
10688 e.preventDefault();
10689 } else if (el('modal-create') && !el('modal-create').classList.contains('hidden')) {
10690 closeCreateModal();
10691 e.preventDefault();
10692 } else if (el('search-key-help')?.open) {
10693 el('search-key-help').open = false;
10694 e.preventDefault();
10695 }
10696 return;
10697 }
10698 if (inInput && e.key !== 'Escape') return;
10699 const searchSec = el('hub-search-section') || document.querySelector('.search-section');
10700 const noteSearchVisible = searchSec && !searchSec.classList.contains('hidden');
10701 if (e.key === '/' && noteSearchVisible) {
10702 searchQuery.focus();
10703 e.preventDefault();
10704 return;
10705 }
10706 // Enter: if the search box has text but focus is elsewhere (e.g. after clicking the list),
10707 // run semantic search instead of opening the selected row (avoids "second search does nothing").
10708 if (e.key === 'Enter' && noteSearchVisible) {
10709 const q = (searchQuery.value || '').trim();
10710 if (q) {
10711 e.preventDefault();
10712 void runVaultSearch();
10713 return;
10714 }
10715 }
10716 const notesTabActive = document.querySelector('[data-tab="notes"]')?.classList.contains('active');
10717 const listViewVisible = !el('notes-view-list').classList.contains('hidden');
10718 const items = notesList.querySelectorAll('.list-item');
10719 if (notesTabActive && listViewVisible && items.length > 0) {
10720 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
10721 listSelectedIndex = Math.min(listSelectedIndex + 1, items.length - 1);
10722 updateListSelection();
10723 e.preventDefault();
10724 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
10725 listSelectedIndex = Math.max(listSelectedIndex - 1, 0);
10726 updateListSelection();
10727 e.preventDefault();
10728 } else if (e.key === 'Enter' && items[listSelectedIndex]) {
10729 const node = items[listSelectedIndex];
10730 if (node.dataset.path) openNote(node.dataset.path);
10731 else if (node.dataset.id) openProposal(node.dataset.id);
10732 e.preventDefault();
10733 }
10734 return;
10735 }
10736 const propContainer = getActiveProposalListContainer();
10737 if (propContainer) {
10738 const propItems = propContainer.querySelectorAll('.list-item[data-id]');
10739 if (propItems.length > 0) {
10740 if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
10741 proposalListSelectedIndex = Math.min(proposalListSelectedIndex + 1, propItems.length - 1);
10742 updateProposalListSelection(propContainer);
10743 e.preventDefault();
10744 } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
10745 proposalListSelectedIndex = Math.max(proposalListSelectedIndex - 1, 0);
10746 updateProposalListSelection(propContainer);
10747 e.preventDefault();
10748 } else if (e.key === 'Enter' && propItems[proposalListSelectedIndex]) {
10749 const node = propItems[proposalListSelectedIndex];
10750 setReviewSplitPosition(proposalListSelectedIndex + 1, propItems.length);
10751 openProposal(node.dataset.id);
10752 e.preventDefault();
10753 }
10754 }
10755 }
10756 });
10757
10758 document.addEventListener('click', (e) => {
10759 const keyHelp = el('search-key-help');
10760 if (!keyHelp || !keyHelp.open) return;
10761 if (keyHelp.contains(e.target)) return;
10762 keyHelp.open = false;
10763 });
10764
10765 document.querySelectorAll('[data-tab].tab').forEach((tab) => {
10766 tab.onclick = () => {
10767 switchHubMainTab(tab.dataset.tab);
10768 };
10769 });
10770 const hubRailHistory = el('hub-rail-history');
10771 if (hubRailHistory) {
10772 hubRailHistory.addEventListener('click', () => openHistoryMode());
10773 }
10774 const hubBottomHistory = el('hub-bottom-history');
10775 if (hubBottomHistory) {
10776 hubBottomHistory.addEventListener('click', () => {
10777 closeHubMoreSheet();
10778 openHistoryMode();
10779 });
10780 }
10781 const hubBottomMore = el('hub-bottom-more');
10782 if (hubBottomMore) {
10783 hubBottomMore.addEventListener('click', () => {
10784 const sheet = el('hub-more-sheet');
10785 const open = sheet && !sheet.classList.contains('hidden');
10786 setHubMoreSheetOpen(!open);
10787 });
10788 }
10789 document.querySelectorAll('[data-hub-more-close]').forEach((node) => {
10790 node.addEventListener('click', () => closeHubMoreSheet());
10791 });
10792 document.querySelectorAll('[data-hub-more-action]').forEach((btn) => {
10793 btn.addEventListener('click', () => {
10794 const action = btn.getAttribute('data-hub-more-action');
10795 closeHubMoreSheet();
10796 runHubSecondaryAction(action);
10797 });
10798 });
10799 document.addEventListener('keydown', (e) => {
10800 if (e.key !== 'Escape') return;
10801 const sheet = el('hub-more-sheet');
10802 if (sheet && !sheet.classList.contains('hidden')) {
10803 closeHubMoreSheet();
10804 e.preventDefault();
10805 }
10806 });
10807 const hubRailInsights = el('hub-rail-insights');
10808 if (hubRailInsights) {
10809 hubRailInsights.addEventListener('click', () => runHubSecondaryAction('insights'));
10810 }
10811 const hubRailImport = el('hub-rail-import');
10812 if (hubRailImport) {
10813 hubRailImport.addEventListener('click', () => runHubSecondaryAction('import'));
10814 }
10815 const hubRailConnect = el('hub-rail-connect');
10816 if (hubRailConnect) {
10817 hubRailConnect.addEventListener('click', () => runHubSecondaryAction('connect'));
10818 }
10819 const hubRailSettings = el('hub-rail-settings');
10820 if (hubRailSettings) {
10821 hubRailSettings.addEventListener('click', () => runHubSecondaryAction('settings'));
10822 }
10823 const hubRailHelp = el('hub-rail-help');
10824 if (hubRailHelp) {
10825 hubRailHelp.addEventListener('click', () => runHubSecondaryAction('help'));
10826 }
10827 const needsYouOpen = el('hub-needs-you-open');
10828 if (needsYouOpen) {
10829 needsYouOpen.addEventListener('click', () => switchHubMainTab('suggested'));
10830 }
10831 const needsYouDismiss = el('hub-needs-you-dismiss');
10832 if (needsYouDismiss) {
10833 needsYouDismiss.addEventListener('click', () => {
10834 hubNeedsYouDismissed = true;
10835 try {
10836 sessionStorage.setItem('hub_needs_you_dismissed', '1');
10837 } catch (_) {}
10838 updateNeedsYouBanner(hubReviewBadgePrevCount);
10839 });
10840 }
10841 if (btnHeaderSuggested) {
10842 btnHeaderSuggested.addEventListener('click', () => switchHubMainTab('suggested'));
10843 }
10844
10845 function escapeHtml(s) {
10846 const div = document.createElement('div');
10847 div.textContent = s == null ? '' : String(s);
10848 return div.innerHTML;
10849 }
10850
10851 // ── Consolidation UI (Stream 2) ───────────────────────────────
10852
10853 function consolModeFromSettings(s) {
10854 if (!s || !s.daemon) return 'off';
10855 if (s.daemon.enabled) return 'daemon';
10856 if (s.hosted_delegating || (s.vault_path_display || '').toLowerCase() === 'canister') return 'hosted';
10857 return 'off';
10858 }
10859
10860 function populateConsolSettingsForm(s) {
10861 if (!s || !s.daemon) return;
10862 const d = s.daemon;
10863 const mode = consolModeFromSettings(s);
10864 document.querySelectorAll('input[name="consol-mode"]').forEach((r) => { r.checked = r.value === mode; });
10865 applyConsolModeVisibility(mode);
10866 const iv = el('consol-interval');
10867 if (iv) iv.value = d.interval_minutes ?? 120;
10868 const idle = el('consol-idle-only');
10869 if (idle) idle.checked = d.idle_only !== false;
10870 const idleTh = el('consol-idle-threshold');
10871 if (idleTh) idleTh.value = d.idle_threshold_minutes ?? 15;
10872 const ros = el('consol-run-on-start');
10873 if (ros) ros.checked = Boolean(d.run_on_start);
10874 const pc = el('pass-consolidate');
10875 if (pc) pc.checked = d.passes?.consolidate !== false;
10876 const pv = el('pass-verify');
10877 if (pv) pv.checked = d.passes?.verify !== false;
10878 const pd = el('pass-discover');
10879 if (pd) pd.checked = Boolean(d.passes?.discover);
10880 const lp = el('consol-llm-provider');
10881 if (lp) lp.value = d.llm?.provider || '';
10882 const lm = el('consol-llm-model');
10883 if (lm) lm.value = d.llm?.model || '';
10884 const lb = el('consol-llm-base-url');
10885 if (lb) lb.value = d.llm?.base_url || '';
10886 const lbh = el('consol-lookback-hours');
10887 if (lbh) lbh.value = d.lookback_hours ?? 24;
10888 const me = el('consol-max-events');
10889 if (me) me.value = d.max_events_per_pass ?? 200;
10890 const mt = el('consol-max-topics');
10891 if (mt) mt.value = d.max_topics_per_pass ?? 10;
10892 const lmt = el('consol-llm-max-tokens');
10893 if (lmt) lmt.value = d.llm?.max_tokens ?? 1024;
10894 const cc = el('consol-cost-cap');
10895 if (cc) cc.value = d.max_cost_per_day_usd != null ? d.max_cost_per_day_usd : '';
10896 const chi = el('consol-hosted-interval');
10897 if (chi && d.interval_minutes != null) {
10898 const v = String(d.interval_minutes);
10899 const allowed = ['30', '60', '120', '360', '720', '1440', '10080'];
10900 chi.value = allowed.includes(v) ? v : '120';
10901 }
10902 }
10903
10904 function buildConsolSettingsPayload() {
10905 const modeRadio = document.querySelector('input[name="consol-mode"]:checked');
10906 const mode = modeRadio ? modeRadio.value : 'off';
10907 const hostedSel = el('consol-hosted-interval');
10908 const intervalRaw =
10909 mode === 'hosted' && hostedSel ? hostedSel.value : el('consol-interval')?.value;
10910 const llm = {
10911 provider: el('consol-llm-provider')?.value || '',
10912 model: el('consol-llm-model')?.value || '',
10913 base_url: el('consol-llm-base-url')?.value || '',
10914 };
10915 if (mode === 'daemon') {
10916 llm.max_tokens = Math.max(
10917 64,
10918 Math.min(8192, Math.floor(Number(el('consol-llm-max-tokens')?.value) || 1024)),
10919 );
10920 }
10921 const payload = {
10922 mode,
10923 enabled: mode === 'daemon',
10924 interval_minutes: Math.max(1, Math.floor(Number(intervalRaw) || 120)),
10925 idle_only: Boolean(el('consol-idle-only')?.checked),
10926 idle_threshold_minutes: Math.max(1, Math.floor(Number(el('consol-idle-threshold')?.value) || 15)),
10927 run_on_start: Boolean(el('consol-run-on-start')?.checked),
10928 passes: {
10929 consolidate: Boolean(el('pass-consolidate')?.checked),
10930 verify: Boolean(el('pass-verify')?.checked),
10931 discover: Boolean(el('pass-discover')?.checked),
10932 },
10933 llm,
10934 max_cost_per_day_usd: el('consol-cost-cap')?.value === '' ? null : Number(el('consol-cost-cap')?.value) || 0,
10935 };
10936 if (mode === 'daemon') {
10937 payload.lookback_hours = Math.max(
10938 1,
10939 Math.min(8760, Math.floor(Number(el('consol-lookback-hours')?.value) || 24)),
10940 );
10941 payload.max_events_per_pass = Math.max(
10942 1,
10943 Math.min(10000, Math.floor(Number(el('consol-max-events')?.value) || 200)),
10944 );
10945 payload.max_topics_per_pass = Math.max(
10946 1,
10947 Math.min(500, Math.floor(Number(el('consol-max-topics')?.value) || 10)),
10948 );
10949 }
10950 return payload;
10951 }
10952
10953 function applyConsolModeVisibility(mode) {
10954 const daemonSection = el('consol-daemon-settings');
10955 const hostedSection = el('consol-hosted-settings');
10956 const llmSection = el('consol-llm-settings');
10957 const costGuard = el('consol-cost-guard');
10958 if (daemonSection) daemonSection.style.display = mode === 'daemon' ? '' : 'none';
10959 if (hostedSection) hostedSection.style.display = mode === 'hosted' ? '' : 'none';
10960 if (llmSection) llmSection.style.display = mode === 'daemon' ? '' : 'none';
10961 if (costGuard) costGuard.style.display = mode === 'daemon' ? '' : 'none';
10962 }
10963
10964 document.querySelectorAll('input[name="consol-mode"]').forEach((radio) => {
10965 radio.addEventListener('change', () => applyConsolModeVisibility(radio.value));
10966 });
10967
10968 let lastChatKeyAvailable = {};
10969
10970 function chatProviderKeyHintText(provider, keyAvail) {
10971 const ka = keyAvail || {};
10972 switch (provider) {
10973 case '':
10974 return 'Auto-detect uses an available managed key if present, otherwise falls back to local Ollama.';
10975 case 'ollama':
10976 return 'Runs on your own Ollama instance — free and private. Set OLLAMA_URL / OLLAMA_CHAT_MODEL on the server if not default.';
10977 case 'openrouter':
10978 return ka.openrouter
10979 ? 'OPENROUTER_API_KEY is set on the server. Calls are billed to your OpenRouter account (not Knowtation packs).'
10980 : 'Set OPENROUTER_API_KEY on the server to use this lane (BYO key).';
10981 case 'openai':
10982 return ka.openai ? 'OPENAI_API_KEY is set on the server.' : 'Set OPENAI_API_KEY on the server to use this lane.';
10983 case 'anthropic':
10984 return ka.anthropic ? 'ANTHROPIC_API_KEY is set on the server.' : 'Set ANTHROPIC_API_KEY on the server to use this lane.';
10985 case 'deepinfra':
10986 return ka.deepinfra ? 'DEEPINFRA_API_KEY is set on the server.' : 'Set DEEPINFRA_API_KEY on the server to use this lane.';
10987 default:
10988 return '';
10989 }
10990 }
10991
10992 function applyChatProviderSettings(s) {
10993 const chat = (s && s.chat) || {};
10994 const sel = el('chat-provider-select');
10995 const keyHint = el('chat-provider-key-hint');
10996 const envHint = el('chat-provider-env-hint');
10997 const adminHint = el('chat-provider-admin-hint');
10998 const saveBtn = el('btn-chat-provider-save');
10999 const msg = el('chat-provider-msg');
11000 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
11001 if (!sel) return;
11002 lastChatKeyAvailable = chat.key_available || {};
11003 const isAdmin = String(s && s.role) === 'admin';
11004 const envLocked = Boolean(chat.env_locked);
11005 sel.value = envLocked ? (chat.env_provider || '') : (chat.provider || '');
11006 sel.disabled = envLocked || !isAdmin;
11007 if (saveBtn) saveBtn.disabled = envLocked || !isAdmin;
11008 if (adminHint) adminHint.classList.toggle('hidden', isAdmin || envLocked);
11009 if (envHint) {
11010 if (envLocked) {
11011 envHint.textContent =
11012 'Locked by the KNOWTATION_CHAT_PROVIDER environment variable (operator-managed). Unset it on the server to choose from here.';
11013 envHint.classList.remove('hidden');
11014 } else {
11015 envHint.classList.add('hidden');
11016 }
11017 }
11018 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
11019
11020 if (!sel.dataset.knowtationBound) {
11021 sel.dataset.knowtationBound = '1';
11022 sel.addEventListener('change', () => {
11023 if (keyHint) keyHint.textContent = chatProviderKeyHintText(sel.value, lastChatKeyAvailable);
11024 });
11025 }
11026 const btn = el('btn-chat-provider-save');
11027 if (btn && !btn.dataset.knowtationBound) {
11028 btn.dataset.knowtationBound = '1';
11029 btn.addEventListener('click', async () => {
11030 const m = el('chat-provider-msg');
11031 if (m) { m.textContent = 'Saving…'; m.className = 'settings-msg'; }
11032 try {
11033 const res = await api('/api/v1/settings/chat', {
11034 method: 'POST',
11035 body: JSON.stringify({ provider: sel.value }),
11036 });
11037 if (res && res.chat) sel.value = res.chat.provider || '';
11038 if (m) { m.textContent = 'Saved.'; m.className = 'settings-msg ok'; }
11039 } catch (e) {
11040 if (m) {
11041 m.textContent = e && e.message ? String(e.message) : 'Failed to save provider';
11042 m.className = 'settings-msg err';
11043 }
11044 }
11045 });
11046 }
11047 }
11048
11049 async function loadConsolidationSettings() {
11050 const msg = el('consol-save-status');
11051 if (msg) msg.textContent = '';
11052 try {
11053 const s = await api('/api/v1/settings');
11054 populateConsolSettingsForm(s);
11055 } catch (e) {
11056 if (msg) { msg.textContent = e?.message || 'Failed to load settings'; msg.className = 'settings-msg err'; }
11057 }
11058 }
11059
11060 const btnConsolSave = el('btn-consol-save');
11061 if (btnConsolSave) {
11062 btnConsolSave.addEventListener('click', async () => {
11063 const msg = el('consol-save-status');
11064 if (msg) { msg.textContent = ''; msg.className = 'settings-msg'; }
11065 const payload = buildConsolSettingsPayload();
11066 if (payload.enabled && payload.interval_minutes < 30) {
11067 if (msg) { msg.textContent = 'Interval must be at least 30 minutes in daemon mode.'; msg.className = 'settings-msg err'; }
11068 return;
11069 }
11070 setButtonBusy(btnConsolSave, true, 'Saving…');
11071 try {
11072 await api('/api/v1/settings/consolidation', {
11073 method: 'POST',
11074 body: JSON.stringify(payload),
11075 });
11076 if (msg) { msg.textContent = 'Saved.'; msg.className = 'settings-msg ok'; }
11077 } catch (e) {
11078 if (msg) { msg.textContent = e?.message || 'Failed to save'; msg.className = 'settings-msg err'; }
11079 }
11080 setButtonBusy(btnConsolSave, false);
11081 });
11082 }
11083
11084 const linkConsolHelp = el('link-consol-help');
11085 if (linkConsolHelp) {
11086 linkConsolHelp.addEventListener('click', (e) => {
11087 e.preventDefault();
11088 closeSettings();
11089 openHowToUse('consolidation');
11090 });
11091 }
11092
11093 // ── Consolidation Dashboard Card ──────────────────────────────
11094
11095 function formatCostMeter(costUsd, capUsd) {
11096 const cost = Math.max(0, Number(costUsd) || 0);
11097 const cap = capUsd != null ? Math.max(0, Number(capUsd) || 0) : null;
11098 const display = '$' + cost.toFixed(3) + ' today';
11099 if (cap == null || cap === 0) return { fillPercent: 0, display, capLabel: '', showMeter: false };
11100 const pct = Math.min(100, (cost / cap) * 100);
11101 return { fillPercent: pct, display, capLabel: 'cap: $' + cap.toFixed(2), showMeter: true };
11102 }
11103
11104 function renderConsolidationHistory(events, container) {
11105 if (!container) return;
11106 if (!events || events.length === 0) {
11107 container.innerHTML = '<p class="muted">No consolidation history found.</p>';
11108 return;
11109 }
11110 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>';
11111 events.forEach((ev) => {
11112 const ts = ev.ts || ev.timestamp || ev.created_at;
11113 const date = ts ? new Date(ts).toLocaleString() : '—';
11114 const rawTopics = ev.data?.topics_count;
11115 const topics = Array.isArray(rawTopics) ? rawTopics.length : (rawTopics ?? ev.data?.topics?.length ?? '—');
11116 const merged = ev.data?.total_events ?? ev.data?.event_count ?? '—';
11117 const status = ev.data?.dry_run ? 'dry-run' : (ev.data?.error ? 'error' : 'complete');
11118 html += '<tr><td>' + escapeHtml(date) + '</td><td>' + escapeHtml(String(topics)) + '</td><td>' + escapeHtml(String(merged)) + '</td><td>' + escapeHtml(status) + '</td></tr>';
11119 });
11120 html += '</tbody></table>';
11121 container.innerHTML = html;
11122 }
11123
11124 async function refreshConsolidationCard() {
11125 const card = el('consolidation-card');
11126 const badge = el('consol-status-badge');
11127 const lastPass = el('consol-last-pass');
11128 const nextPass = el('consol-next-pass');
11129 const quotaMeter = el('consol-quota-meter');
11130 const quotaLabel = el('consol-quota-label');
11131 const quotaFill = el('consol-quota-fill');
11132 const btnNow = el('btn-consol-now');
11133 if (!card) return;
11134
11135 try {
11136 const s = await api('/api/v1/settings');
11137 const mode = consolModeFromSettings(s);
11138 if (mode === 'off') {
11139 card.style.display = 'none';
11140 return;
11141 }
11142 card.style.display = '';
11143
11144 if (mode === 'hosted') {
11145 try {
11146 const st = await api('/api/v1/memory/consolidate/status');
11147 if (badge) {
11148 badge.textContent = '● Active (hosted)';
11149 badge.className = 'consol-badge consol-badge-success';
11150 }
11151 if (lastPass) lastPass.textContent = 'Last pass: ' + (st.last_pass ? new Date(st.last_pass).toLocaleString() : '—');
11152 if (nextPass) nextPass.textContent = 'Next pass: scheduled';
11153
11154 // Quota display using tier limit from local constant (same source as billing-constants.mjs)
11155 const passUsed = st.pass_count_month ?? 0;
11156 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
11157 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
11158 if (quotaMeter) {
11159 if (passLimit === null) {
11160 if (quotaLabel) quotaLabel.textContent = passUsed + ' consolidations this month (unlimited)';
11161 if (quotaFill) quotaFill.style.width = '0%';
11162 } else if (passLimit > 0) {
11163 const pct = Math.min(100, Math.round((passUsed / passLimit) * 100));
11164 if (quotaLabel) quotaLabel.textContent = passUsed + ' of ' + passLimit + ' consolidations used';
11165 if (quotaFill) quotaFill.style.width = pct + '%';
11166 }
11167 quotaMeter.style.display = passLimit !== 0 ? '' : 'none';
11168 }
11169
11170 // Disable "Consolidate Now" during cooldown; show time remaining.
11171 const cooldown = st.cooldown_minutes ?? 0;
11172 if (btnNow && cooldown > 0) {
11173 btnNow.disabled = true;
11174 btnNow.textContent = 'Available in ' + cooldown + ' min';
11175 } else if (btnNow) {
11176 btnNow.disabled = false;
11177 btnNow.textContent = 'Consolidate Now';
11178 }
11179 } catch (_) {
11180 if (badge) { badge.textContent = '● Hosted'; badge.className = 'consol-badge consol-badge-warning'; }
11181 }
11182 } else {
11183 if (badge) {
11184 badge.textContent = s.daemon.enabled ? '● Daemon enabled' : '● Not running';
11185 badge.className = 'consol-badge ' + (s.daemon.enabled ? 'consol-badge-success' : 'consol-badge-warning');
11186 }
11187 if (lastPass) lastPass.textContent = 'Last pass: —';
11188 if (nextPass) nextPass.textContent = 'Next pass: ' + (s.daemon.enabled ? 'per daemon schedule' : '—');
11189 if (quotaMeter) quotaMeter.style.display = 'none';
11190 }
11191 } catch (_) {
11192 card.style.display = 'none';
11193 }
11194 }
11195
11196 const btnConsolNow = el('btn-consol-now');
11197 if (btnConsolNow) {
11198 btnConsolNow.addEventListener('click', async () => {
11199 setButtonBusy(btnConsolNow, true, 'Previewing…');
11200 try {
11201 const preview = await api('/api/v1/memory/consolidate', {
11202 method: 'POST',
11203 body: JSON.stringify({ dry_run: true }),
11204 });
11205 setButtonBusy(btnConsolNow, false);
11206 const topicsRaw = preview.topics;
11207 const topics = Array.isArray(topicsRaw) ? topicsRaw.length : (preview.topics_count ?? topicsRaw ?? 0);
11208 const events = preview.total_events ?? 0;
11209 // Fetch current quota to show remaining passes in the preview dialog.
11210 let quotaLine = '';
11211 try {
11212 const st = await api('/api/v1/memory/consolidate/status');
11213 const passUsed = st.pass_count_month ?? 0;
11214 const currentTier = (typeof window !== 'undefined' && window.__billing_tier) || 'free';
11215 const passLimit = CONSOLIDATION_PASSES_BY_TIER[currentTier] ?? 0;
11216 if (passLimit === null) {
11217 quotaLine = '\nConsolidations this month: ' + passUsed + ' (unlimited)';
11218 } else if (passLimit > 0) {
11219 const remaining = Math.max(0, passLimit - passUsed);
11220 quotaLine = '\nConsolidations remaining: ' + remaining + ' of ' + passLimit;
11221 }
11222 } catch (_) {}
11223 const ok = confirm('Consolidation preview:\n\nTopics found: ' + topics + '\nEvents to merge: ' + events + quotaLine + '\n\nProceed?');
11224 if (!ok) return;
11225 setButtonBusy(btnConsolNow, true, 'Consolidating…');
11226 await api('/api/v1/memory/consolidate', {
11227 method: 'POST',
11228 body: JSON.stringify({ dry_run: false }),
11229 });
11230 if (typeof showToast === 'function') showToast('Consolidation complete.');
11231 refreshConsolidationCard();
11232 } catch (e) {
11233 const msg = e?.message || 'Consolidation failed';
11234 if (typeof showToast === 'function') showToast(msg, true);
11235 // Re-check cooldown after a rate-limit response so the button state updates.
11236 refreshConsolidationCard();
11237 }
11238 setButtonBusy(btnConsolNow, false);
11239 });
11240 }
11241
11242 const btnConsolHistory = el('btn-consol-history');
11243 if (btnConsolHistory) {
11244 btnConsolHistory.addEventListener('click', async () => {
11245 setButtonBusy(btnConsolHistory, true, 'Loading…');
11246 try {
11247 const res = await api('/api/v1/memory?type=consolidation_pass&limit=20');
11248 const events = res.events || res.history || [];
11249 setButtonBusy(btnConsolHistory, false);
11250 const modal = document.createElement('div');
11251 modal.className = 'modal';
11252 modal.setAttribute('aria-modal', 'true');
11253 modal.innerHTML =
11254 '<div class="modal-backdrop"></div>' +
11255 '<div class="modal-card consol-history-modal">' +
11256 '<div class="modal-header"><h2>Consolidation History</h2><button type="button" class="modal-close" aria-label="Close">×</button></div>' +
11257 '<div style="padding: 1rem 1.25rem;" id="consol-history-body"></div></div>';
11258 document.body.appendChild(modal);
11259 renderConsolidationHistory(events, modal.querySelector('#consol-history-body'));
11260 modal.querySelector('.modal-backdrop').onclick = () => modal.remove();
11261 modal.querySelector('.modal-close').onclick = () => modal.remove();
11262 } catch (e) {
11263 setButtonBusy(btnConsolHistory, false);
11264 if (typeof showToast === 'function') showToast(e?.message || 'Failed to load history', true);
11265 }
11266 });
11267 }
11268
11269 function openSettingsConsolidationTab() {
11270 openSettings();
11271 document.querySelectorAll('.settings-tab').forEach((t) => {
11272 t.classList.toggle('active', t.dataset.settingsTab === 'consolidation');
11273 t.setAttribute('aria-selected', t.dataset.settingsTab === 'consolidation' ? 'true' : 'false');
11274 });
11275 document.querySelectorAll('.settings-panel').forEach((p) => {
11276 p.classList.toggle('active', p.id === 'settings-panel-consolidation');
11277 });
11278 loadConsolidationSettings();
11279 }
11280
11281 const btnConsolSettings = el('btn-consol-settings');
11282 if (btnConsolSettings) {
11283 btnConsolSettings.addEventListener('click', openSettingsConsolidationTab);
11284 }
11285
11286 // Billing panel: consolidation row population (piggyback on loadBillingPanel)
11287 const _origLoadBillingPanel = typeof loadBillingPanel === 'function' ? loadBillingPanel : null;
11288 // Billing consolidation row is populated inline in loadBillingPanel's try block.
11289 // We add to the existing billing flow by hooking the billing API response.
11290
11291 // Refresh consolidation card when dashboard renders
11292 const _origRenderDashboard = typeof renderDashboard === 'function' ? renderDashboard : null;
11293 })();
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago