// 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 maskLabel(state) { if (!state || state.state === 'absent') return '(无)'; if (state.state === 'masked') return '••••••••'; if (state.state === 'revealed') return state.value || ''; return '••••••••'; } function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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: {}, isAiVault: false, aiVaultActorId: 'mnote-e2e', vaultRole: 'user', showEmptyFields: false, }; 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); try { var savedCollapse = sessionStorage.getItem('mnote.vault.collapsedFolders'); if (savedCollapse) state.collapsedFolders = JSON.parse(savedCollapse) || {}; } catch (_e) { state.collapsedFolders = {}; } function persistCollapse() { try { sessionStorage.setItem( 'mnote.vault.collapsedFolders', JSON.stringify(state.collapsedFolders) ); } catch (_e) { /* ignore */ } } /** 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'); } } 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'); } function confirmDiscard() { if (!state.dirty) return true; return window.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.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 meta = [item.username || item.email || ''] .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 = !!state.collapsedFolders[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 = !!state.collapsedFolders[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; } function emptyForm(defaults) { defaults = defaults || {}; return { id: '', title: defaults.title || '', url: defaults.url || '', username: defaults.username || '', email: defaults.email || '', passwordHint: defaults.passwordHint || '', folderPath: defaults.folderPath || '', password: { state: 'absent' }, apikey: { state: 'absent' }, token: { state: 'absent' }, tags: defaults.tags || [], notesMarkdown: defaults.notesMarkdown || '## Notes\n\n', revision: 0, status: 'active', }; } function renderDetail() { if (!detailEl) return; if (!state.rootUri) { detailEl.innerHTML = '
请先打开本地文件夹工作区,再使用密码箱。
'; return; } if (state.mode === 'cipher') { renderCipherBook(); return; } if (state.mode === 'create') { renderForm(emptyForm(state._createDefaults || {}), true); return; } if (!state.item && !state.selectedId) { detailEl.innerHTML = '
从左侧选择条目,或点击「新建」。
'; return; } if (!state.item) { detailEl.innerHTML = '
加载中…
'; return; } if (state.mode === 'edit') { renderForm(state.item, false); return; } renderView(state.item); } function secretRow(label, field, secretState, showEmpty) { var has = secretState && secretState.state === 'masked'; var canReveal = has || (secretState && secretState.state === 'revealed'); if (!canReveal && !showEmpty) return ''; var shown = state.revealed[field] ? state._revealedValues && state._revealedValues[field] ? escapeHtml(state._revealedValues[field]) : '…' : maskLabel(secretState); 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 fieldsHtml = fieldRow('分组', item.folderPath, showEmpty) + fieldRow('网址', item.url, showEmpty) + fieldRow('用户名', item.username, showEmpty) + fieldRow('邮箱', item.email, showEmpty) + secretRow('密码', 'password', item.password, showEmpty) + fieldRow('提示', item.passwordHint, showEmpty) + secretRow('API Key', 'apikey', item.apikey, showEmpty) + secretRow('Token', 'token', item.token, showEmpty) + (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 select / 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'); }); } function formFolderPathField(value) { var current = (value || '').trim(); var paths = collectFolderPaths(); var seen = {}; paths.forEach(function (p) { seen[p] = true; }); var options = ''; paths.forEach(function (p) { options += ''; }); if (current && !seen[current]) { options += ''; } return ( '
' + '' + '
' + '' + '' + '' + paths .map(function (p) { return ''; }) .join('') + '' + '
' ); } function bindFolderPathControls(form) { if (!form) return; var select = qs('[data-vault-folder-select]', form); var input = qs('[data-vault-folder-input]', form); if (select && input) { select.addEventListener('change', function () { input.value = select.value || ''; state.dirty = true; }); } if (input) { input.addEventListener('input', function () { var n = normalizeFolderPath(input.value); // keep select in sync when user picks a known path via typing if (select) { var match = false; for (var i = 0; i < select.options.length; i++) { if (select.options[i].value === n) { select.selectedIndex = i; match = true; break; } } if (!match) select.selectedIndex = 0; } }); input.addEventListener('change', function () { var n = normalizeFolderPath(input.value); input.value = n; state.dirty = true; }); } } function renderForm(item, isCreate) { var tagsStr = (item.tags || []).join(', '); detailEl.innerHTML = '
' + '
' + '

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

' + '
' + '' + '' + '
' + '
' + formField('title', '标题 *', item.title || '', true) + formFolderPathField(item.folderPath || '') + formField('url', '网址', item.url || '', false) + formField('username', '用户名', item.username || '', false) + formField('email', '邮箱', item.email || '', false) + formSecretField('password', '密码(可用 [Key] 密文片段)', item.password, isCreate) + formField('passwordHint', '密码提示', item.passwordHint || '', false) + formSecretField('apikey', 'API Key', item.apikey, isCreate) + formSecretField('token', 'Token', item.token, isCreate) + formField('tags', '标签(逗号分隔)', tagsStr, false) + '
' + '
' + '
'; var form = qs('[data-vault-form]', detailEl); if (form) { bindFolderPathControls(form); form.addEventListener('input', function () { state.dirty = true; }); form.addEventListener('submit', function (ev) { ev.preventDefault(); saveForm(form, isCreate); }); } } 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(); } setStatus( (state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条', 'success' ); } 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 }; renderList(); renderDetail(); } catch (err) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } async function saveForm(form, isCreate) { 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 body = { title: title, url: String(fd.get('url') || '').trim() || null, username: String(fd.get('username') || '').trim() || null, email: String(fd.get('email') || '').trim() || null, passwordHint: String(fd.get('passwordHint') || '').trim() || null, folderPath: folderPath || null, tags: tags, notesMarkdown: String(fd.get('notesMarkdown') || ''), syncAiShare: true, }; ['password', 'apikey', 'token'].forEach(function (field) { var input = form.querySelector('[data-secret-field="' + field + '"]'); var cleared = state.secretDraft[field] === 'clear'; var raw = input ? String(input.value || '') : ''; if (isCreate) { if (raw) body[field] = raw; } else if (cleared) { body[field] = { clear: true }; } else if (raw) { // reject mask sentinels client-side if (/^[•*·]+$/.test(raw) || raw === '••••••••') { toast('不能将掩码写回 ' + field, 'error'); throw new Error('mask'); } body[field] = { set: raw }; } }); 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'); } } async function loadCipherBook() { try { var result = await apiJson('GET', '/api/vault/cipher-book', state.rootUri); state.cipherKeys = (result && result.keys) || []; state.cipherRevealed = {}; renderCipherBook(); setStatus('密文簿 ' + state.cipherKeys.length + ' 个 key', 'info'); } catch (err) { setStatus(err.message || String(err), 'error'); toast(err.message || String(err), 'error'); } } 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', function (ev) { var t = ev.target; if (!(t instanceof Element)) return; var toggle = t.closest('[data-vault-toggle-folder]'); if (toggle && root.contains(toggle)) { var fpath = toggle.getAttribute('data-vault-toggle-folder') || ''; state.collapsedFolders[fpath] = !state.collapsedFolders[fpath]; persistCollapse(); 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; } if (t.closest('[data-vault-add-account]')) { if (!confirmDiscard()) return; var cur = state.item || {}; state.mode = 'create'; state.selectedId = ''; state.item = null; state._createDefaults = { title: (cur.title || '') + ' (账号)', url: cur.url || '', folderPath: cur.folderPath || '', tags: (cur.tags || []).slice(), email: '', username: '', }; state.dirty = false; state.secretDraft = { password: null, apikey: null, token: null }; renderList(); renderDetail(); 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 ( !window.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(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 ( !window.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-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(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 (!window.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 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 (!window.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(function (err) { toast(err.message || String(err), 'error'); }); return; } if (t.closest('[data-vault-purge]')) { if (!window.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'); if (state.revealed[field]) { state.revealed[field] = false; renderDetail(); return; } apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, { field: field } ) .then(function (result) { var secret = result && result.secret; state._revealedValues = state._revealedValues || {}; if (secret && secret.state === 'revealed') { state._revealedValues[field] = secret.value || ''; state.revealed[field] = 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 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[cfield] && state._revealedValues && state._revealedValues[cfield]) { doCopy(state._revealedValues[cfield]); } else { apiJson( 'POST', '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', state.rootUri, { field: cfield } ) .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(); }); } // 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 }; })();