Files
mnote/rust/crates/mnote-web/browser/vault-workbench-runtime.js
T

1734 lines
60 KiB
JavaScript
Raw Normal View History

// 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 };
})();