Files
mnote/rust/crates/mnote-web/browser/vault-workbench-runtime.js
T
Agent Board b798f628ee chore: land tree view-state, vault, Pi module split, and repo hygiene
Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
2026-07-21 05:13:05 +08:00

1734 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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<string, boolean>} 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 +=
'<span class="mnote-vault-list-icon is-shared" title="已共享到 AI 密码本" aria-label="已共享">↗</span>';
}
if (itemIsAiCopy(item)) {
icons +=
'<span class="mnote-vault-list-icon is-ai" title="AI 密码本副本" aria-label="AI 副本">◆</span>';
}
return (
'<button type="button" class="mnote-vault-list-item' +
active +
(itemIsSharedToAi(item) ? ' is-shared-ai' : '') +
(itemIsAiCopy(item) ? ' is-ai-copy' : '') +
'" data-vault-item-id="' +
escapeHtml(item.id) +
'" data-testid="vault-list-item">' +
'<span class="mnote-vault-list-main">' +
'<span class="mnote-vault-list-title">' +
escapeHtml(item.title || '(无标题)') +
'</span>' +
(meta
? '<span class="mnote-vault-list-meta">' + escapeHtml(meta) + '</span>'
: '') +
'</span>' +
(icons ? '<span class="mnote-vault-list-icons">' + icons + '</span>' : '') +
'</button>'
);
}
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 +=
'<div class="mnote-vault-folder mnote-vault-site-group" style="--vault-depth:' +
depth +
'" data-vault-folder-path="' +
escapeHtml(siteKey) +
'">' +
'<button type="button" class="mnote-vault-folder-toggle mnote-vault-site-toggle' +
(collapsed ? ' is-collapsed' : '') +
'" data-vault-toggle-folder="' +
escapeHtml(siteKey) +
'" aria-expanded="' +
(collapsed ? 'false' : 'true') +
'" title="' +
escapeHtml(url) +
'">' +
'<span class="mnote-vault-folder-chevron" aria-hidden="true">' +
(collapsed ? '▸' : '▾') +
'</span>' +
'<span class="mnote-vault-folder-name">🔗 ' +
escapeHtml(label) +
'</span>' +
'<span class="mnote-vault-folder-count">' +
group.length +
' 账号</span>' +
'</button>';
if (!collapsed) {
html += '<div class="mnote-vault-folder-body">';
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 +=
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
(depth + 1) +
'">' +
renderItemButton(item) +
'</div>';
});
html += '</div>';
}
html += '</div>';
});
noUrl
.slice()
.sort(function (a, b) {
return String(a.title || '').localeCompare(String(b.title || ''), 'zh');
})
.forEach(function (item) {
html +=
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
depth +
'">' +
renderItemButton(item) +
'</div>';
});
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 +=
'<div class="mnote-vault-list-item-wrap" style="--vault-depth:' +
depth +
'">' +
renderItemButton(child.items[0]) +
'</div>';
return;
}
var collapsed = !!state.collapsedFolders[child.path];
var count = countNodeItems(child);
html +=
'<div class="mnote-vault-folder" style="--vault-depth:' +
depth +
'" data-vault-folder-path="' +
escapeHtml(child.path) +
'">' +
'<button type="button" class="mnote-vault-folder-toggle' +
(collapsed ? ' is-collapsed' : '') +
'" data-vault-toggle-folder="' +
escapeHtml(child.path) +
'" aria-expanded="' +
(collapsed ? 'false' : 'true') +
'">' +
'<span class="mnote-vault-folder-chevron" aria-hidden="true">' +
(collapsed ? '▸' : '▾') +
'</span>' +
'<span class="mnote-vault-folder-name">' +
escapeHtml(child.name) +
'</span>' +
'<span class="mnote-vault-folder-count">' +
count +
'</span>' +
'</button>';
if (!collapsed) {
html += '<div class="mnote-vault-folder-body">';
html += renderTreeNode(child, depth + 1);
html += renderItemsWithUrlGroups(child.items, depth + 1, child.path);
html += '</div>';
}
html += '</div>';
});
return html;
}
function renderList() {
if (!listEl) return;
var items = filteredItems();
if (!items.length) {
listEl.innerHTML =
'<div class="mnote-vault-empty" data-testid="vault-list-empty">暂无条目。点击「新建」添加。</div>';
return;
}
var tree = buildFolderTree(items);
var html = '';
// Root (ungrouped) items first — also group by same URL
if (tree.items.length) {
html +=
'<div class="mnote-vault-folder mnote-vault-folder--root" data-vault-folder-path="">' +
'<div class="mnote-vault-folder-label">未分组</div>' +
'<div class="mnote-vault-folder-body">' +
renderItemsWithUrlGroups(tree.items, 0, '') +
'</div></div>';
}
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 =
'<div class="mnote-vault-empty">请先打开本地文件夹工作区,再使用密码箱。</div>';
return;
}
if (state.mode === 'cipher') {
renderCipherBook();
return;
}
if (state.mode === 'create') {
renderForm(emptyForm(state._createDefaults || {}), true);
return;
}
if (!state.item && !state.selectedId) {
detailEl.innerHTML =
'<div class="mnote-vault-empty" data-testid="vault-detail-empty">从左侧选择条目,或点击「新建」。</div>';
return;
}
if (!state.item) {
detailEl.innerHTML = '<div class="mnote-vault-empty">加载中…</div>';
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 (
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
field +
'">' +
'<label>' +
escapeHtml(label) +
'</label>' +
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
field +
'">' +
shown +
'</div>' +
'<div class="mnote-vault-secret-actions">' +
(canReveal
? '<button type="button" data-vault-reveal="' +
field +
'">' +
(state.revealed[field] ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-vault-copy-secret="' +
field +
'">复制</button>'
: '<span class="mnote-vault-muted">未设置</span>') +
'</div></div>'
);
}
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 '<span class="mnote-vault-tag">' + escapeHtml(t) + '</span>';
})
.join(' ');
var isDeleted = item.status === 'deleted' || state.status === 'deleted';
var siblings = sameUrlAccounts(item);
var siblingHtml = '';
if (siblings.length) {
siblingHtml =
'<div class="mnote-vault-field-row mnote-vault-siblings"><label>同站其他账号</label><div class="mnote-vault-sibling-list">' +
siblings
.map(function (s) {
return (
'<button type="button" class="mnote-vault-sibling" data-vault-item-id="' +
escapeHtml(s.id) +
'">' +
escapeHtml(s.title || s.username || s.id) +
(s.username ? ' · ' + escapeHtml(s.username) : '') +
'</button>'
);
})
.join('') +
'</div></div>';
}
var shared = itemIsSharedToAi(item);
var aiCopy = itemIsAiCopy(item) || state.isAiVault;
var link = item.sharedToAi || {};
var titleChips = '';
if (shared && !state.isAiVault) {
titleChips +=
'<span class="mnote-vault-status-chip is-shared" data-testid="vault-shared-banner" title="编辑保存会自动同步到 AI 副本' +
(link.targetActorId ? ' · ' + escapeHtml(link.targetActorId) : '') +
(link.lastSyncedAt
? ' · ' + escapeHtml(String(link.lastSyncedAt).slice(0, 16).replace('T', ' '))
: '') +
'">已共享</span>';
}
if (aiCopy && (item.sharedFrom || state.isAiVault)) {
titleChips +=
'<span class="mnote-vault-status-chip is-ai" data-testid="vault-ai-copy-banner" title="' +
(item.sharedFrom
? '来自 ' + escapeHtml(item.sharedFrom.sourceActorId || '')
: 'AI 密码本') +
'">AI</span>';
}
var shareBtn = '';
if (!isDeleted && !state.isAiVault && !aiCopy) {
shareBtn = shared
? '<button type="button" data-vault-share-ai data-testid="vault-share-ai" title="同步到 AI 副本">同步 AI</button>' +
'<button type="button" class="is-danger" data-vault-unshare-ai data-testid="vault-unshare-ai" title="撤销共享并删除 AI 密码本副本">撤销共享</button>'
: '<button type="button" data-vault-share-ai data-testid="vault-share-ai" title="共享到 AI 密码本">共享 AI</button>';
}
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
? '<div class="mnote-vault-field-row"><label>标签</label><div>' +
(tags || '<span class="mnote-vault-muted">无</span>') +
'</div></div>'
: '') +
siblingHtml +
(!isNotesEmpty(item.notesMarkdown) || showEmpty
? '<div class="mnote-vault-field-row mnote-vault-notes"><label>备注</label><pre>' +
escapeHtml(item.notesMarkdown || '') +
'</pre></div>'
: '') +
'<p class="mnote-vault-hint-mini">' +
'<button type="button" class="mnote-vault-linkish" data-vault-toggle-empty-fields data-testid="vault-toggle-empty-fields">' +
(showEmpty ? '隐藏空字段' : '显示空字段') +
'</button></p>';
detailEl.innerHTML =
'<div class="mnote-vault-detail-view" data-testid="vault-detail-view">' +
'<div class="mnote-vault-detail-header">' +
'<h2 class="mnote-vault-detail-title">' +
'<span class="mnote-vault-detail-title-text">' +
escapeHtml(item.title || '') +
'</span>' +
titleChips +
'</h2>' +
'<div class="mnote-vault-detail-actions">' +
(isDeleted
? '<button type="button" data-vault-restore>恢复</button>' +
'<button type="button" class="is-danger" data-vault-purge>永久删除</button>'
: '<button type="button" data-vault-add-account data-testid="vault-add-account">同站新账号</button>' +
shareBtn +
'<button type="button" data-vault-edit>编辑</button>' +
'<button type="button" class="is-danger" data-vault-delete>删除</button>') +
'</div></div>' +
'<div class="mnote-vault-fields">' +
fieldsHtml +
'</div></div>';
}
function fieldRow(label, value, showEmpty) {
if (!value && !showEmpty) return '';
return (
'<div class="mnote-vault-field-row"><label>' +
escapeHtml(label) +
'</label><div>' +
(value
? escapeHtml(value)
: '<span class="mnote-vault-muted">—</span>') +
'</div></div>'
);
}
/** 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 =
'<option value=""' +
(!current ? ' selected' : '') +
'>(无分组)</option>';
paths.forEach(function (p) {
options +=
'<option value="' +
escapeHtml(p) +
'"' +
(p === current ? ' selected' : '') +
'>' +
escapeHtml(p) +
'</option>';
});
if (current && !seen[current]) {
options +=
'<option value="' +
escapeHtml(current) +
'" selected>' +
escapeHtml(current) +
'</option>';
}
return (
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
'<label for="vault-f-folderPath">分组</label>' +
'<div class="mnote-vault-folder-controls">' +
'<select id="vault-f-folderSelect" data-vault-folder-select data-testid="vault-folder-select" aria-label="选择分组">' +
options +
'</select>' +
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
'value="' +
escapeHtml(current) +
'" placeholder="新分组可直接输入,用 / 分层" ' +
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" />' +
'<datalist id="vault-folder-path-list">' +
paths
.map(function (p) {
return '<option value="' + escapeHtml(p) + '"></option>';
})
.join('') +
'</datalist>' +
'</div></div>'
);
}
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 =
'<form class="mnote-vault-form" data-testid="vault-form" data-vault-form="true">' +
'<div class="mnote-vault-detail-header">' +
'<h2>' +
(isCreate ? '新建条目' : '编辑条目') +
'</h2>' +
'<div class="mnote-vault-detail-actions">' +
'<button type="button" data-vault-cancel>取消</button>' +
'<button type="submit" data-vault-save data-testid="vault-save">保存</button>' +
'</div></div>' +
'<div class="mnote-vault-fields">' +
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) +
'<div class="mnote-vault-field-row mnote-vault-notes"><label>备注</label>' +
'<textarea name="notesMarkdown" rows="6">' +
escapeHtml(item.notesMarkdown || '') +
'</textarea></div>' +
'</div></form>';
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 (
'<div class="mnote-vault-field-row"><label for="vault-f-' +
name +
'">' +
escapeHtml(label) +
'</label><input id="vault-f-' +
name +
'" name="' +
name +
'" type="text" value="' +
escapeHtml(value) +
'"' +
(required ? ' required' : '') +
' /></div>'
);
}
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 (
'<div class="mnote-vault-field-row"><label for="vault-f-' +
name +
'">' +
escapeHtml(label) +
'</label>' +
'<div class="mnote-vault-secret-edit">' +
'<input id="vault-f-' +
name +
'" name="' +
name +
'" type="text" autocomplete="off" spellcheck="false" placeholder="' +
escapeHtml(placeholder) +
'" data-secret-field="' +
name +
'" class="mnote-vault-secret-input-plain" />' +
(!isCreate
? '<button type="button" data-vault-clear-secret="' + name + '">清空</button>'
: '') +
'</div></div>'
);
}
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 (
'<div class="mnote-vault-cipher-row" data-cipher-key="' +
escapeHtml(key) +
'">' +
'<code class="mnote-vault-cipher-key">[' +
escapeHtml(key) +
']</code>' +
'<span class="mnote-vault-cipher-value">' +
(revealed
? escapeHtml(revealed)
: k.hasValue
? '••••••••'
: '(空)') +
'</span>' +
'<span class="mnote-vault-cipher-actions">' +
'<button type="button" data-vault-cipher-reveal="' +
escapeHtml(key) +
'">' +
(revealed ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-vault-cipher-edit="' +
escapeHtml(key) +
'">改</button>' +
'<button type="button" class="is-danger" data-vault-cipher-delete="' +
escapeHtml(key) +
'">删</button>' +
'</span></div>'
);
})
.join('');
detailEl.innerHTML =
'<div class="mnote-vault-cipher-panel" data-testid="vault-cipher-panel">' +
'<div class="mnote-vault-detail-header">' +
'<h2>密文簿</h2>' +
'<div class="mnote-vault-detail-actions">' +
'<button type="button" data-vault-cipher-close>关闭</button>' +
'</div></div>' +
'<form class="mnote-vault-cipher-add" data-vault-cipher-form="true">' +
'<input name="key" placeholder="Key(如 A" maxlength="32" required pattern="[A-Za-z0-9_]+" />' +
'<input name="value" type="password" placeholder="片段明文" required autocomplete="off" />' +
'<button type="submit">添加 / 更新</button>' +
'</form>' +
'<div class="mnote-vault-cipher-list" data-testid="vault-cipher-list">' +
(rows ||
'<div class="mnote-vault-empty">尚无片段。例如 key=<code>A</code> 值为 <code>Li@</code>,密码可写 <code>Li@[A]s3cret</code>。</div>') +
'</div></div>';
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 };
})();