// Password vault workbench runtime — dedicated /vault CRUD UI. // Refresh model: command-result re-fetch only; no setInterval. (function initVaultWorkbenchRuntime() { if (window.__mnoteVaultWorkbenchStarted) return; window.__mnoteVaultWorkbenchStarted = true; function qs(sel, root) { return (root || document).querySelector(sel); } function qsa(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); } function toast(message, kind) { if (window.mnote && typeof window.mnote.toast === 'function') { window.mnote.toast(message, { kind: kind || 'info' }); return; } console.log('[vault]', kind || 'info', message); } function readRootUri(root) { var fromData = root.getAttribute('data-root-uri') || ''; if (fromData) return fromData; var body = document.body && document.body.getAttribute('data-mnote-root-uri'); return body || ''; } function apiUrl(path, rootUri, extra) { var url = new URL(path, window.location.origin); if (rootUri) url.searchParams.set('rootUri', rootUri); url.searchParams.set('sourceKind', 'local_folder'); if (extra) { Object.keys(extra).forEach(function (key) { if (extra[key] != null && extra[key] !== '') { url.searchParams.set(key, extra[key]); } }); } return url.toString(); } async function apiJson(method, path, rootUri, body, queryExtra) { var opts = { method: method, credentials: 'same-origin', headers: { Accept: 'application/json' }, }; var url = path; if (method === 'GET' || method === 'DELETE') { url = apiUrl(path, rootUri, queryExtra); } else { opts.headers['Content-Type'] = 'application/json'; var payload = Object.assign({ rootUri: rootUri, sourceKind: 'local_folder' }, body || {}); opts.body = JSON.stringify(payload); if (queryExtra) { url = apiUrl(path, rootUri, queryExtra); } } var res = await fetch(url, opts); var data = null; try { data = await res.json(); } catch (_e) { data = null; } if (!res.ok || (data && data.ok === false)) { var code = (data && (data.code || (data.error && data.error.code))) || res.status; var msg = (data && (data.message || data.error || (data.error && data.error.message))) || ('请求失败 ' + res.status); if (typeof msg === 'object') msg = JSON.stringify(msg); var err = new Error(String(msg)); err.code = code; err.status = res.status; err.payload = data; throw err; } return data && data.result != null ? data.result : data; } function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Display label for a secret field state. Always HTML-escaped so callers can * safely inject into innerHTML (revealed values may contain user-controlled text). */ function maskLabel(secretState) { if (!secretState || secretState.state === 'absent') return '(无)'; if (secretState.state === 'masked') return '••••••••'; if (secretState.state === 'revealed') return escapeHtml(secretState.value || ''); return '••••••••'; } /** * Optional reusable secret-row helper. Prefer bindWorkbench's secretRow for * production paths; this stays fail-safe if called with uiState in opts. * @param {object} [opts.uiState] workbench state with revealed / _revealedValues */ function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts) { opts = opts || {}; var uiState = opts.uiState || { revealed: {}, _revealedValues: {} }; var revealed = uiState.revealed || {}; var revealedValues = uiState._revealedValues || {}; var key = opts.secretId || secretId || field || ''; var has = secretState && secretState.state === 'masked'; var canReveal = has || (secretState && secretState.state === 'revealed'); if (!canReveal && !showEmpty) return ''; var isOpen = !!revealed[key]; var shown = isOpen ? (revealedValues[key] != null ? escapeHtml(revealedValues[key]) : maskLabel(secretState)) : maskLabel(secretState); var revealBtn = isOpen ? '隐藏' : '显示'; var copyBtn = ''; var actions = '' + copyBtn; return ( '
' + '
' + shown + '
' + '
' + actions + '
' + '
' ); } function bindWorkbench(root) { if (!(root instanceof HTMLElement) || root.getAttribute('data-vault-bound') === '1') { return; } root.setAttribute('data-vault-bound', '1'); var state = { rootUri: readRootUri(root), status: root.getAttribute('data-status') || 'active', items: [], selectedId: root.getAttribute('data-selected-id') || '', item: null, dirty: false, revealed: { password: false, apikey: false, token: false }, secretDraft: { password: null, apikey: null, token: null }, mode: 'view', // view | create | edit | cipher /** @type {Record} collapsed folder paths (true = collapsed) */ collapsedFolders: {}, cipherKeys: [], cipherRevealed: {}, /** @type {HTMLInputElement|HTMLTextAreaElement|null} 编辑表单中最近一次可插入密文的输入框 */ lastCipherInsertTarget: null, /** @type {{ start: number, end: number }|null} 失焦前光标,避免点下拉后丢失 */ lastCipherInsertRange: null, isAiVault: false, aiVaultActorId: 'mnote-e2e', vaultRole: 'user', showEmptyFields: false, /** * Edit/create form UI chrome that must survive reRenderFormKeepingSlots * and view-mode renderDetail (password/appendix reveal re-renders). * Without this, appendix-secret panels always re-mount collapsed. * @type {{ openAccountIds: Record, expandedSecretPanels: Record }} */ _formUi: { openAccountIds: {}, expandedSecretPanels: {} }, }; function ensureFormUi() { if (!state._formUi) { state._formUi = { openAccountIds: {}, expandedSecretPanels: {}, folderPickerOpen: {}, }; } if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {}; if (!state._formUi.expandedSecretPanels) { state._formUi.expandedSecretPanels = {}; } if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {}; return state._formUi; } /** Account key for UI chrome (open group / appendix panel). */ function accountUiKey(accId, idx) { if (accId != null && String(accId) !== '') return String(accId); if (idx != null) return 'idx:' + String(idx); return 'default'; } function isSecretsPanelExpanded(accKey) { var ui = ensureFormUi(); return !!ui.expandedSecretPanels[accKey]; } function setSecretsPanelExpanded(accKey, expanded) { if (!accKey) return; ensureFormUi().expandedSecretPanels[accKey] = !!expanded; } /** * Build appendix-secrets panel markup; honors expanded state so reveal/hide * re-renders do not collapse an already-open panel. */ function renderSecretsPanelHtml(opts) { opts = opts || {}; var accKey = opts.accKey || 'default'; var title = opts.title || '附录密钥'; var count = opts.count || 0; var bodyHtml = opts.bodyHtml || ''; var extraAttrs = opts.extraAttrs || ''; var headExtra = opts.headExtra || ''; var expanded = isSecretsPanelExpanded(accKey); return ( '' ); } var listEl = qs('[data-vault-list]', root); var detailEl = qs('[data-vault-detail]', root); var statusEl = qs('[data-vault-status]', root); var searchEl = qs('[data-vault-search]', root); var tabActive = qs('[data-vault-tab="active"]', root); var tabDeleted = qs('[data-vault-tab="deleted"]', root); // 折叠态:localStorage(同 origin 多窗 / 未来桌面多窗共享)+ BroadcastChannel 即时同步。 // 与页面树 view-state 同思路的「本机 UI 状态」层;暂不写 control-plane。 var VAULT_COLLAPSE_LEGACY_SESSION_KEY = 'mnote.vault.collapsedFolders'; var VAULT_COLLAPSE_SCHEMA = 1; var collapseUpdatedAtMs = 0; var collapseBroadcast = null; var collapseStorageKey = vaultCollapseStorageKey(state.rootUri); function vaultCollapseStorageKey(rootUri) { var raw = String(rootUri || '').trim() || 'default'; var hash = 0; for (var i = 0; i < raw.length; i++) { hash = (hash * 31 + raw.charCodeAt(i)) | 0; } return ( 'mnote.vault.collapsedFolders.v1:' + (hash >>> 0).toString(16) + ':' + encodeURIComponent(raw.slice(0, 120)) ); } function normalizeCollapseMap(raw) { var out = {}; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; Object.keys(raw).forEach(function (k) { out[k] = !!raw[k]; }); return out; } function parseCollapsePayload(raw) { if (!raw) return null; try { var parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== 'object') return null; // v1 envelope: { schemaVersion, updatedAtMs, collapsed } if (parsed.collapsed && typeof parsed.collapsed === 'object') { return { schemaVersion: Number(parsed.schemaVersion) || VAULT_COLLAPSE_SCHEMA, updatedAtMs: Number(parsed.updatedAtMs) || 0, collapsed: normalizeCollapseMap(parsed.collapsed), }; } // legacy flat map { path: boolean } return { schemaVersion: VAULT_COLLAPSE_SCHEMA, updatedAtMs: 0, collapsed: normalizeCollapseMap(parsed), }; } catch (_e) { return null; } } function serializeCollapsePayload() { return JSON.stringify({ schemaVersion: VAULT_COLLAPSE_SCHEMA, updatedAtMs: collapseUpdatedAtMs || Date.now(), collapsed: state.collapsedFolders || {}, }); } function loadCollapseState() { var fromLocal = null; try { if (window.localStorage) { fromLocal = parseCollapsePayload(window.localStorage.getItem(collapseStorageKey)); } } catch (_e) { fromLocal = null; } // 一次性迁移:同 tab 旧 sessionStorage → localStorage var fromSession = null; try { if (window.sessionStorage) { fromSession = parseCollapsePayload( window.sessionStorage.getItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY) ); } } catch (_e) { fromSession = null; } var chosen = fromLocal; if (fromSession && (!chosen || (fromSession.updatedAtMs || 0) >= (chosen.updatedAtMs || 0))) { // 无 local 或 session 更新时间不落后时:优先有数据的一份;二者都无时间戳时若 local 空则用 session if (!chosen || !Object.keys(chosen.collapsed || {}).length) { chosen = fromSession; } } if (chosen) { state.collapsedFolders = chosen.collapsed || {}; collapseUpdatedAtMs = chosen.updatedAtMs || Date.now(); } else { state.collapsedFolders = {}; collapseUpdatedAtMs = 0; } // 若只从 session 迁过来,立刻落到 localStorage(不广播,避免冷启动风暴) if (fromSession && Object.keys(fromSession.collapsed || {}).length) { try { if (window.sessionStorage) { window.sessionStorage.removeItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY); } } catch (_e2) { /* ignore */ } if (!fromLocal || !Object.keys(fromLocal.collapsed || {}).length) { collapseUpdatedAtMs = collapseUpdatedAtMs || Date.now(); try { if (window.localStorage) { window.localStorage.setItem(collapseStorageKey, serializeCollapsePayload()); } } catch (_e3) { /* ignore */ } } } } function applyRemoteCollapsePayload(payload, source) { if (!payload || !payload.collapsed) return false; var remoteTs = Number(payload.updatedAtMs) || 0; // LWW:远程更旧则忽略(含本窗 storage 回声时用 updatedAt 防抖) if (remoteTs && collapseUpdatedAtMs && remoteTs < collapseUpdatedAtMs) { return false; } var next = normalizeCollapseMap(payload.collapsed); var prev = state.collapsedFolders || {}; var same = Object.keys(next).length === Object.keys(prev).length && Object.keys(next).every(function (k) { return prev[k] === next[k]; }); if (same && remoteTs <= collapseUpdatedAtMs) return false; state.collapsedFolders = next; collapseUpdatedAtMs = remoteTs || Date.now(); if (source !== 'localStorage-echo') { // storage 事件来自其他窗已写好 localStorage,无需再写 } return true; } function persistCollapse(options) { options = options || {}; collapseUpdatedAtMs = Date.now(); var payload = { schemaVersion: VAULT_COLLAPSE_SCHEMA, updatedAtMs: collapseUpdatedAtMs, collapsed: state.collapsedFolders || {}, }; var raw = JSON.stringify(payload); try { if (window.localStorage) { window.localStorage.setItem(collapseStorageKey, raw); } } catch (_e) { /* ignore */ } // 同 origin 其他窗:BroadcastChannel 即时;无 BC 时靠 storage 事件 if (options.broadcast !== false && collapseBroadcast) { try { collapseBroadcast.postMessage({ type: 'vault.collapse.changed', storageKey: collapseStorageKey, payload: payload, }); } catch (_e2) { /* ignore */ } } } function onCollapseSyncMessage(payload, source) { if (applyRemoteCollapsePayload(payload, source)) { // 列表已渲染时跟着重画;boot 前 renderList 可能尚不可用 if (typeof renderList === 'function') { try { renderList(); } catch (_e) { /* ignore */ } } } } function installCollapseSync() { try { if (typeof BroadcastChannel !== 'undefined') { collapseBroadcast = new BroadcastChannel('mnote.vault.collapse.v1'); collapseBroadcast.onmessage = function (ev) { var data = ev && ev.data; if (!data || data.type !== 'vault.collapse.changed') return; if (data.storageKey && data.storageKey !== collapseStorageKey) return; onCollapseSyncMessage(data.payload, 'broadcast'); }; } } catch (_e) { collapseBroadcast = null; } // 跨窗 localStorage 变更(BroadcastChannel 不可用或 Electron 分区差异时的托底) window.addEventListener('storage', function (ev) { if (!ev || ev.key !== collapseStorageKey || ev.storageArea !== window.localStorage) { return; } var remote = parseCollapsePayload(ev.newValue); if (remote) onCollapseSyncMessage(remote, 'storage'); }); } loadCollapseState(); installCollapseSync(); /** * Collapse state: only keys present in collapsedFolders are explicit. * 默认全部折叠(文件夹 + 同 URL 多账号组);用户点开后写入 localStorage 并跨窗同步。 * 旧版「未记录 = 展开」会让大分组(如「倍特」14 条)一进来铺满左栏。 */ function isCollapsedKey(key) { if (Object.prototype.hasOwnProperty.call(state.collapsedFolders, key)) { return !!state.collapsedFolders[key]; } return true; } function toggleCollapseKey(key) { state.collapsedFolders[key] = !isCollapsedKey(key); persistCollapse({ broadcast: true }); } /** Normalize folderPath for save; groups appear in the list only after an entry uses them. */ function normalizeFolderPath(raw) { var fp = String(raw || '') .trim() .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, ''); if (!fp) return ''; var parts = fp.split('/').filter(function (p) { return p && p !== '.' && p !== '..'; }); return parts.join('/'); } function isNotesEmpty(notes) { var t = String(notes || '') .replace(/^#+\s*Notes\s*/i, '') .trim(); return !t; } function itemIsSharedToAi(item) { return !!(item && (item.isSharedToAi || item.sharedToAi)); } function itemIsAiCopy(item) { return !!(item && (item.isAiSharedCopy || item.sharedFrom || ((item.tags || []).indexOf('ai-shared') >= 0))); } function updateHeaderChrome() { var titleEl = qs('.mnote-vault-header h1', root); var badge = qs('[data-vault-role-badge]', root); if (titleEl) { titleEl.textContent = state.isAiVault ? 'AI 密码本' : '密码箱'; } if (!badge) { var main = qs('.mnote-vault-header-main', root); if (main) { badge = document.createElement('span'); badge.setAttribute('data-vault-role-badge', '1'); badge.className = 'mnote-vault-role-badge'; main.appendChild(badge); } } if (badge) { if (state.isAiVault) { badge.hidden = false; badge.textContent = 'AI 专用 · ' + (state.aiVaultActorId || 'mnote-e2e'); badge.classList.add('is-ai'); } else { badge.hidden = true; badge.classList.remove('is-ai'); } } var repairBtn = qs('[data-vault-repair-ai-folders]', root); if (repairBtn) { repairBtn.hidden = !state.isAiVault; } root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user'); } function setStatus(message, type) { if (!statusEl) return; statusEl.textContent = message || ''; statusEl.setAttribute('data-type', type || 'info'); } async function confirmDiscard() { if (!state.dirty) return true; return await window.mnote.confirm('有未保存的修改,确定放弃?'); } function filteredItems() { var q = searchEl && searchEl.value ? String(searchEl.value).trim().toLowerCase() : ''; if (!q) return state.items.slice(); return state.items.filter(function (item) { var hay = [ item.title, item.url, (item.urls || []).join(' '), item.username, item.email, item.passwordHint, item.folderPath, (item.tags || []).join(' '), ] .filter(Boolean) .join(' ') .toLowerCase(); return hay.indexOf(q) >= 0; }); } /** Build nested folder tree from folderPath (a/b/c). Only paths that have entries. */ function buildFolderTree(items) { var treeRoot = { name: '', path: '', children: {}, items: [] }; items.forEach(function (item) { var fp = (item.folderPath || '').trim(); if (!fp) { treeRoot.items.push(item); return; } var parts = fp.split('/').filter(Boolean); var node = treeRoot; var acc = []; parts.forEach(function (part) { acc.push(part); var p = acc.join('/'); if (!node.children[part]) { node.children[part] = { name: part, path: p, children: {}, items: [] }; } node = node.children[part]; }); node.items.push(item); }); return treeRoot; } function renderItemButton(item) { var active = item.id === state.selectedId ? ' is-active' : ''; var accN = item.accountCount || 0; var secN = item.secretCount || 0; var metaParts = [item.username || item.email || '']; if (accN > 1) metaParts.push(accN + ' 账号'); if (secN > 0) metaParts.push(secN + ' 密钥'); var meta = metaParts.filter(Boolean).join(' · '); // Single-row compact: title · meta | status glyphs only (no badge rows). var icons = ''; if (itemIsSharedToAi(item) && !state.isAiVault) { icons += ''; } if (itemIsAiCopy(item)) { icons += ''; } return ( '' ); } function countNodeItems(node) { var n = node.items.length; Object.keys(node.children).forEach(function (k) { n += countNodeItems(node.children[k]); }); return n; } /** Group items sharing the same non-empty URL into a collapsible site node. */ function renderItemsWithUrlGroups(items, depth, folderPath) { var html = ''; var byUrl = {}; var noUrl = []; items.forEach(function (item) { var u = (item.url || '').trim(); if (!u) { noUrl.push(item); return; } if (!byUrl[u]) byUrl[u] = []; byUrl[u].push(item); }); // Collapsible site group only when 2+ accounts share the URL. Object.keys(byUrl) .sort(function (a, b) { return a.localeCompare(b, 'zh'); }) .forEach(function (url) { var group = byUrl[url]; if (group.length < 2) { noUrl.push(group[0]); return; } var siteKey = 'url:' + (folderPath || '') + '|' + url; var collapsed = isCollapsedKey(siteKey); var label = shortenUrlLabel(url); html += '
' + ''; if (!collapsed) { html += '
'; group .slice() .sort(function (a, b) { var ua = String(a.username || a.title || ''); var ub = String(b.username || b.title || ''); return ua.localeCompare(ub, 'zh'); }) .forEach(function (item) { html += '
' + renderItemButton(item) + '
'; }); html += '
'; } html += '
'; }); noUrl .slice() .sort(function (a, b) { return String(a.title || '').localeCompare(String(b.title || ''), 'zh'); }) .forEach(function (item) { html += '
' + renderItemButton(item) + '
'; }); return html; } function shortenUrlLabel(url) { try { var u = new URL(url.indexOf('://') >= 0 ? url : 'https://' + url); return u.host + (u.pathname && u.pathname !== '/' ? u.pathname : ''); } catch (_e) { return url.length > 40 ? url.slice(0, 37) + '…' : url; } } function renderTreeNode(node, depth) { var html = ''; var childKeys = Object.keys(node.children).sort(function (a, b) { return a.localeCompare(b, 'zh'); }); childKeys.forEach(function (key) { var child = node.children[key]; // 只有一个直连凭证的叶子分组会在左栏重复同名标签,直接展平。 if (child.items.length === 1 && Object.keys(child.children).length === 0) { html += '
' + renderItemButton(child.items[0]) + '
'; return; } var collapsed = isCollapsedKey(child.path); var count = countNodeItems(child); html += '
' + ''; if (!collapsed) { html += '
'; html += renderTreeNode(child, depth + 1); html += renderItemsWithUrlGroups(child.items, depth + 1, child.path); html += '
'; } html += '
'; }); return html; } function renderList() { if (!listEl) return; var items = filteredItems(); if (!items.length) { listEl.innerHTML = '
暂无条目。点击「新建」添加。
'; return; } var tree = buildFolderTree(items); var html = ''; // Root (ungrouped) items first — also group by same URL if (tree.items.length) { html += '
' + '
未分组
' + '
' + renderItemsWithUrlGroups(tree.items, 0, '') + '
'; } html += renderTreeNode(tree, 0); listEl.innerHTML = html; } /** Ordered unique URLs from item (primary first); supports legacy single `url`. */ function normalizeUrlsFromItem(item) { var out = []; function push(u) { var t = String(u || '').trim(); if (!t) return; if (out.indexOf(t) >= 0) return; out.push(t); } if (item && Array.isArray(item.urls) && item.urls.length) { item.urls.forEach(push); } if (item && item.url) push(item.url); // Prefer primary url first when both present and order disagrees. if (item && item.url && out.length) { var primary = String(item.url).trim(); if (primary && out[0] !== primary && out.indexOf(primary) >= 0) { out = [primary].concat( out.filter(function (u) { return u !== primary; }) ); } } return out; } /** * URLs for the edit form: keep empty draft rows (so "+ 网址" can add a blank * line). normalizeUrlsFromItem strips empties and would make add-url look dead. */ function urlsForFormEditor(item) { if (item && Array.isArray(item.urls) && item.urls.length) { return item.urls.map(function (u) { return u == null ? '' : String(u); }); } var urls = normalizeUrlsFromItem(item); if (!urls.length) urls = ['']; return urls; } /** Make a safe absolute href for external open (http/https only). */ function externalUrlHref(raw) { var t = String(raw || '').trim(); if (!t) return ''; if (/^https?:\/\//i.test(t)) return t; if (/^\/\//.test(t)) return 'https:' + t; // Bare host/path: assume https (common for vault entries). if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}([/:].*)?$/i.test(t)) { return 'https://' + t; } return ''; } /** View-mode URL as hyperlink look; open only via Ctrl/Cmd+click (see click handler). */ function urlLinkHtml(raw) { var text = String(raw || '').trim(); if (!text) return ''; var href = externalUrlHref(text); if (!href) { return '' + escapeHtml(text) + ''; } return ( '' + escapeHtml(text) + '' ); } function emptyForm(defaults) { defaults = defaults || {}; var urls = normalizeUrlsFromItem(defaults); if (!urls.length && defaults.url) urls = [String(defaults.url)]; return { id: '', title: defaults.title || '', url: urls[0] || defaults.url || '', urls: urls, username: defaults.username || '', email: defaults.email || '', passwordHint: defaults.passwordHint || '', folderPath: defaults.folderPath || '', password: { state: 'absent' }, apikey: { state: 'absent' }, token: { state: 'absent' }, accounts: defaults.accounts || [ { id: '', label: '', username: defaults.username || '', email: defaults.email || '', passwordHint: defaults.passwordHint || '', password: { state: 'absent' }, secrets: defaults.secrets || [], }, ], // Top-level secrets kept for API flatten only; UI nests under accounts. secrets: defaults.secrets || [], tags: defaults.tags || [], notesMarkdown: defaults.notesMarkdown || '## Notes\n\n', revision: 0, status: 'active', }; } function newClientId(prefix) { return ( prefix + '_' + Math.random().toString(16).slice(2) + Date.now().toString(16) ); } /** View shows expanded value; edit prefers raw [Key] template when present. */ function cipherFieldValue(displayValue, templateValue) { if (templateValue) return String(templateValue); return displayValue || ''; } function mapSecretSlot(s) { return { id: (s && s.id) || '', kind: (s && s.kind) || 'apikey', label: (s && s.label) || '', value: (s && s.value) || { state: 'absent' }, // Draft-only flag from reRenderFormKeepingSlots; absent on server items. valueClear: !!(s && s.valueClear), accountId: (s && (s.accountId || s.account_id)) || '', }; } /** Flatten top-level secrets for legacy items; prefer nested account.secrets. */ function normalizeSecretsFromItem(item) { var secrets = (item && item.secrets) || []; if (secrets.length) { return secrets.map(mapSecretSlot); } var out = []; if (item && item.apikey && item.apikey.state && item.apikey.state !== 'absent') { out.push({ id: '', kind: 'apikey', label: 'API Key', value: item.apikey, accountId: '', }); } if (item && item.token && item.token.state && item.token.state !== 'absent') { out.push({ id: '', kind: 'token', label: 'Token', value: item.token, accountId: '', }); } return out; } /** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */ function formatVaultDateTime(raw) { var s = String(raw || '').trim(); if (!s) return ''; var d = new Date(s); if (isNaN(d.getTime())) return s; try { return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); } catch (_e) { return s; } } /** * 账号级登录态展示(扩展 Cookie session)。 * 优先账号槽位字段;合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt。 */ function accountSessionView(acc, item, isSyntheticSingle) { var has = acc && typeof acc.hasLoginSession === 'boolean' ? acc.hasLoginSession : isSyntheticSingle && item ? !!item.hasLoginSession : false; var savedAt = (acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) || (isSyntheticSingle && item ? item.lastLoginAt || item.sessionUpdatedAt || '' : '') || ''; var expiresAt = (acc && acc.sessionExpiresAt) || (isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') || ''; return { hasLoginSession: !!has, savedAt: String(savedAt || '').trim(), expiresAt: String(expiresAt || '').trim(), source: (acc && acc.sessionSource) || '', }; } /** 详情页:账号下「是否保存登录态 + 保存日期」行。 */ function renderAccountSessionRows(sess, showEmpty) { if (!sess.hasLoginSession && !showEmpty) { // 未保存时也简短展示一行,避免用户以为功能缺失 return ( '
' + '' + '
未保存
' + '
' ); } var badge = sess.hasLoginSession ? '已保存' : '未保存'; var dateText = sess.hasLoginSession ? formatVaultDateTime(sess.savedAt) || '—' : '—'; var html = '
' + '' + '
' + badge + '
' + '
' + '' + '
' + (sess.hasLoginSession ? escapeHtml(dateText) : '') + '
'; if (sess.hasLoginSession && sess.expiresAt) { html += '
' + '' + '
' + escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) + '
'; } return html; } function normalizeAccountsFromItem(item, forEdit) { var topSecrets = normalizeSecretsFromItem(item); var accounts = (item && item.accounts) || []; if (accounts.length) { var anyNested = accounts.some(function (a) { return a.secrets && a.secrets.length; }); return accounts.map(function (a, aidx) { var nested = (a.secrets || []).map(mapSecretSlot); // Legacy: top-level secrets attach to first account when no nested. if (!anyNested && aidx === 0 && topSecrets.length) { nested = topSecrets.slice(); } else if (!anyNested && topSecrets.length) { nested = topSecrets.filter(function (s) { return s.accountId && s.accountId === a.id; }); } // 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影) var hasSess = typeof a.hasLoginSession === 'boolean' ? a.hasLoginSession : aidx === 0 ? !!(item && item.hasLoginSession) : false; var lastLogin = a.lastLoginAt || (aidx === 0 && item ? item.lastLoginAt : null) || null; var sessUpdated = a.sessionUpdatedAt || (aidx === 0 && item ? item.sessionUpdatedAt : null) || null; var sessExpires = a.sessionExpiresAt || (aidx === 0 && item ? item.sessionExpiresAt : null) || null; return { id: a.id || '', label: a.label || '', username: forEdit ? cipherFieldValue(a.username, a.usernameTemplate) : a.username || '', email: forEdit ? cipherFieldValue(a.email, a.emailTemplate) : a.email || '', passwordHint: a.passwordHint || '', password: a.password || { state: 'absent' }, // Draft-only flag from reRenderFormKeepingSlots; absent on server items. passwordClear: !!a.passwordClear, secrets: nested, hasLoginSession: hasSess, lastLoginAt: lastLogin, sessionUpdatedAt: sessUpdated, sessionExpiresAt: sessExpires, sessionSource: a.sessionSource || '', }; }); } if ( (item && (item.username || item.email || item.passwordHint || item.usernameTemplate || item.emailTemplate)) || (item && item.password && item.password.state && item.password.state !== 'absent') || topSecrets.length ) { return [ { id: '', label: '', username: forEdit ? cipherFieldValue(item && item.username, item && item.usernameTemplate) : (item && item.username) || '', email: forEdit ? cipherFieldValue(item && item.email, item && item.emailTemplate) : (item && item.email) || '', passwordHint: (item && item.passwordHint) || '', password: (item && item.password) || { state: 'absent' }, secrets: topSecrets, // 合成单账号:登录态取条目级 hasLoginSession: !!(item && item.hasLoginSession), lastLoginAt: (item && item.lastLoginAt) || null, sessionUpdatedAt: (item && item.sessionUpdatedAt) || null, sessionExpiresAt: (item && item.sessionExpiresAt) || null, sessionSource: '', _syntheticFromItem: true, }, ]; } return [ { id: '', label: '', username: '', email: '', passwordHint: '', password: { state: 'absent' }, secrets: [], hasLoginSession: !!(item && item.hasLoginSession), lastLoginAt: (item && item.lastLoginAt) || null, sessionUpdatedAt: (item && item.sessionUpdatedAt) || null, sessionExpiresAt: (item && item.sessionExpiresAt) || null, sessionSource: '', _syntheticFromItem: true, }, ]; } function renderDetail() { if (!detailEl) return; if (!state.rootUri) { detailEl.innerHTML = '
请先打开本地文件夹工作区,再使用密码箱。
'; syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'cipher') { renderCipherBook(); syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'create') { renderForm(emptyForm(state._createDefaults || {}), true); syncHeaderCipherInsertVisibility(); return; } if (!state.item && !state.selectedId) { detailEl.innerHTML = '
从左侧选择条目,或点击「新建」。
'; syncHeaderCipherInsertVisibility(); return; } if (!state.item) { detailEl.innerHTML = '
加载中…
'; syncHeaderCipherInsertVisibility(); return; } if (state.mode === 'edit') { renderForm(state.item, false); syncHeaderCipherInsertVisibility(); return; } renderView(state.item); syncHeaderCipherInsertVisibility(); } function revealKey(field, accountId, secretId) { if (secretId) return 'secret:' + secretId; if (accountId && field === 'password') return 'account:' + accountId + ':password'; return field; } function secretRow(label, field, secretState, showEmpty, opts) { opts = opts || {}; var accountId = opts.accountId || ''; var secretId = opts.secretId || ''; var key = revealKey(field, accountId, secretId); var has = secretState && secretState.state === 'masked'; var canReveal = has || (secretState && secretState.state === 'revealed'); if (!canReveal && !showEmpty) return ''; var shown = state.revealed[key] ? state._revealedValues && state._revealedValues[key] ? escapeHtml(state._revealedValues[key]) : '…' : maskLabel(secretState); var extra = (accountId ? ' data-vault-account-id="' + escapeHtml(accountId) + '"' : '') + (secretId ? ' data-vault-secret-id="' + escapeHtml(secretId) + '"' : ''); return ( '
' + '' + '
' + shown + '
' + '
' + (canReveal ? '' + '' : '未设置') + '
' ); } function sameUrlAccounts(item) { var url = (item.url || '').trim(); if (!url) return []; return state.items.filter(function (other) { return other.id !== item.id && (other.url || '').trim() === url; }); } function renderView(item) { var visibleTags = (item.tags || []).filter(function (t) { return t !== 'ai-shared'; }); var tags = visibleTags .map(function (t) { return '' + escapeHtml(t) + ''; }) .join(' '); var isDeleted = item.status === 'deleted' || state.status === 'deleted'; var siblings = sameUrlAccounts(item); var siblingHtml = ''; if (siblings.length) { siblingHtml = '
' + siblings .map(function (s) { return ( '' ); }) .join('') + '
'; } var shared = itemIsSharedToAi(item); var aiCopy = itemIsAiCopy(item) || state.isAiVault; var link = item.sharedToAi || {}; var titleChips = ''; if (shared && !state.isAiVault) { titleChips += '已共享'; } if (aiCopy && (item.sharedFrom || state.isAiVault)) { titleChips += 'AI'; } var shareBtn = ''; if (!isDeleted && !state.isAiVault && !aiCopy) { shareBtn = shared ? '' + '' : ''; } var showEmpty = !!state.showEmptyFields; var accounts = normalizeAccountsFromItem(item, false); var accountsHtml = accounts .map(function (acc, idx) { var title = acc.label || acc.username || acc.email || '账号 ' + (idx + 1); var sess = accountSessionView( acc, item, !!acc._syntheticFromItem || accounts.length === 1 ); var body = fieldRow('标签名', acc.label, showEmpty) + fieldRow('用户名', acc.username, showEmpty) + fieldRow('邮箱', acc.email, showEmpty) + secretRow('密码', 'password', acc.password, showEmpty, { accountId: acc.id || '', }) + fieldRow('密码提示', acc.passwordHint, showEmpty) + renderAccountSessionRows(sess, showEmpty); var secList = acc.secrets || []; var nestedSecretsHtml = secList .map(function (sec, sidx) { var kindLabel = sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key'; var stitle = sec.label || kindLabel + ' ' + (sidx + 1); return ( '
' + '
' + escapeHtml(kindLabel) + (sec.label ? ' · ' + escapeHtml(sec.label) : '') + '
' + secretRow( stitle, sec.kind === 'token' ? 'token' : 'apikey', sec.value, showEmpty, { secretId: sec.id || '' } ) + '
' ); }) .join(''); if (nestedSecretsHtml) { // 附录密钥默认折叠;展开态记在 state,避免显示/隐藏密码时整页重绘后被收起。 body += renderSecretsPanelHtml({ accKey: accountUiKey(acc.id, idx), title: '附录密钥', count: secList.length, bodyHtml: nestedSecretsHtml, }); } else if (showEmpty) { body += ''; } if (!body.trim()) { body = '
(空账号组)
'; } return ( '
' + '' + '账号 · ' + escapeHtml(title) + '' + (secList.length ? '密钥 ' + secList.length + '' : '') + (sess.hasLoginSession ? '登录态' : '') + '' + '
' + body + '
' ); }) .join(''); if (!accountsHtml && showEmpty) { accountsHtml = '
暂无账号组
'; } var urls = normalizeUrlsFromItem(item); var urlsHtml = ''; if (urls.length) { urlsHtml = urls .map(function (u, idx) { var label = idx === 0 ? (urls.length > 1 ? '主网址' : '网址') : '备用 ' + idx; return ( '
' + '' + '
' + urlLinkHtml(u) + '
' ); }) .join(''); } else if (showEmpty) { urlsHtml = fieldRow('网址', '', true); } var fieldsHtml = fieldRow('分组', item.folderPath, showEmpty) + (urls.length > 1 ? '
' + '
网址(等价入口)' + '' + urls.length + ' 个
' + urlsHtml + '
' : urlsHtml) + '
' + '
登录账号' + (accounts.length > 1 ? '' + accounts.length + ' 组' : '') + '
' + accountsHtml + '
' + (visibleTags.length || showEmpty ? '
' + (tags || '') + '
' : '') + siblingHtml + (!isNotesEmpty(item.notesMarkdown) || showEmpty ? '
' +
            escapeHtml(item.notesMarkdown || '') +
            '
' : '') + '

' + '

'; detailEl.innerHTML = '
' + '
' + '

' + '' + escapeHtml(item.title || '') + '' + titleChips + '

' + '
' + (isDeleted ? '' + '' : shareBtn + '' + '') + '
' + '
' + fieldsHtml + '
'; } function fieldRow(label, value, showEmpty) { if (!value && !showEmpty) return ''; return ( '
' + (value ? escapeHtml(value) : '') + '
' ); } /** Unique folderPath values from current list (for picker / datalist). */ function collectFolderPaths() { var set = {}; (state.items || []).forEach(function (item) { var fp = (item.folderPath || '').trim(); if (!fp) return; set[fp] = true; var parts = fp.split('/').filter(Boolean); for (var i = 1; i < parts.length; i++) { set[parts.slice(0, i).join('/')] = true; } }); return Object.keys(set).sort(function (a, b) { return a.localeCompare(b, 'zh'); }); } /** * Nested tree of known folderPath segments for the expand-style picker. * @returns {{ name: string, path: string, children: Array }} */ function buildFolderPickerTree(paths) { var root = { name: '', path: '', children: [] }; var byPath = { '': root }; (paths || []).forEach(function (fp) { var parts = String(fp || '') .split('/') .filter(Boolean); var acc = []; var parent = root; parts.forEach(function (part) { acc.push(part); var p = acc.join('/'); if (!byPath[p]) { var node = { name: part, path: p, children: [] }; byPath[p] = node; parent.children.push(node); } parent = byPath[p]; }); }); function sortNodes(nodes) { nodes.sort(function (a, b) { return a.name.localeCompare(b.name, 'zh'); }); nodes.forEach(function (n) { if (n.children && n.children.length) sortNodes(n.children); }); } sortNodes(root.children); return root; } /** * Whether a picker branch starts expanded. * Default: all collapsed. Only expand ancestors of the current selection * (so the selected path stays visible), or branches the user toggled open. */ function isFolderPickerBranchOpen(path, depth, current) { var ui = ensureFormUi(); if (!ui.folderPickerOpen) ui.folderPickerOpen = {}; if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) { return !!ui.folderPickerOpen[path]; } // Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection). if (current && path && current.indexOf(path + '/') === 0) { return true; } return false; } function renderFolderPickerNode(node, depth, current) { var kids = node.children || []; var hasKids = kids.length > 0; var selected = current === node.path; var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current); var html = ''; html += '
'; html += '
'; if (hasKids) { html += ''; } else { html += ''; } html += ''; html += '
'; if (hasKids) { html += '
'; kids.forEach(function (child) { html += renderFolderPickerNode(child, depth + 1, current); }); html += '
'; } html += '
'; return html; } function formFolderPathField(value) { var current = normalizeFolderPath(value || ''); var paths = collectFolderPaths(); var seen = {}; paths.forEach(function (p) { seen[p] = true; }); // 当前值若不在列表中,仍挂到树上便于高亮/再选。 var treePaths = paths.slice(); if (current && !seen[current]) { treePaths.push(current); var parts = current.split('/').filter(Boolean); for (var i = 1; i < parts.length; i++) { var prefix = parts.slice(0, i).join('/'); if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix); } } var tree = buildFolderPickerTree(treePaths); var treeHtml = ''; treeHtml += ''; tree.children.forEach(function (child) { treeHtml += renderFolderPickerNode(child, 0, current); }); if (!tree.children.length && !current) { treeHtml += '
暂无已有分组,可在下方输入新建
'; } return ( '
' + '' + '
' + '
' + treeHtml + '
' + '' + '' + paths .map(function (p) { return ''; }) .join('') + '' + '

分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。

' + '
' ); } function syncFolderPickerSelection(form, path) { if (!form) return; var picker = qs('[data-vault-folder-picker]', form); if (!picker) return; var normalized = normalizeFolderPath(path || ''); qsa('[data-vault-folder-pick]', picker).forEach(function (btn) { var p = btn.getAttribute('data-vault-folder-pick'); if (p == null) p = ''; var row = btn.closest('.mnote-vault-folder-picker-row'); var selected = p === normalized; btn.classList.toggle('is-selected', selected); if (row) row.classList.toggle('is-selected', selected); if (btn.classList.contains('mnote-vault-folder-picker-none')) { btn.classList.toggle('is-selected', selected); } }); } function bindFolderPathControls(form) { if (!form) return; var input = qs('[data-vault-folder-input]', form); var picker = qs('[data-vault-folder-picker]', form); if (picker) { picker.addEventListener('click', function (ev) { var t = ev.target; if (!(t instanceof Element)) return; var toggle = t.closest('[data-vault-folder-picker-toggle]'); if (toggle && picker.contains(toggle)) { ev.preventDefault(); var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || ''; var nodeEl = toggle.closest('.mnote-vault-folder-picker-node'); // First matching descendants is the direct children panel for this node. var kidsEl = nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children'); var open = toggle.getAttribute('aria-expanded') === 'true'; var next = !open; ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {}; ensureFormUi().folderPickerOpen[tpath] = next; toggle.setAttribute('aria-expanded', next ? 'true' : 'false'); toggle.textContent = next ? '▼' : '▶'; toggle.setAttribute('title', next ? '收起' : '展开'); if (kidsEl) kidsEl.hidden = !next; return; } var pick = t.closest('[data-vault-folder-pick]'); if (pick && picker.contains(pick)) { ev.preventDefault(); var chosen = pick.getAttribute('data-vault-folder-pick'); if (chosen == null) chosen = ''; chosen = normalizeFolderPath(chosen); if (input) input.value = chosen; syncFolderPickerSelection(form, chosen); state.dirty = true; } }); } if (input) { input.addEventListener('input', function () { syncFolderPickerSelection(form, input.value); }); input.addEventListener('change', function () { var n = normalizeFolderPath(input.value); input.value = n; syncFolderPickerSelection(form, n); state.dirty = true; }); } } function renderForm(item, isCreate) { var tagsStr = (item.tags || []).join(', '); // 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。 var accounts = normalizeAccountsFromItem(item, true); // Prefer draft rows (may include empty slots after "+ 网址"); do not strip blanks. var urls = urlsForFormEditor(item); state._formUrls = urls.slice(); /** * Rebuild draft slots from item projection. * reRenderFormKeepingSlots injects in-progress plaintext as * `{ state: 'revealed', value }` — must become valueText/passwordValue, * not be wiped to '' (that made the 1st new secret disappear after +密钥). * valueClear/passwordClear on the pseudo-item are also preserved. */ state._formAccounts = accounts.map(function (a) { var pwd = a.password || { state: 'absent' }; var passwordClear = !!a.passwordClear; var passwordValue = ''; var passwordState = pwd; if (passwordClear) { passwordState = { state: 'absent' }; passwordValue = ''; } else if (pwd && pwd.state === 'revealed') { // Draft plaintext typed in this session (not yet saved / mid re-render). passwordValue = String(pwd.value || ''); // Treat as "no stored secret yet" for keep-logic; save uses passwordValue. passwordState = { state: 'absent' }; } return { id: a.id || newClientId('acc'), label: a.label || '', username: a.username || '', email: a.email || '', passwordHint: a.passwordHint || '', passwordState: passwordState, passwordClear: passwordClear, passwordValue: passwordValue, secrets: (a.secrets || []).map(function (s) { var v = s.value || { state: 'absent' }; var valueClear = !!s.valueClear; var valueText = ''; var valueState = v; if (valueClear) { valueState = { state: 'absent' }; valueText = ''; } else if (v && v.state === 'revealed') { valueText = String(v.value || ''); valueState = { state: 'absent' }; } return { id: s.id || newClientId('sec'), kind: s.kind || 'apikey', label: s.label || '', valueState: valueState, valueClear: valueClear, valueText: valueText, accountId: a.id || '', }; }), }; }); if (!state._formAccounts.length) { state._formAccounts.push({ id: newClientId('acc'), label: '', username: '', email: '', passwordHint: '', passwordState: { state: 'absent' }, passwordClear: false, passwordValue: '', secrets: [], }); } // Legacy flat list no longer used in UI; kept empty for re-render injects. state._formSecrets = []; function urlsEditorHtml() { var rows = (state._formUrls || ['']).map(function (u, idx) { return ( '
' + '' + '' + (idx > 0 ? '' : '') + '
' ); }); return ( '
' + '
' + '网址(等价入口 / fallback)' + '' + '
' + '

同一服务多个可达地址时,第一项为主入口,其余为备用(VPS / FRP / 本地等)。

' + rows.join('') + '
' ); } function accountGroupHtml(acc, idx) { // Preserve which account groups were open across re-renders (not only idx===0). var uiOpen = state._formUi && state._formUi.openAccountIds; var open = (uiOpen && uiOpen[acc.id] === true) || (uiOpen && uiOpen[acc.id] !== false && idx === 0) || (!uiOpen && idx === 0) ? ' open' : ''; var title = acc.label || acc.username || acc.email || '账号 ' + (idx + 1); var hasPwd = acc.passwordState && acc.passwordState.state === 'masked'; var secrets = acc.secrets || []; var secretsBody = secrets.length > 0 ? secrets .map(function (sec, sidx) { return secretRowHtml(sec, acc.id, sidx); }) .join('') : '
暂无密钥,可点 + 密钥 添加
'; // 附录密钥编辑区默认折叠;展开态跨 re-render 保留(+密钥 / 显示密文等)。 var secretsPanel = renderSecretsPanelHtml({ accKey: accountUiKey(acc.id, idx), title: '附录密钥(API Key / Token)', count: secrets.length, bodyHtml: secretsBody, extraAttrs: ' data-vault-account-secrets="' + escapeHtml(acc.id) + '"', headExtra: '', }); return ( '
' + '' + '账号 · ' + escapeHtml(title) + '' + (secrets.length ? '密钥 ' + secrets.length + '' : '') + '' + (state._formAccounts.length > 1 ? '' : '') + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + (!isCreate && hasPwd ? '' + '' : '') + '
' + '
' + '
' + secretsPanel + '
' ); } function secretRowHtml(sec, accountId, idx) { var kindLabel = sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key'; var title = sec.label || kindLabel + ' ' + (idx + 1); var hasVal = sec.valueState && sec.valueState.state === 'masked'; var draftText = sec.valueText || ''; return ( '
' + '
' + '' + escapeHtml(kindLabel) + ' · ' + escapeHtml(title) + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + (hasVal ? '' + '' : '') + '
' + '
' ); } detailEl.innerHTML = '
' + '
' + '

' + (isCreate ? '新建条目' : '编辑条目') + '

' + '
' + '' + '' + '
' + '

可先点用户名 / 邮箱 / 密码框,再点顶栏「插入密文」选择已有 [Key];也可手写 [Key]

' + '
' + formField('title', '标题 *', item.title || '', true) + formFolderPathField(item.folderPath || '') + urlsEditorHtml() + '
' + '
' + '登录账号(用户名 / 邮箱 / 密码 / 提示)' + '' + '
' + state._formAccounts.map(accountGroupHtml).join('') + '
' + formField('tags', '标签(逗号分隔)', tagsStr, false) + '
' + '
' + '
'; var form = qs('[data-vault-form]', detailEl); if (form) { state.lastCipherInsertTarget = null; state.lastCipherInsertRange = null; bindFolderPathControls(form); bindFormCipherInsertTracking(form); form.addEventListener('input', function () { state.dirty = true; }); form.addEventListener('change', function () { state.dirty = true; }); form.addEventListener('submit', function (ev) { ev.preventDefault(); saveForm(form, isCreate); }); // 顶栏「插入密文」在编辑态刷新 key 列表 fetchCipherKeys({ silent: true }) .then(function () { refreshCipherInsertSelect(); }) .catch(function () { /* 静默:下拉仍显示已有缓存或空 */ }); } } function snapshotFormSlots(form) { if (!form) return; var urlInputs = form.querySelectorAll('[data-vault-url-input]'); if (urlInputs && urlInputs.length) { state._formUrls = []; for (var ui = 0; ui < urlInputs.length; ui++) { state._formUrls.push(urlInputs[ui].value || ''); } } if (!state._formAccounts) return; state._formAccounts.forEach(function (acc) { var id = acc.id; var labelEl = form.querySelector('[data-acc-field="label"][data-acc-id="' + id + '"]'); var userEl = form.querySelector('[data-acc-field="username"][data-acc-id="' + id + '"]'); var emailEl = form.querySelector('[data-acc-field="email"][data-acc-id="' + id + '"]'); var hintEl = form.querySelector( '[data-acc-field="passwordHint"][data-acc-id="' + id + '"]' ); var pwdEl = form.querySelector('[data-acc-field="password"][data-acc-id="' + id + '"]'); if (labelEl) acc.label = labelEl.value; if (userEl) acc.username = userEl.value; if (emailEl) acc.email = emailEl.value; if (hintEl) acc.passwordHint = hintEl.value; if (pwdEl) acc.passwordValue = pwdEl.value; (acc.secrets || []).forEach(function (sec) { var sid = sec.id; var kindEl = form.querySelector( '[data-sec-field="kind"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]' ); var secLabelEl = form.querySelector( '[data-sec-field="label"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]' ); var valEl = form.querySelector( '[data-sec-field="value"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]' ); if (kindEl) sec.kind = kindEl.value; if (secLabelEl) sec.label = secLabelEl.value; if (valEl) sec.valueText = valEl.value; sec.accountId = id; }); }); state._formSecrets = []; } function collectFormUrls(form) { var raw = []; if (form) { var inputs = form.querySelectorAll('[data-vault-url-input]'); for (var i = 0; i < inputs.length; i++) { raw.push(String(inputs[i].value || '').trim()); } } else if (state._formUrls) { raw = state._formUrls.map(function (u) { return String(u || '').trim(); }); } var out = []; raw.forEach(function (t) { if (!t) return; if (out.indexOf(t) >= 0) return; out.push(t); }); return out; } function reRenderFormKeepingSlots(isCreate) { var form = qs('[data-vault-form]', detailEl); if (form) snapshotFormSlots(form); var base = isCreate ? emptyForm(state._createDefaults || {}) : state.item || emptyForm(); // inject draft slots into a pseudo item for normalize base = Object.assign({}, base, { urls: (state._formUrls || []).slice(), url: (state._formUrls && state._formUrls[0]) || base.url || '', accounts: (state._formAccounts || []).map(function (a) { return { id: a.id, label: a.label, username: a.username, email: a.email, passwordHint: a.passwordHint, // Flags survive re-render so clear intent is not lost. passwordClear: !!a.passwordClear, password: a.passwordClear ? { state: 'absent' } : a.passwordValue ? { state: 'revealed', value: a.passwordValue } : a.passwordState, secrets: (a.secrets || []).map(function (s) { return { id: s.id, kind: s.kind, label: s.label, accountId: a.id, valueClear: !!s.valueClear, value: s.valueClear ? { state: 'absent' } : s.valueText ? { state: 'revealed', value: s.valueText } : s.valueState, }; }), }; }), secrets: [], }); // preserve title/url etc from form if (form) { var fd = new FormData(form); base.title = String(fd.get('title') || base.title || ''); var draftUrls = collectFormUrls(form); if (draftUrls.length || (state._formUrls && state._formUrls.length)) { // Keep empty draft rows while editing multi-url so re-render doesn't drop blanks mid-type. base.urls = (state._formUrls || draftUrls).slice(); base.url = draftUrls[0] || base.urls[0] || ''; } base.folderPath = String(fd.get('folderPath') || base.folderPath || ''); base.tags = String(fd.get('tags') || '') .split(/[,,;;\n]/) .map(function (s) { return s.trim(); }) .filter(Boolean); base.notesMarkdown = String(fd.get('notesMarkdown') || base.notesMarkdown || ''); } renderForm(base, isCreate); // restore typed secret values after re-render var form2 = qs('[data-vault-form]', detailEl); if (!form2) return; (state._formAccounts || []).forEach(function (acc) { var pwdEl = form2.querySelector( '[data-acc-field="password"][data-acc-id="' + acc.id + '"]' ); if (pwdEl && acc.passwordValue) pwdEl.value = acc.passwordValue; var showPwdBtn = form2.querySelector( '[data-acc-show-password="' + acc.id + '"]' ); if (showPwdBtn && acc.passwordValue && !acc.passwordClear) { showPwdBtn.setAttribute('data-shown', '1'); showPwdBtn.textContent = '隐藏'; } }); (state._formAccounts || []).forEach(function (acc) { (acc.secrets || []).forEach(function (sec) { var valEl = form2.querySelector( '[data-sec-field="value"][data-sec-id="' + sec.id + '"][data-acc-id="' + acc.id + '"]' ); if (valEl && sec.valueText) valEl.value = sec.valueText; var showSecBtn = form2.querySelector( '[data-sec-show-value="' + sec.id + '"]' ); if (showSecBtn && sec.valueText && !sec.valueClear) { showSecBtn.setAttribute('data-shown', '1'); showSecBtn.textContent = '隐藏'; } }); }); state.dirty = true; } function formField(name, label, value, required) { return ( '
' ); } function formSecretField(name, label, secretState, isCreate) { var placeholder = !isCreate && secretState && secretState.state === 'masked' ? '留空表示不修改;输入新值覆盖;点「清空」删除' : '可选;可用 [Key] 密文片段'; // Editing secrets is always plaintext so users can see typos / [Key] templates. // View mode still masks; reveal remains explicit + audited. return ( '
' + '
' + '' + (!isCreate ? '' : '') + '
' ); } async function loadList() { if (!state.rootUri) { state.items = []; renderList(); renderDetail(); setStatus('未绑定本地文件夹 rootUri', 'error'); return; } setStatus('加载列表…', 'info'); try { var result = await apiJson('GET', '/api/vault/list', state.rootUri, null, { status: state.status, }); state.items = (result && result.items) || []; state.isAiVault = !!(result && result.isAiVault); state.aiVaultActorId = (result && result.aiVaultActorId) || state.aiVaultActorId || 'mnote-e2e'; state.vaultRole = (result && result.vaultRole) || (state.isAiVault ? 'ai' : 'user'); updateHeaderChrome(); renderList(); if (state.selectedId) { var still = state.items.some(function (i) { return i.id === state.selectedId; }); if (!still && state.mode !== 'create') { state.selectedId = ''; state.item = null; } } if (state.selectedId && state.mode !== 'create' && state.mode !== 'edit') { await loadItem(state.selectedId); } else { renderDetail(); } var repair = result && result.folderRepair; var statusMsg = (state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条'; if (repair && Number(repair.updated) > 0) { statusMsg += ' · 已修复分组 ' + repair.updated + ' 条'; } setStatus(statusMsg, 'success'); } catch (err) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } async function repairAiFolders() { if (!state.isAiVault) return; // 你已清掉其它人的分享时,可指定默认分享者;留空则自动从 from-* / sharedFrom 推断 var hint = window.prompt( '修复 AI 分组为「用户名/原分组」。\n' + '若库内只剩一人分享,可留空自动识别;否则填写分享者用户名(如 liaibo):', 'liaibo' ); if (hint === null) return; setStatus('正在修复 AI 分组…', 'info'); try { var body = {}; var actor = String(hint || '').trim(); if (actor) body.defaultSourceActor = actor; var payload = await apiJson('POST', '/api/vault/ai/repair-folders', state.rootUri, body); var result = (payload && payload.result) || payload || {}; var n = Number(result.updated || 0); toast( n > 0 ? '已修复 ' + n + ' 条 → 用户名/原分组(如 liaibo/系统)' : '无需修复(分组已是 用户名/原分组)', n > 0 ? 'success' : 'info' ); await loadList(); } catch (err) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } async function loadItem(id) { try { var result = await apiJson('GET', '/api/vault/items/' + encodeURIComponent(id), state.rootUri); state.item = result.item || result; state.selectedId = id; state.mode = 'view'; state.dirty = false; state.revealed = { password: false, apikey: false, token: false }; state._revealedValues = {}; state.secretDraft = { password: null, apikey: null, token: null }; // 切换条目时重置附录密钥展开态(同一条目内 reveal 仍保留)。 ensureFormUi().expandedSecretPanels = {}; renderList(); renderDetail(); } catch (err) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } async function saveForm(form, isCreate) { snapshotFormSlots(form); var fd = new FormData(form); var title = String(fd.get('title') || '').trim(); if (!title) { toast('标题不能为空', 'error'); return; } var tagsRaw = String(fd.get('tags') || ''); var tags = tagsRaw .split(/[,,;;\n]/) .map(function (s) { return s.trim(); }) .filter(Boolean); var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim()); var accounts = (state._formAccounts || []) .map(function (acc) { // Prefer live input draft; fall back to revealed draft left by re-render inject. var raw = String(acc.passwordValue || ''); if ( !raw && acc.passwordState && acc.passwordState.state === 'revealed' && acc.passwordState.value ) { raw = String(acc.passwordState.value); } if (/^[•*·]+$/.test(raw) || raw === '••••••••') { toast('不能将掩码写回 password', 'error'); throw new Error('mask'); } var nestedSecrets = (acc.secrets || []).map(function (sec) { var sraw = String(sec.valueText || ''); if ( !sraw && sec.valueState && sec.valueState.state === 'revealed' && sec.valueState.value ) { sraw = String(sec.valueState.value); } if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') { toast('不能将掩码写回 secret', 'error'); throw new Error('mask'); } var sslot = { id: sec.id || undefined, kind: sec.kind || 'apikey', label: String(sec.label || '').trim() || null, accountId: acc.id || null, }; if (isCreate) { if (sraw) sslot.value = sraw; } else if (sec.valueClear) { sslot.value = { clear: true }; } else if (sraw) { sslot.value = { set: sraw }; } else if (sec.valueState && sec.valueState.state === 'masked') { sslot._keep = true; } return sslot; }); var slot = { id: acc.id && String(acc.id).indexOf('acc_') === 0 ? acc.id : acc.id || undefined, label: String(acc.label || '').trim() || null, username: String(acc.username || '').trim() || null, email: String(acc.email || '').trim() || null, passwordHint: String(acc.passwordHint || '').trim() || null, secrets: nestedSecrets, }; if (isCreate) { if (raw) slot.password = raw; } else if (acc.passwordClear) { slot.password = { clear: true }; } else if (raw) { slot.password = { set: raw }; } return slot; }) .filter(function (slot) { return ( slot.label || slot.username || slot.email || slot.passwordHint || slot.password || (slot.secrets && slot.secrets.length) ); }); // Flatten nested secrets for top-level API field (compat + server rebuild). var secrets = []; accounts.forEach(function (acc) { (acc.secrets || []).forEach(function (s) { secrets.push(s); }); }); // Edit full-replace: masked fields that were not retyped carry `_keep`. // Do NOT reveal them client-side (avoid race / drop). Strip `_keep` and omit // value/password; server merges previous secrets by id. Explicit clear uses // `{ clear: true }` → empty string on the wire and is not restored. function stripKeepMarkers(list) { return (list || []).map(function (s) { var copy = Object.assign({}, s); delete copy._keep; return copy; }); } if (!isCreate) { // accounts keep password when not retyped and not cleared for (var ai = 0; ai < accounts.length; ai++) { var a = accounts[ai]; if (!a.password && a.id) { var draftA = (state._formAccounts || []).find(function (x) { return x.id === a.id; }); if ( draftA && !draftA.passwordClear && draftA.passwordState && draftA.passwordState.state === 'masked' ) { a._keep = true; } } } accounts = stripKeepMarkers(accounts); accounts.forEach(function (acc) { if (acc.secrets && acc.secrets.length) { acc.secrets = stripKeepMarkers(acc.secrets); } }); secrets = []; accounts.forEach(function (acc) { (acc.secrets || []).forEach(function (s) { secrets.push(s); }); }); } else { // create: drop empty secret shells accounts.forEach(function (acc) { acc.secrets = (acc.secrets || []).filter(function (s) { return s.value || s.label; }); }); secrets = []; accounts.forEach(function (acc) { (acc.secrets || []).forEach(function (s) { secrets.push(s); }); }); } var urls = collectFormUrls(form); var body = { title: title, url: urls[0] || null, urls: urls, folderPath: folderPath || null, tags: tags, notesMarkdown: String(fd.get('notesMarkdown') || ''), accounts: accounts, secrets: secrets, syncAiShare: true, }; try { var result; if (isCreate) { result = await apiJson('POST', '/api/vault/items', state.rootUri, body); } else { body.baseRevision = state.item && state.item.revision; result = await apiJson( 'PATCH', '/api/vault/items/' + encodeURIComponent(state.selectedId), state.rootUri, body ); } state.item = result.item || result; state.selectedId = state.item.id; state.mode = 'view'; state.dirty = false; state.secretDraft = { password: null, apikey: null, token: null }; state._createDefaults = null; var msg = isCreate ? '已创建' : '已保存'; var aiSync = result && result.aiShareSync; if (aiSync && aiSync.ok) { msg += ' · 已同步 AI 副本'; } else if (aiSync && aiSync.ok === false) { msg += ' · AI 同步未完成(' + (aiSync.message || aiSync.code || '') + ')'; } toast(msg, aiSync && aiSync.ok === false ? 'info' : 'success'); setStatus(msg, 'success'); await loadList(); await loadItem(state.selectedId); } catch (err) { if (err.message === 'mask') return; setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } /** 仅拉取密文 key 列表(L0,无明文),供编辑表单「插入密文」使用。 */ async function fetchCipherKeys(options) { var opts = options || {}; try { var result = await apiJson('GET', '/api/vault/cipher-book', state.rootUri); state.cipherKeys = (result && result.keys) || []; if (opts.resetRevealed) state.cipherRevealed = {}; if (state.mode === 'create' || state.mode === 'edit') { refreshCipherInsertSelect(); } return state.cipherKeys; } catch (err) { if (!opts.silent) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } throw err; } } async function loadCipherBook() { try { await fetchCipherKeys({ resetRevealed: true }); renderCipherBook(); setStatus('密文簿 ' + state.cipherKeys.length + ' 个 key', 'info'); } catch (err) { // fetchCipherKeys 已 toast } } /** 判断输入是否支持插入 [Key] 密文片段(用户名/邮箱/密码/附录密钥值)。 */ function isCipherInsertTarget(el) { if (!el || !(el instanceof HTMLElement)) return false; if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return false; if (el.disabled || el.readOnly) return false; var acc = el.getAttribute('data-acc-field'); if (acc === 'username' || acc === 'email' || acc === 'password') return true; if (el.getAttribute('data-sec-field') === 'value') return true; return false; } function rememberCipherInsertTarget(el) { if (isCipherInsertTarget(el)) { state.lastCipherInsertTarget = el; try { if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') { state.lastCipherInsertRange = { start: el.selectionStart, end: el.selectionEnd, }; } } catch (_e) { /* ignore */ } } } function captureCipherInsertRange(el) { if (!isCipherInsertTarget(el)) return; state.lastCipherInsertTarget = el; try { if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') { state.lastCipherInsertRange = { start: el.selectionStart, end: el.selectionEnd, }; } } catch (_e) { /* ignore */ } } function resolveCipherInsertTarget(form) { var active = document.activeElement; if (form && active && form.contains(active) && isCipherInsertTarget(active)) { captureCipherInsertRange(active); return active; } var remembered = state.lastCipherInsertTarget; if ( remembered && form && form.contains(remembered) && isCipherInsertTarget(remembered) ) { return remembered; } // 回退:第一个密码框 → 用户名 → 邮箱 → 密钥值 if (!form) return null; state.lastCipherInsertRange = null; return ( form.querySelector('[data-acc-field="password"]') || form.querySelector('[data-acc-field="username"]') || form.querySelector('[data-acc-field="email"]') || form.querySelector('[data-sec-field="value"]') ); } /** 在光标处插入 `[Key]`;不改服务端解析规则。 */ function insertCipherKeyIntoField(input, key) { if (!input || !key) return false; var token = '[' + key + ']'; var start; var end; var range = state.lastCipherInsertRange; if ( range && state.lastCipherInsertTarget === input && typeof range.start === 'number' && typeof range.end === 'number' ) { start = range.start; end = range.end; } else { start = typeof input.selectionStart === 'number' ? input.selectionStart : input.value.length; end = typeof input.selectionEnd === 'number' ? input.selectionEnd : input.value.length; } // 钳制到当前值长度(输入可能在失焦后被改过) var len = input.value.length; if (start < 0) start = 0; if (end < 0) end = 0; if (start > len) start = len; if (end > len) end = len; if (end < start) end = start; var before = input.value.slice(0, start); var after = input.value.slice(end); input.value = before + token + after; var caret = start + token.length; try { input.focus(); input.setSelectionRange(caret, caret); } catch (_e) { /* ignore non-text inputs */ } state.dirty = true; state.lastCipherInsertTarget = input; state.lastCipherInsertRange = { start: caret, end: caret }; // 触发已绑定的 input 监听 try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_e2) { /* older engines */ } return true; } /** 顶栏「插入密文」options HTML(与密文簿 key 列表同步)。 */ function cipherInsertOptionsHtml() { var keys = state.cipherKeys || []; return ( '' + keys .map(function (k) { var key = (k && k.key) || k; if (!key) return ''; return ( '' ); }) .join('') ); } function headerCipherInsertSelect() { return qs('[data-vault-insert-cipher]', root); } function refreshCipherInsertSelect() { var sel = headerCipherInsertSelect(); if (!sel) return; sel.innerHTML = cipherInsertOptionsHtml(); sel.value = ''; } /** * 仅在新建/编辑表单时显示顶栏「插入密文」; * 放在密文簿旁,详情区滚动时仍可见。 */ function syncHeaderCipherInsertVisibility() { var wrap = qs('.mnote-vault-insert-cipher', root); if (!wrap) return; var editing = state.mode === 'create' || state.mode === 'edit'; wrap.hidden = !editing; wrap.setAttribute('aria-hidden', editing ? 'false' : 'true'); if (editing) refreshCipherInsertSelect(); } /** 表单内记录焦点/光标,供顶栏下拉插入使用。 */ function bindFormCipherInsertTracking(form) { if (!form || form.getAttribute('data-cipher-insert-bound') === '1') return; form.setAttribute('data-cipher-insert-bound', '1'); form.addEventListener('focusin', function (ev) { var t = ev.target; if (t instanceof HTMLElement) rememberCipherInsertTarget(t); }); form.addEventListener('focusout', function (ev) { var t = ev.target; if (t instanceof HTMLElement) captureCipherInsertRange(t); }); form.addEventListener('mouseup', function (ev) { var t = ev.target; if (t instanceof HTMLElement) captureCipherInsertRange(t); }); form.addEventListener('keyup', function (ev) { var t = ev.target; if (t instanceof HTMLElement) captureCipherInsertRange(t); }); } /** 顶栏「插入密文」下拉:绑定一次,插入目标为当前编辑表单字段。 */ function bindHeaderCipherInsertControls() { if (root.getAttribute('data-cipher-insert-header-bound') === '1') return; root.setAttribute('data-cipher-insert-header-bound', '1'); var sel = headerCipherInsertSelect(); if (!sel) return; sel.addEventListener('change', function () { var key = String(sel.value || '').trim(); sel.value = ''; if (!key) return; var form = qs('[data-vault-form]', detailEl); if (!form || (state.mode !== 'create' && state.mode !== 'edit')) { toast('请先新建或编辑条目', 'info'); return; } var target = resolveCipherInsertTarget(form); if (!target) { toast('请先点击用户名 / 邮箱 / 密码 / 密钥值输入框', 'info'); return; } if (insertCipherKeyIntoField(target, key)) { toast('已插入 [' + key + ']', 'success'); } }); // 失焦前点下拉时仍能记住光标:mousedown 捕获阶段 sel.addEventListener('mousedown', function () { var form = qs('[data-vault-form]', detailEl); if (!form) return; var active = document.activeElement; if (active && form.contains(active) && active instanceof HTMLElement) { captureCipherInsertRange(active); } }); } function renderCipherBook() { if (!detailEl) return; var rows = state.cipherKeys .map(function (k) { var key = k.key; var revealed = state.cipherRevealed[key]; return ( '
' + '[' + escapeHtml(key) + ']' + '' + (revealed ? escapeHtml(revealed) : k.hasValue ? '••••••••' : '(空)') + '' + '' + '' + '' + '' + '
' ); }) .join(''); detailEl.innerHTML = '
' + '
' + '

密文簿

' + '
' + '' + '
' + '
' + '' + '' + '' + '
' + '
' + (rows || '
尚无片段。例如 key=A 值为 Li@,密码可写 Li@[A]s3cret
') + '
'; var form = qs('[data-vault-cipher-form]', detailEl); if (form) { form.addEventListener('submit', function (ev) { ev.preventDefault(); var fd = new FormData(form); var key = String(fd.get('key') || '').trim(); var value = String(fd.get('value') || ''); if (!key || !value) { toast('需要 Key 与片段值', 'error'); return; } apiJson( 'PUT', '/api/vault/cipher-book/' + encodeURIComponent(key), state.rootUri, { value: value } ) .then(function () { toast('已保存 [' + key + ']', 'success'); return loadCipherBook(); }) .catch(function (err) { toast(err.message || String(err), 'error'); }); }); } } root.addEventListener('click', async function (ev) { var t = ev.target; if (!(t instanceof Element)) return; // View-mode URL: look like a link, open only with Ctrl/Cmd+click. var urlLink = t.closest('a.mnote-vault-url-link, a[data-vault-external-url]'); if (urlLink && root.contains(urlLink)) { var href = urlLink.getAttribute('data-vault-external-url') || urlLink.getAttribute('href') || ''; if (!(ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); return; } ev.preventDefault(); if (href) { try { window.open(href, '_blank', 'noopener,noreferrer'); } catch (_e) { /* ignore popup block */ } } return; } var toggle = t.closest('[data-vault-toggle-folder]'); if (toggle && root.contains(toggle)) { var fpath = toggle.getAttribute('data-vault-toggle-folder') || ''; toggleCollapseKey(fpath); renderList(); return; } var itemBtn = t.closest('[data-vault-item-id]'); if (itemBtn && root.contains(itemBtn)) { var id = itemBtn.getAttribute('data-vault-item-id'); if (!confirmDiscard()) return; state.mode = 'view'; loadItem(id); return; } if (t.closest('[data-vault-create], [data-vault-new]')) { if (!confirmDiscard()) return; state.mode = 'create'; state.selectedId = ''; state.item = null; state._createDefaults = null; state.dirty = false; state.secretDraft = { password: null, apikey: null, token: null }; renderList(); renderDetail(); return; } if (t.closest('[data-vault-toggle-empty-fields]')) { state.showEmptyFields = !state.showEmptyFields; if (state.item && state.mode === 'view') renderView(state.item); return; } // 附录密钥折叠/展开(详情与编辑共用);写入 state,避免 reveal 重绘后丢失。 var secretsToggle = t.closest('[data-vault-toggle-secrets]'); if (secretsToggle && root.contains(secretsToggle)) { var panel = secretsToggle.closest('[data-vault-secrets-panel]') || secretsToggle.closest('[data-vault-account-secrets]'); if (panel) { var bodyEl = panel.querySelector('.mnote-vault-account-secrets-body'); var collapsed = panel.classList.contains('is-collapsed'); var nextExpanded = !!collapsed; var accKey = panel.getAttribute('data-vault-secrets-acc') || panel.getAttribute('data-vault-account-secrets') || 'default'; setSecretsPanelExpanded(accKey, nextExpanded); if (nextExpanded) { panel.classList.remove('is-collapsed'); if (bodyEl) bodyEl.hidden = false; secretsToggle.setAttribute('aria-expanded', 'true'); var chevOpen = secretsToggle.querySelector('.mnote-vault-secrets-chevron'); if (chevOpen) chevOpen.textContent = '▼'; } else { panel.classList.add('is-collapsed'); if (bodyEl) bodyEl.hidden = true; secretsToggle.setAttribute('aria-expanded', 'false'); var chevClose = secretsToggle.querySelector('.mnote-vault-secrets-chevron'); if (chevClose) chevClose.textContent = '▶'; } } return; } if (t.closest('[data-vault-add-url]')) { var formAddUrl = qs('[data-vault-form]', detailEl); if (formAddUrl) snapshotFormSlots(formAddUrl); state._formUrls = state._formUrls || ['']; state._formUrls.push(''); reRenderFormKeepingSlots(state.mode === 'create'); return; } var removeUrl = t.closest('[data-vault-remove-url]'); if (removeUrl) { var urlIdx = parseInt(removeUrl.getAttribute('data-vault-remove-url'), 10); var formRmUrl = qs('[data-vault-form]', detailEl); if (formRmUrl) snapshotFormSlots(formRmUrl); state._formUrls = (state._formUrls || []).filter(function (_u, i) { return i !== urlIdx; }); if (!state._formUrls.length) state._formUrls = ['']; reRenderFormKeepingSlots(state.mode === 'create'); return; } if (t.closest('[data-vault-add-account-slot]')) { var formAddAcc = qs('[data-vault-form]', detailEl); if (formAddAcc) snapshotFormSlots(formAddAcc); state._formAccounts = state._formAccounts || []; state._formAccounts.push({ id: newClientId('acc'), label: '', username: '', email: '', passwordHint: '', passwordState: { state: 'absent' }, passwordClear: false, passwordValue: '', secrets: [], }); reRenderFormKeepingSlots(state.mode === 'create'); return; } var removeAcc = t.closest('[data-vault-remove-account]'); if (removeAcc) { var rid = removeAcc.getAttribute('data-vault-remove-account'); var formRmAcc = qs('[data-vault-form]', detailEl); if (formRmAcc) snapshotFormSlots(formRmAcc); state._formAccounts = (state._formAccounts || []).filter(function (a) { return a.id !== rid; }); if (!state._formAccounts.length) { state._formAccounts.push({ id: newClientId('acc'), label: '', username: '', email: '', passwordHint: '', passwordState: { state: 'absent' }, passwordClear: false, passwordValue: '', secrets: [], }); } reRenderFormKeepingSlots(state.mode === 'create'); return; } var addSecBtn = t.closest('[data-vault-add-secret-slot]'); if (addSecBtn) { var formAddSec = qs('[data-vault-form]', detailEl); if (formAddSec) snapshotFormSlots(formAddSec); var accIdForSec = addSecBtn.getAttribute('data-acc-id') || (addSecBtn.closest('[data-vault-account-slot]') && addSecBtn.closest('[data-vault-account-slot]').getAttribute('data-vault-account-slot')); var targetAcc = (state._formAccounts || []).find(function (a) { return a.id === accIdForSec; }); if (!targetAcc && state._formAccounts && state._formAccounts.length) { targetAcc = state._formAccounts[0]; } if (targetAcc) { targetAcc.secrets = targetAcc.secrets || []; targetAcc.secrets.push({ id: newClientId('sec'), kind: 'apikey', label: '', valueState: { state: 'absent' }, valueClear: false, valueText: '', accountId: targetAcc.id, }); } // 添加密钥后展开对应附录密钥面板(写入 state,re-render 后仍展开)。 if (targetAcc) { setSecretsPanelExpanded(accountUiKey(targetAcc.id), true); } else if (accIdForSec) { setSecretsPanelExpanded(accountUiKey(accIdForSec), true); } reRenderFormKeepingSlots(state.mode === 'create'); return; } var removeSec = t.closest('[data-vault-remove-secret]'); if (removeSec) { var rsid = removeSec.getAttribute('data-vault-remove-secret'); var rAccId = removeSec.getAttribute('data-acc-id'); var formRmSec = qs('[data-vault-form]', detailEl); if (formRmSec) snapshotFormSlots(formRmSec); (state._formAccounts || []).forEach(function (acc) { if (rAccId && acc.id !== rAccId) return; acc.secrets = (acc.secrets || []).filter(function (s) { return s.id !== rsid; }); }); reRenderFormKeepingSlots(state.mode === 'create'); return; } var showAccPwd = t.closest('[data-acc-show-password]'); if (showAccPwd) { var said = showAccPwd.getAttribute('data-acc-show-password'); var pwdShowInput = detailEl.querySelector( '[data-acc-field="password"][data-acc-id="' + said + '"]' ); // 已显示则收起:清空输入框(留空=保存时不改),不触发 passwordClear if (showAccPwd.getAttribute('data-shown') === '1') { if (pwdShowInput) { pwdShowInput.value = ''; pwdShowInput.placeholder = '留空不改;输入新值覆盖'; } (state._formAccounts || []).forEach(function (a) { if (a.id === said) { a.passwordValue = ''; a.passwordClear = false; } }); showAccPwd.setAttribute('data-shown', '0'); showAccPwd.textContent = '显示'; return; } if (!state.selectedId) { toast('请先保存条目后再查看已存密码', 'info'); return; } showAccPwd.disabled = true; var revealPwdBody = { field: 'password' }; if (said) revealPwdBody.accountId = said; apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, revealPwdBody ) .then(function (result) { var secret = result && result.secret; showAccPwd.disabled = false; if (!secret || secret.state !== 'revealed') { toast('该账号未设置密码', 'info'); return; } // 编辑优先写回 [Key] 模板,避免把展开明文误存为新值 var editVal = secret.template != null && secret.template !== '' ? String(secret.template) : String(secret.value || ''); if (pwdShowInput) { pwdShowInput.value = editVal; pwdShowInput.focus(); try { var len = editVal.length; pwdShowInput.setSelectionRange(len, len); } catch (_e) { /* ignore */ } } (state._formAccounts || []).forEach(function (a) { if (a.id === said) { a.passwordValue = editVal; a.passwordClear = false; } }); state.dirty = true; showAccPwd.setAttribute('data-shown', '1'); showAccPwd.textContent = '隐藏'; if (secret.missingCipherKeys && secret.missingCipherKeys.length) { toast( '密文簿缺少 key: ' + secret.missingCipherKeys.join(', '), 'info' ); } }) .catch(function (err) { showAccPwd.disabled = false; toast(err.message || String(err), 'error'); }); return; } var clearAccPwd = t.closest('[data-acc-clear-password]'); if (clearAccPwd) { var caid = clearAccPwd.getAttribute('data-acc-clear-password'); (state._formAccounts || []).forEach(function (a) { if (a.id === caid) { a.passwordClear = true; a.passwordValue = ''; a.passwordState = { state: 'absent' }; } }); var pwdInput = detailEl.querySelector( '[data-acc-field="password"][data-acc-id="' + caid + '"]' ); if (pwdInput) { pwdInput.value = ''; pwdInput.placeholder = '(将清空)'; } var showBtnAfterClear = detailEl.querySelector( '[data-acc-show-password="' + caid + '"]' ); if (showBtnAfterClear) { showBtnAfterClear.setAttribute('data-shown', '0'); showBtnAfterClear.textContent = '显示'; } state.dirty = true; toast('该账号密码将在保存时清空', 'info'); return; } var showSecVal = t.closest('[data-sec-show-value]'); if (showSecVal) { var ssid = showSecVal.getAttribute('data-sec-show-value'); var secShowInput = detailEl.querySelector( '[data-sec-field="value"][data-sec-id="' + ssid + '"]' ); if (showSecVal.getAttribute('data-shown') === '1') { if (secShowInput) { secShowInput.value = ''; secShowInput.placeholder = '留空不改;输入新值覆盖'; } (state._formAccounts || []).forEach(function (acc) { (acc.secrets || []).forEach(function (s) { if (s.id === ssid) { s.valueText = ''; s.valueClear = false; } }); }); showSecVal.setAttribute('data-shown', '0'); showSecVal.textContent = '显示'; return; } if (!state.selectedId) { toast('请先保存条目后再查看已存密钥', 'info'); return; } showSecVal.disabled = true; apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, { secretId: ssid } ) .then(function (result) { var secret = result && result.secret; showSecVal.disabled = false; if (!secret || secret.state !== 'revealed') { toast('该密钥未设置', 'info'); return; } var editVal = secret.template != null && secret.template !== '' ? String(secret.template) : String(secret.value || ''); if (secShowInput) { secShowInput.value = editVal; secShowInput.focus(); } (state._formAccounts || []).forEach(function (acc) { (acc.secrets || []).forEach(function (s) { if (s.id === ssid) { s.valueText = editVal; s.valueClear = false; } }); }); state.dirty = true; showSecVal.setAttribute('data-shown', '1'); showSecVal.textContent = '隐藏'; if (secret.missingCipherKeys && secret.missingCipherKeys.length) { toast( '密文簿缺少 key: ' + secret.missingCipherKeys.join(', '), 'info' ); } }) .catch(function (err) { showSecVal.disabled = false; toast(err.message || String(err), 'error'); }); return; } var clearSecVal = t.closest('[data-sec-clear-value]'); if (clearSecVal) { var csid = clearSecVal.getAttribute('data-sec-clear-value'); (state._formAccounts || []).forEach(function (acc) { (acc.secrets || []).forEach(function (s) { if (s.id === csid) { s.valueClear = true; s.valueText = ''; s.valueState = { state: 'absent' }; } }); }); var secInput = detailEl.querySelector( '[data-sec-field="value"][data-sec-id="' + csid + '"]' ); if (secInput) { secInput.value = ''; secInput.placeholder = '(将清空)'; } var showSecAfterClear = detailEl.querySelector( '[data-sec-show-value="' + csid + '"]' ); if (showSecAfterClear) { showSecAfterClear.setAttribute('data-shown', '0'); showSecAfterClear.textContent = '显示'; } state.dirty = true; toast('该密钥将在保存时清空', 'info'); return; } if (t.closest('[data-vault-share-ai]')) { if (!state.selectedId || !state.rootUri) return; if (state.isAiVault) { toast('当前已是 AI 密码本,无需再共享', 'info'); return; } var shareTitle = (state.item && state.item.title) || state.selectedId; var alreadyShared = itemIsSharedToAi(state.item); if (!(await window.mnote.confirm( alreadyShared ? '将「' + shareTitle + '」重新同步到已关联的 AI 密码本副本?\n' + '会更新目标条目内容与缺失密文 key(目标已有 key 不覆盖)。' : '将「' + shareTitle + '」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' + '会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' + '密文 [Key] 模板原样复制,并自动同步缺失密文 key。' )) ) { return; } apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/share-to-ai', state.rootUri, { syncCipherKeys: true } ) .then(function (result) { var target = (result && result.targetActorId) || 'mnote-e2e'; var newId = result && result.item && result.item.id; var cipher = (result && result.cipherSync) || {}; var copied = cipher.copiedKeys || cipher.copied || []; var missing = cipher.missingInSource || []; var parts = [ result && result.updatedExisting ? '已同步 AI 副本(' + target + ')' : '已共享到 AI 密码本(' + target + ')', ]; if (newId) parts.push(newId); if (copied.length) parts.push('同步密文 key: ' + copied.join(', ')); if (missing.length) { parts.push('源缺 key: ' + missing.join(', ')); } toast(parts.join(' · '), missing.length ? 'info' : 'success'); setStatus(parts.join(' · '), missing.length ? 'info' : 'success'); // Refresh source item so shared banner / button label update return loadItem(state.selectedId).then(function () { return loadList(); }); }) .catch(async function (err) { toast(err.message || String(err), 'error'); setStatus(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-unshare-ai]')) { if (!state.selectedId || !state.rootUri) return; if (state.isAiVault) { toast('请在用户密码箱中撤销共享', 'info'); return; } if (!itemIsSharedToAi(state.item)) { toast('当前条目未共享到 AI', 'info'); return; } var unshareTitle = (state.item && state.item.title) || state.selectedId; var linkActor = (state.item && state.item.sharedToAi && state.item.sharedToAi.targetActorId) || 'mnote-e2e'; if (!(await window.mnote.confirm( '撤销「' + unshareTitle + '」对 AI 密码本(' + linkActor + ')的共享?\n' + '将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' + '原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。' )) ) { return; } apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/unshare-from-ai', state.rootUri, {} ) .then(function (result) { var target = (result && result.targetActorId) || linkActor; var parts = []; if (result && result.wasShared === false) { parts.push('本来就未共享'); } else { parts.push('已撤销共享(' + target + ')'); if (result && result.targetDeleted) { parts.push('AI 副本已移入回收站'); } else if (result && result.removedTargetId) { parts.push('AI 副本已不存在或已在回收站'); } } toast(parts.join(' · '), 'success'); setStatus(parts.join(' · '), 'success'); return loadItem(state.selectedId).then(function () { return loadList(); }); }) .catch(function (err) { toast(err.message || String(err), 'error'); setStatus(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-repair-ai-folders]')) { repairAiFolders(); return; } if (t.closest('[data-vault-cipher-book]')) { if (!confirmDiscard()) return; state.mode = 'cipher'; state.dirty = false; loadCipherBook(); return; } if (t.closest('[data-vault-cipher-close]')) { state.mode = state.selectedId ? 'view' : 'view'; if (state.selectedId) loadItem(state.selectedId); else { state.item = null; renderDetail(); } return; } var cipherReveal = t.closest('[data-vault-cipher-reveal]'); if (cipherReveal) { var ck = cipherReveal.getAttribute('data-vault-cipher-reveal'); if (state.cipherRevealed[ck]) { delete state.cipherRevealed[ck]; renderCipherBook(); return; } apiJson( 'POST', '/api/vault/cipher-book/' + encodeURIComponent(ck) + '/reveal', state.rootUri, {} ) .then(function (result) { var secret = result && result.secret; if (secret && secret.state === 'revealed') { state.cipherRevealed[ck] = secret.value; renderCipherBook(); } }) .catch(function (err) { toast(err.message || String(err), 'error'); }); return; } var cipherEdit = t.closest('[data-vault-cipher-edit]'); if (cipherEdit) { var ek = cipherEdit.getAttribute('data-vault-cipher-edit'); var nv = window.prompt('更新 [' + ek + '] 的片段值(输入明文)', ''); if (nv == null || nv === '') return; apiJson( 'PUT', '/api/vault/cipher-book/' + encodeURIComponent(ek), state.rootUri, { value: nv } ) .then(function () { toast('已更新 [' + ek + ']', 'success'); delete state.cipherRevealed[ek]; return loadCipherBook(); }) .catch(async function (err) { toast(err.message || String(err), 'error'); }); return; } var cipherDel = t.closest('[data-vault-cipher-delete]'); if (cipherDel) { var dk = cipherDel.getAttribute('data-vault-cipher-delete'); if (!(await window.mnote.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。'))) return; apiJson( 'DELETE', '/api/vault/cipher-book/' + encodeURIComponent(dk), state.rootUri ) .then(function () { toast('已删除 [' + dk + ']', 'success'); delete state.cipherRevealed[dk]; return loadCipherBook(); }) .catch(function (err) { toast(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-refresh]')) { if (!confirmDiscard()) return; state.mode = 'view'; state.dirty = false; loadList(); return; } if (t.closest('[data-vault-tab="active"]')) { if (!confirmDiscard()) return; state.status = 'active'; state.mode = 'view'; state.selectedId = ''; state.item = null; if (tabActive) tabActive.classList.add('is-active'); if (tabDeleted) tabDeleted.classList.remove('is-active'); root.setAttribute('data-status', 'active'); loadList(); return; } if (t.closest('[data-vault-tab="deleted"]')) { if (!confirmDiscard()) return; state.status = 'deleted'; state.mode = 'view'; state.selectedId = ''; state.item = null; if (tabDeleted) tabDeleted.classList.add('is-active'); if (tabActive) tabActive.classList.remove('is-active'); root.setAttribute('data-status', 'deleted'); loadList(); return; } if (t.closest('[data-vault-edit]')) { state.mode = 'edit'; state.dirty = false; state.secretDraft = { password: null, apikey: null, token: null }; renderDetail(); return; } if (t.closest('[data-vault-cancel]')) { if (!confirmDiscard()) return; state.mode = state.selectedId ? 'view' : 'view'; state.dirty = false; if (state.selectedId) loadItem(state.selectedId); else { state.item = null; renderDetail(); } return; } var clearSecVal = t.closest('[data-vault-clear-sec-value]'); if (clearSecVal) { var csid = clearSecVal.getAttribute('data-vault-clear-sec-value'); var csAcc = clearSecVal.getAttribute('data-acc-id'); var formCs = qs('[data-vault-form]', detailEl); if (formCs) snapshotFormSlots(formCs); (state._formAccounts || []).forEach(function (acc) { if (csAcc && acc.id !== csAcc) return; (acc.secrets || []).forEach(function (sec) { if (sec.id !== csid) return; sec.valueClear = true; sec.valueState = { state: 'absent' }; sec.valueText = ''; }); }); state.dirty = true; reRenderFormKeepingSlots(state.mode === 'create'); toast('密钥将在保存时清空', 'info'); return; } var clearBtn = t.closest('[data-vault-clear-secret]'); if (clearBtn) { var cf = clearBtn.getAttribute('data-vault-clear-secret'); state.secretDraft[cf] = 'clear'; state.dirty = true; var input = detailEl.querySelector('[data-secret-field="' + cf + '"]'); if (input) { input.value = ''; input.placeholder = '(将清空)'; } toast(cf + ' 将在保存时清空', 'info'); return; } if (t.closest('[data-vault-delete]')) { if (!(await window.mnote.confirm('确定将条目移入密码箱回收站?'))) return; apiJson( 'DELETE', '/api/vault/items/' + encodeURIComponent(state.selectedId), state.rootUri ) .then(function () { toast('已移入回收站', 'success'); state.selectedId = ''; state.item = null; return loadList(); }) .catch(function (err) { toast(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-restore]')) { apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/restore', state.rootUri, {} ) .then(function () { toast('已恢复', 'success'); state.status = 'active'; if (tabActive) tabActive.classList.add('is-active'); if (tabDeleted) tabDeleted.classList.remove('is-active'); return loadList(); }) .catch(async function (err) { toast(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-purge]')) { if (!(await window.mnote.confirm('永久删除后无法恢复,确定?'))) return; apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge', state.rootUri, {} ) .then(function () { toast('已永久删除', 'success'); state.selectedId = ''; state.item = null; return loadList(); }) .catch(function (err) { toast(err.message || String(err), 'error'); }); return; } var revealBtn = t.closest('[data-vault-reveal]'); if (revealBtn) { var field = revealBtn.getAttribute('data-vault-reveal'); var accountId = revealBtn.getAttribute('data-vault-account-id') || ''; var secretId = revealBtn.getAttribute('data-vault-secret-id') || ''; var rkey = revealKey(field, accountId, secretId); if (state.revealed[rkey]) { state.revealed[rkey] = false; renderDetail(); return; } var revealBody = { field: field }; if (accountId) revealBody.accountId = accountId; if (secretId) { revealBody.secretId = secretId; if (!field || field === 'value') { // appendix secret: field optional on server when secretId present delete revealBody.field; } } apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, revealBody ) .then(function (result) { var secret = result && result.secret; state._revealedValues = state._revealedValues || {}; if (secret && secret.state === 'revealed') { state._revealedValues[rkey] = secret.value || ''; state.revealed[rkey] = true; if (secret.missingCipherKeys && secret.missingCipherKeys.length) { toast( '密文簿缺少 key: ' + secret.missingCipherKeys.join(', '), 'info' ); } } else { toast('该字段未设置', 'info'); } renderDetail(); }) .catch(function (err) { toast(err.message || String(err), 'error'); }); return; } var copyBtn = t.closest('[data-vault-copy-secret]'); if (copyBtn) { var cfield = copyBtn.getAttribute('data-vault-copy-secret'); var cAccountId = copyBtn.getAttribute('data-vault-account-id') || ''; var cSecretId = copyBtn.getAttribute('data-vault-secret-id') || ''; var ckey = revealKey(cfield, cAccountId, cSecretId); var doCopy = function (text) { if (!text) { toast('无可复制内容', 'info'); return; } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then( function () { toast('已复制', 'success'); }, function () { toast('复制失败', 'error'); } ); } else { toast('剪贴板不可用', 'error'); } }; if (state.revealed[ckey] && state._revealedValues && state._revealedValues[ckey]) { doCopy(state._revealedValues[ckey]); } else { var copyBody = { field: cfield }; if (cAccountId) copyBody.accountId = cAccountId; if (cSecretId) { copyBody.secretId = cSecretId; if (!cfield || cfield === 'value') delete copyBody.field; } apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, copyBody ) .then(function (result) { var secret = result && result.secret; if (secret && secret.state === 'revealed') { doCopy(secret.value || ''); } else { toast('该字段未设置', 'info'); } }) .catch(function (err) { toast(err.message || String(err), 'error'); }); } return; } var copyPlain = t.closest('[data-vault-copy]'); if (copyPlain) { var text = copyPlain.getAttribute('data-vault-copy') || ''; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast('已复制', 'success'); }); } } }); if (searchEl) { searchEl.addEventListener('input', function () { renderList(); }); } // 顶栏「插入密文」:固定在密文簿旁,仅编辑态显示 bindHeaderCipherInsertControls(); syncHeaderCipherInsertVisibility(); // deep-link id try { var params = new URLSearchParams(window.location.search); var deepId = params.get('id'); if (deepId) state.selectedId = deepId; var st = params.get('status'); if (st === 'deleted') { state.status = 'deleted'; if (tabDeleted) tabDeleted.classList.add('is-active'); if (tabActive) tabActive.classList.remove('is-active'); } } catch (_e) {} // ensure then list if (state.rootUri) { apiJson('POST', '/api/vault/ensure', state.rootUri, {}) .then(function () { return loadList(); }) .catch(function (err) { setStatus(err.message || String(err), 'error'); loadList(); }); } else { renderList(); renderDetail(); setStatus('请先打开本地文件夹', 'error'); } } /** Keep left-rail /vault links in the current local_folder context. */ function enrichVaultNavLinks() { var rootUri = (document.body && document.body.getAttribute('data-mnote-root-uri')) || new URLSearchParams(window.location.search).get('rootUri') || ''; rootUri = String(rootUri || '').trim(); var sourceKind = (document.body && document.body.getAttribute('data-mnote-source-kind')) || new URLSearchParams(window.location.search).get('sourceKind') || (rootUri ? 'local_folder' : ''); sourceKind = String(sourceKind || '').trim(); if (!rootUri && !sourceKind) return; qsa('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]').forEach( function (link) { if (!(link instanceof HTMLAnchorElement)) return; try { var url = new URL(link.getAttribute('href') || '/vault', window.location.origin); if (url.pathname !== '/vault') return; if (rootUri && !url.searchParams.get('rootUri')) { url.searchParams.set('rootUri', rootUri); } if (sourceKind && !url.searchParams.get('sourceKind')) { url.searchParams.set('sourceKind', sourceKind); } link.setAttribute('href', url.pathname + url.search + url.hash); } catch (_e) {} } ); } function boot() { enrichVaultNavLinks(); qsa('[data-testid="mnote-vault-workbench"]').forEach(bindWorkbench); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } // Capture-phase enrich only. Soft-open is owned by sidebar-tree-runtime // (keeps PageTree/FileTree DOM). Full assign only when soft-open is unavailable. if (!window.__mnoteVaultNavCapture) { window.__mnoteVaultNavCapture = true; document.addEventListener( 'click', function (ev) { var t = ev.target; if (!(t instanceof Element)) return; if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; var link = t.closest( 'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]' ); if (!(link instanceof HTMLAnchorElement)) return; try { var url = new URL(link.getAttribute('href') || '/vault', window.location.origin); if (url.pathname !== '/vault') return; var rootUri = (document.body && document.body.getAttribute('data-mnote-root-uri')) || new URLSearchParams(window.location.search).get('rootUri') || ''; rootUri = String(rootUri || '').trim(); if (rootUri && !url.searchParams.get('rootUri')) { url.searchParams.set('rootUri', rootUri); if (!url.searchParams.get('sourceKind')) { url.searchParams.set('sourceKind', 'local_folder'); } link.setAttribute('href', url.pathname + url.search + url.hash); } // Prefer soft-open so sidebar tree expansion is preserved. if (typeof window.__mnoteOpenVaultWorkbench === 'function') { ev.preventDefault(); void window.__mnoteOpenVaultWorkbench(url); } } catch (_e) {} }, true ); } window.mnote = window.mnote || {}; window.mnote.vaultWorkbench = { boot: boot, enrichVaultNavLinks: enrichVaultNavLinks }; })();