Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token read path design, vault transport split, and retire obsolete filetree smokes. Ignore local vault reimport scripts that trip secret scanners.
3554 lines
128 KiB
JavaScript
3554 lines
128 KiB
JavaScript
// Password vault workbench runtime — dedicated /vault CRUD UI.
|
||
// Refresh model: command-result re-fetch only; no setInterval.
|
||
(function initVaultWorkbenchRuntime() {
|
||
if (window.__mnoteVaultWorkbenchStarted) return;
|
||
window.__mnoteVaultWorkbenchStarted = true;
|
||
|
||
function qs(sel, root) {
|
||
return (root || document).querySelector(sel);
|
||
}
|
||
function qsa(sel, root) {
|
||
return Array.prototype.slice.call((root || document).querySelectorAll(sel));
|
||
}
|
||
|
||
function toast(message, kind) {
|
||
if (window.mnote && typeof window.mnote.toast === 'function') {
|
||
window.mnote.toast(message, { kind: kind || 'info' });
|
||
return;
|
||
}
|
||
console.log('[vault]', kind || 'info', message);
|
||
}
|
||
|
||
function readRootUri(root) {
|
||
var fromData = root.getAttribute('data-root-uri') || '';
|
||
if (fromData) return fromData;
|
||
var body = document.body && document.body.getAttribute('data-mnote-root-uri');
|
||
return body || '';
|
||
}
|
||
|
||
function apiUrl(path, rootUri, extra) {
|
||
var url = new URL(path, window.location.origin);
|
||
if (rootUri) url.searchParams.set('rootUri', rootUri);
|
||
url.searchParams.set('sourceKind', 'local_folder');
|
||
if (extra) {
|
||
Object.keys(extra).forEach(function (key) {
|
||
if (extra[key] != null && extra[key] !== '') {
|
||
url.searchParams.set(key, extra[key]);
|
||
}
|
||
});
|
||
}
|
||
return url.toString();
|
||
}
|
||
|
||
async function apiJson(method, path, rootUri, body, queryExtra) {
|
||
var opts = {
|
||
method: method,
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
};
|
||
var url = path;
|
||
if (method === 'GET' || method === 'DELETE') {
|
||
url = apiUrl(path, rootUri, queryExtra);
|
||
} else {
|
||
opts.headers['Content-Type'] = 'application/json';
|
||
var payload = Object.assign({ rootUri: rootUri, sourceKind: 'local_folder' }, body || {});
|
||
opts.body = JSON.stringify(payload);
|
||
if (queryExtra) {
|
||
url = apiUrl(path, rootUri, queryExtra);
|
||
}
|
||
}
|
||
var res = await fetch(url, opts);
|
||
var data = null;
|
||
try {
|
||
data = await res.json();
|
||
} catch (_e) {
|
||
data = null;
|
||
}
|
||
if (!res.ok || (data && data.ok === false)) {
|
||
var code = (data && (data.code || (data.error && data.error.code))) || res.status;
|
||
var msg =
|
||
(data && (data.message || data.error || (data.error && data.error.message))) ||
|
||
('请求失败 ' + res.status);
|
||
if (typeof msg === 'object') msg = JSON.stringify(msg);
|
||
var err = new Error(String(msg));
|
||
err.code = code;
|
||
err.status = res.status;
|
||
err.payload = data;
|
||
throw err;
|
||
}
|
||
return data && data.result != null ? data.result : data;
|
||
}
|
||
|
||
function maskLabel(state) {
|
||
if (!state || state.state === 'absent') return '(无)';
|
||
if (state.state === 'masked') return '••••••••';
|
||
if (state.state === 'revealed') return state.value || '';
|
||
return '••••••••';
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text == null ? '' : text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
/**
|
||
* Reusable helper for rendering a single secret row (password/apikey/token).
|
||
* Used heavily by account secrets, nested secrets, and the second-secret bug fix.
|
||
* Reduces duplication in render blocks.
|
||
*/
|
||
function renderSecretRow(field, secretState, accountId, secretId, showEmpty, opts = {}) {
|
||
var key = opts.secretId || '';
|
||
var has = secretState && secretState.state === 'masked';
|
||
var canReveal = has || (secretState && secretState.state === 'revealed');
|
||
if (!canReveal && !showEmpty) return '';
|
||
|
||
var shown = state.revealed[key]
|
||
? state._revealedValues && state._revealedValues[key]
|
||
? escapeHtml(state._revealedValues[key])
|
||
: maskLabel(secretState)
|
||
: maskLabel(secretState);
|
||
|
||
var revealBtn = state.revealed[key] ? '隐藏' : '显示';
|
||
var copyBtn = '<button type="button" data-vault-copy-secret="" data-testid="vault-secret-' + escapeHtml(key) + '">复制</button>';
|
||
|
||
var actions = state.revealed[key]
|
||
? '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn
|
||
: '<button type="button" data-vault-reveal="" data-vault-secret-id="' + escapeHtml(secretId) + '" data-testid="vault-reveal-' + escapeHtml(key) + '">' + revealBtn + '</button>' + copyBtn;
|
||
|
||
return '<div class="mnote-vault-field-row" data-vault-secret-row="' + escapeHtml(key) + '">'
|
||
+ '<div class="mnote-vault-secret-value" data-testid="vault-secret-' + escapeHtml(key) + '">' + shown + '</div>'
|
||
+ '<div class="mnote-vault-secret-actions">' + actions + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
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: {},
|
||
/** @type {HTMLInputElement|HTMLTextAreaElement|null} 编辑表单中最近一次可插入密文的输入框 */
|
||
lastCipherInsertTarget: null,
|
||
/** @type {{ start: number, end: number }|null} 失焦前光标,避免点下拉后丢失 */
|
||
lastCipherInsertRange: null,
|
||
isAiVault: false,
|
||
aiVaultActorId: 'mnote-e2e',
|
||
vaultRole: 'user',
|
||
showEmptyFields: false,
|
||
/**
|
||
* Edit/create form UI chrome that must survive reRenderFormKeepingSlots
|
||
* and view-mode renderDetail (password/appendix reveal re-renders).
|
||
* Without this, appendix-secret panels always re-mount collapsed.
|
||
* @type {{ openAccountIds: Record<string, boolean>, expandedSecretPanels: Record<string, boolean> }}
|
||
*/
|
||
_formUi: { openAccountIds: {}, expandedSecretPanels: {} },
|
||
};
|
||
|
||
function ensureFormUi() {
|
||
if (!state._formUi) {
|
||
state._formUi = { openAccountIds: {}, expandedSecretPanels: {} };
|
||
}
|
||
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
|
||
if (!state._formUi.expandedSecretPanels) {
|
||
state._formUi.expandedSecretPanels = {};
|
||
}
|
||
return state._formUi;
|
||
}
|
||
|
||
/** Account key for UI chrome (open group / appendix panel). */
|
||
function accountUiKey(accId, idx) {
|
||
if (accId != null && String(accId) !== '') return String(accId);
|
||
if (idx != null) return 'idx:' + String(idx);
|
||
return 'default';
|
||
}
|
||
|
||
function isSecretsPanelExpanded(accKey) {
|
||
var ui = ensureFormUi();
|
||
return !!ui.expandedSecretPanels[accKey];
|
||
}
|
||
|
||
function setSecretsPanelExpanded(accKey, expanded) {
|
||
if (!accKey) return;
|
||
ensureFormUi().expandedSecretPanels[accKey] = !!expanded;
|
||
}
|
||
|
||
/**
|
||
* Build appendix-secrets panel markup; honors expanded state so reveal/hide
|
||
* re-renders do not collapse an already-open panel.
|
||
*/
|
||
function renderSecretsPanelHtml(opts) {
|
||
opts = opts || {};
|
||
var accKey = opts.accKey || 'default';
|
||
var title = opts.title || '附录密钥';
|
||
var count = opts.count || 0;
|
||
var bodyHtml = opts.bodyHtml || '';
|
||
var extraAttrs = opts.extraAttrs || '';
|
||
var headExtra = opts.headExtra || '';
|
||
var expanded = isSecretsPanelExpanded(accKey);
|
||
return (
|
||
'<div class="mnote-vault-account-secrets' +
|
||
(expanded ? '' : ' is-collapsed') +
|
||
'" data-vault-secrets-panel data-vault-secrets-acc="' +
|
||
escapeHtml(accKey) +
|
||
'"' +
|
||
extraAttrs +
|
||
'>' +
|
||
'<div class="mnote-vault-account-secrets-head">' +
|
||
'<button type="button" class="mnote-vault-secrets-toggle" data-vault-toggle-secrets aria-expanded="' +
|
||
(expanded ? 'true' : 'false') +
|
||
'">' +
|
||
'<span class="mnote-vault-secrets-chevron" aria-hidden="true">' +
|
||
(expanded ? '▼' : '▶') +
|
||
'</span>' +
|
||
'<span>' +
|
||
escapeHtml(title) +
|
||
'</span>' +
|
||
(count
|
||
? '<span class="mnote-vault-secrets-count">(' + count + ')</span>'
|
||
: '') +
|
||
'</button>' +
|
||
headExtra +
|
||
'</div>' +
|
||
'<div class="mnote-vault-account-secrets-body"' +
|
||
(expanded ? '' : ' hidden') +
|
||
'>' +
|
||
bodyHtml +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
var listEl = qs('[data-vault-list]', root);
|
||
var detailEl = qs('[data-vault-detail]', root);
|
||
var statusEl = qs('[data-vault-status]', root);
|
||
var searchEl = qs('[data-vault-search]', root);
|
||
var tabActive = qs('[data-vault-tab="active"]', root);
|
||
var tabDeleted = qs('[data-vault-tab="deleted"]', root);
|
||
|
||
// 折叠态:localStorage(同 origin 多窗 / 未来桌面多窗共享)+ BroadcastChannel 即时同步。
|
||
// 与页面树 view-state 同思路的「本机 UI 状态」层;暂不写 control-plane。
|
||
var VAULT_COLLAPSE_LEGACY_SESSION_KEY = 'mnote.vault.collapsedFolders';
|
||
var VAULT_COLLAPSE_SCHEMA = 1;
|
||
var collapseUpdatedAtMs = 0;
|
||
var collapseBroadcast = null;
|
||
var collapseStorageKey = vaultCollapseStorageKey(state.rootUri);
|
||
|
||
function vaultCollapseStorageKey(rootUri) {
|
||
var raw = String(rootUri || '').trim() || 'default';
|
||
var hash = 0;
|
||
for (var i = 0; i < raw.length; i++) {
|
||
hash = (hash * 31 + raw.charCodeAt(i)) | 0;
|
||
}
|
||
return (
|
||
'mnote.vault.collapsedFolders.v1:' +
|
||
(hash >>> 0).toString(16) +
|
||
':' +
|
||
encodeURIComponent(raw.slice(0, 120))
|
||
);
|
||
}
|
||
|
||
function normalizeCollapseMap(raw) {
|
||
var out = {};
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||
Object.keys(raw).forEach(function (k) {
|
||
out[k] = !!raw[k];
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function parseCollapsePayload(raw) {
|
||
if (!raw) return null;
|
||
try {
|
||
var parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||
if (!parsed || typeof parsed !== 'object') return null;
|
||
// v1 envelope: { schemaVersion, updatedAtMs, collapsed }
|
||
if (parsed.collapsed && typeof parsed.collapsed === 'object') {
|
||
return {
|
||
schemaVersion: Number(parsed.schemaVersion) || VAULT_COLLAPSE_SCHEMA,
|
||
updatedAtMs: Number(parsed.updatedAtMs) || 0,
|
||
collapsed: normalizeCollapseMap(parsed.collapsed),
|
||
};
|
||
}
|
||
// legacy flat map { path: boolean }
|
||
return {
|
||
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
||
updatedAtMs: 0,
|
||
collapsed: normalizeCollapseMap(parsed),
|
||
};
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function serializeCollapsePayload() {
|
||
return JSON.stringify({
|
||
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
||
updatedAtMs: collapseUpdatedAtMs || Date.now(),
|
||
collapsed: state.collapsedFolders || {},
|
||
});
|
||
}
|
||
|
||
function loadCollapseState() {
|
||
var fromLocal = null;
|
||
try {
|
||
if (window.localStorage) {
|
||
fromLocal = parseCollapsePayload(window.localStorage.getItem(collapseStorageKey));
|
||
}
|
||
} catch (_e) {
|
||
fromLocal = null;
|
||
}
|
||
// 一次性迁移:同 tab 旧 sessionStorage → localStorage
|
||
var fromSession = null;
|
||
try {
|
||
if (window.sessionStorage) {
|
||
fromSession = parseCollapsePayload(
|
||
window.sessionStorage.getItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY)
|
||
);
|
||
}
|
||
} catch (_e) {
|
||
fromSession = null;
|
||
}
|
||
var chosen = fromLocal;
|
||
if (fromSession && (!chosen || (fromSession.updatedAtMs || 0) >= (chosen.updatedAtMs || 0))) {
|
||
// 无 local 或 session 更新时间不落后时:优先有数据的一份;二者都无时间戳时若 local 空则用 session
|
||
if (!chosen || !Object.keys(chosen.collapsed || {}).length) {
|
||
chosen = fromSession;
|
||
}
|
||
}
|
||
if (chosen) {
|
||
state.collapsedFolders = chosen.collapsed || {};
|
||
collapseUpdatedAtMs = chosen.updatedAtMs || Date.now();
|
||
} else {
|
||
state.collapsedFolders = {};
|
||
collapseUpdatedAtMs = 0;
|
||
}
|
||
// 若只从 session 迁过来,立刻落到 localStorage(不广播,避免冷启动风暴)
|
||
if (fromSession && Object.keys(fromSession.collapsed || {}).length) {
|
||
try {
|
||
if (window.sessionStorage) {
|
||
window.sessionStorage.removeItem(VAULT_COLLAPSE_LEGACY_SESSION_KEY);
|
||
}
|
||
} catch (_e2) {
|
||
/* ignore */
|
||
}
|
||
if (!fromLocal || !Object.keys(fromLocal.collapsed || {}).length) {
|
||
collapseUpdatedAtMs = collapseUpdatedAtMs || Date.now();
|
||
try {
|
||
if (window.localStorage) {
|
||
window.localStorage.setItem(collapseStorageKey, serializeCollapsePayload());
|
||
}
|
||
} catch (_e3) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyRemoteCollapsePayload(payload, source) {
|
||
if (!payload || !payload.collapsed) return false;
|
||
var remoteTs = Number(payload.updatedAtMs) || 0;
|
||
// LWW:远程更旧则忽略(含本窗 storage 回声时用 updatedAt 防抖)
|
||
if (remoteTs && collapseUpdatedAtMs && remoteTs < collapseUpdatedAtMs) {
|
||
return false;
|
||
}
|
||
var next = normalizeCollapseMap(payload.collapsed);
|
||
var prev = state.collapsedFolders || {};
|
||
var same =
|
||
Object.keys(next).length === Object.keys(prev).length &&
|
||
Object.keys(next).every(function (k) {
|
||
return prev[k] === next[k];
|
||
});
|
||
if (same && remoteTs <= collapseUpdatedAtMs) return false;
|
||
state.collapsedFolders = next;
|
||
collapseUpdatedAtMs = remoteTs || Date.now();
|
||
if (source !== 'localStorage-echo') {
|
||
// storage 事件来自其他窗已写好 localStorage,无需再写
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function persistCollapse(options) {
|
||
options = options || {};
|
||
collapseUpdatedAtMs = Date.now();
|
||
var payload = {
|
||
schemaVersion: VAULT_COLLAPSE_SCHEMA,
|
||
updatedAtMs: collapseUpdatedAtMs,
|
||
collapsed: state.collapsedFolders || {},
|
||
};
|
||
var raw = JSON.stringify(payload);
|
||
try {
|
||
if (window.localStorage) {
|
||
window.localStorage.setItem(collapseStorageKey, raw);
|
||
}
|
||
} catch (_e) {
|
||
/* ignore */
|
||
}
|
||
// 同 origin 其他窗:BroadcastChannel 即时;无 BC 时靠 storage 事件
|
||
if (options.broadcast !== false && collapseBroadcast) {
|
||
try {
|
||
collapseBroadcast.postMessage({
|
||
type: 'vault.collapse.changed',
|
||
storageKey: collapseStorageKey,
|
||
payload: payload,
|
||
});
|
||
} catch (_e2) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
function onCollapseSyncMessage(payload, source) {
|
||
if (applyRemoteCollapsePayload(payload, source)) {
|
||
// 列表已渲染时跟着重画;boot 前 renderList 可能尚不可用
|
||
if (typeof renderList === 'function') {
|
||
try {
|
||
renderList();
|
||
} catch (_e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function installCollapseSync() {
|
||
try {
|
||
if (typeof BroadcastChannel !== 'undefined') {
|
||
collapseBroadcast = new BroadcastChannel('mnote.vault.collapse.v1');
|
||
collapseBroadcast.onmessage = function (ev) {
|
||
var data = ev && ev.data;
|
||
if (!data || data.type !== 'vault.collapse.changed') return;
|
||
if (data.storageKey && data.storageKey !== collapseStorageKey) return;
|
||
onCollapseSyncMessage(data.payload, 'broadcast');
|
||
};
|
||
}
|
||
} catch (_e) {
|
||
collapseBroadcast = null;
|
||
}
|
||
// 跨窗 localStorage 变更(BroadcastChannel 不可用或 Electron 分区差异时的托底)
|
||
window.addEventListener('storage', function (ev) {
|
||
if (!ev || ev.key !== collapseStorageKey || ev.storageArea !== window.localStorage) {
|
||
return;
|
||
}
|
||
var remote = parseCollapsePayload(ev.newValue);
|
||
if (remote) onCollapseSyncMessage(remote, 'storage');
|
||
});
|
||
}
|
||
|
||
loadCollapseState();
|
||
installCollapseSync();
|
||
|
||
/**
|
||
* Collapse state: only keys present in collapsedFolders are explicit.
|
||
* 默认全部折叠(文件夹 + 同 URL 多账号组);用户点开后写入 localStorage 并跨窗同步。
|
||
* 旧版「未记录 = 展开」会让大分组(如「倍特」14 条)一进来铺满左栏。
|
||
*/
|
||
function isCollapsedKey(key) {
|
||
if (Object.prototype.hasOwnProperty.call(state.collapsedFolders, key)) {
|
||
return !!state.collapsedFolders[key];
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function toggleCollapseKey(key) {
|
||
state.collapsedFolders[key] = !isCollapsedKey(key);
|
||
persistCollapse({ broadcast: true });
|
||
}
|
||
|
||
/** Normalize folderPath for save; groups appear in the list only after an entry uses them. */
|
||
function normalizeFolderPath(raw) {
|
||
var fp = String(raw || '')
|
||
.trim()
|
||
.replace(/\\/g, '/')
|
||
.replace(/\/+/g, '/')
|
||
.replace(/^\/+|\/+$/g, '');
|
||
if (!fp) return '';
|
||
var parts = fp.split('/').filter(function (p) {
|
||
return p && p !== '.' && p !== '..';
|
||
});
|
||
return parts.join('/');
|
||
}
|
||
|
||
function isNotesEmpty(notes) {
|
||
var t = String(notes || '')
|
||
.replace(/^#+\s*Notes\s*/i, '')
|
||
.trim();
|
||
return !t;
|
||
}
|
||
|
||
function itemIsSharedToAi(item) {
|
||
return !!(item && (item.isSharedToAi || item.sharedToAi));
|
||
}
|
||
|
||
function itemIsAiCopy(item) {
|
||
return !!(item && (item.isAiSharedCopy || item.sharedFrom ||
|
||
((item.tags || []).indexOf('ai-shared') >= 0)));
|
||
}
|
||
|
||
function updateHeaderChrome() {
|
||
var titleEl = qs('.mnote-vault-header h1', root);
|
||
var badge = qs('[data-vault-role-badge]', root);
|
||
if (titleEl) {
|
||
titleEl.textContent = state.isAiVault ? 'AI 密码本' : '密码箱';
|
||
}
|
||
if (!badge) {
|
||
var main = qs('.mnote-vault-header-main', root);
|
||
if (main) {
|
||
badge = document.createElement('span');
|
||
badge.setAttribute('data-vault-role-badge', '1');
|
||
badge.className = 'mnote-vault-role-badge';
|
||
main.appendChild(badge);
|
||
}
|
||
}
|
||
if (badge) {
|
||
if (state.isAiVault) {
|
||
badge.hidden = false;
|
||
badge.textContent = 'AI 专用 · ' + (state.aiVaultActorId || 'mnote-e2e');
|
||
badge.classList.add('is-ai');
|
||
} else {
|
||
badge.hidden = true;
|
||
badge.classList.remove('is-ai');
|
||
}
|
||
}
|
||
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.urls || []).join(' '),
|
||
item.username,
|
||
item.email,
|
||
item.passwordHint,
|
||
item.folderPath,
|
||
(item.tags || []).join(' '),
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return hay.indexOf(q) >= 0;
|
||
});
|
||
}
|
||
|
||
/** Build nested folder tree from folderPath (a/b/c). Only paths that have entries. */
|
||
function buildFolderTree(items) {
|
||
var treeRoot = { name: '', path: '', children: {}, items: [] };
|
||
items.forEach(function (item) {
|
||
var fp = (item.folderPath || '').trim();
|
||
if (!fp) {
|
||
treeRoot.items.push(item);
|
||
return;
|
||
}
|
||
var parts = fp.split('/').filter(Boolean);
|
||
var node = treeRoot;
|
||
var acc = [];
|
||
parts.forEach(function (part) {
|
||
acc.push(part);
|
||
var p = acc.join('/');
|
||
if (!node.children[part]) {
|
||
node.children[part] = { name: part, path: p, children: {}, items: [] };
|
||
}
|
||
node = node.children[part];
|
||
});
|
||
node.items.push(item);
|
||
});
|
||
return treeRoot;
|
||
}
|
||
|
||
function renderItemButton(item) {
|
||
var active = item.id === state.selectedId ? ' is-active' : '';
|
||
var accN = item.accountCount || 0;
|
||
var secN = item.secretCount || 0;
|
||
var metaParts = [item.username || item.email || ''];
|
||
if (accN > 1) metaParts.push(accN + ' 账号');
|
||
if (secN > 0) metaParts.push(secN + ' 密钥');
|
||
var meta = metaParts.filter(Boolean).join(' · ');
|
||
// Single-row compact: title · meta | status glyphs only (no badge rows).
|
||
var icons = '';
|
||
if (itemIsSharedToAi(item) && !state.isAiVault) {
|
||
icons +=
|
||
'<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 = isCollapsedKey(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 = isCollapsedKey(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;
|
||
}
|
||
|
||
/** Ordered unique URLs from item (primary first); supports legacy single `url`. */
|
||
function normalizeUrlsFromItem(item) {
|
||
var out = [];
|
||
function push(u) {
|
||
var t = String(u || '').trim();
|
||
if (!t) return;
|
||
if (out.indexOf(t) >= 0) return;
|
||
out.push(t);
|
||
}
|
||
if (item && Array.isArray(item.urls) && item.urls.length) {
|
||
item.urls.forEach(push);
|
||
}
|
||
if (item && item.url) push(item.url);
|
||
// Prefer primary url first when both present and order disagrees.
|
||
if (item && item.url && out.length) {
|
||
var primary = String(item.url).trim();
|
||
if (primary && out[0] !== primary && out.indexOf(primary) >= 0) {
|
||
out = [primary].concat(
|
||
out.filter(function (u) {
|
||
return u !== primary;
|
||
})
|
||
);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function emptyForm(defaults) {
|
||
defaults = defaults || {};
|
||
var urls = normalizeUrlsFromItem(defaults);
|
||
if (!urls.length && defaults.url) urls = [String(defaults.url)];
|
||
return {
|
||
id: '',
|
||
title: defaults.title || '',
|
||
url: urls[0] || defaults.url || '',
|
||
urls: urls,
|
||
username: defaults.username || '',
|
||
email: defaults.email || '',
|
||
passwordHint: defaults.passwordHint || '',
|
||
folderPath: defaults.folderPath || '',
|
||
password: { state: 'absent' },
|
||
apikey: { state: 'absent' },
|
||
token: { state: 'absent' },
|
||
accounts: defaults.accounts || [
|
||
{
|
||
id: '',
|
||
label: '',
|
||
username: defaults.username || '',
|
||
email: defaults.email || '',
|
||
passwordHint: defaults.passwordHint || '',
|
||
password: { state: 'absent' },
|
||
secrets: defaults.secrets || [],
|
||
},
|
||
],
|
||
// Top-level secrets kept for API flatten only; UI nests under accounts.
|
||
secrets: defaults.secrets || [],
|
||
tags: defaults.tags || [],
|
||
notesMarkdown: defaults.notesMarkdown || '## Notes\n\n',
|
||
revision: 0,
|
||
status: 'active',
|
||
};
|
||
}
|
||
|
||
function newClientId(prefix) {
|
||
return (
|
||
prefix +
|
||
'_' +
|
||
Math.random().toString(16).slice(2) +
|
||
Date.now().toString(16)
|
||
);
|
||
}
|
||
|
||
/** View shows expanded value; edit prefers raw [Key] template when present. */
|
||
function cipherFieldValue(displayValue, templateValue) {
|
||
if (templateValue) return String(templateValue);
|
||
return displayValue || '';
|
||
}
|
||
|
||
function mapSecretSlot(s) {
|
||
return {
|
||
id: (s && s.id) || '',
|
||
kind: (s && s.kind) || 'apikey',
|
||
label: (s && s.label) || '',
|
||
value: (s && s.value) || { state: 'absent' },
|
||
accountId: (s && (s.accountId || s.account_id)) || '',
|
||
};
|
||
}
|
||
|
||
/** Flatten top-level secrets for legacy items; prefer nested account.secrets. */
|
||
function normalizeSecretsFromItem(item) {
|
||
var secrets = (item && item.secrets) || [];
|
||
if (secrets.length) {
|
||
return secrets.map(mapSecretSlot);
|
||
}
|
||
var out = [];
|
||
if (item && item.apikey && item.apikey.state && item.apikey.state !== 'absent') {
|
||
out.push({
|
||
id: '',
|
||
kind: 'apikey',
|
||
label: 'API Key',
|
||
value: item.apikey,
|
||
accountId: '',
|
||
});
|
||
}
|
||
if (item && item.token && item.token.state && item.token.state !== 'absent') {
|
||
out.push({
|
||
id: '',
|
||
kind: 'token',
|
||
label: 'Token',
|
||
value: item.token,
|
||
accountId: '',
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function normalizeAccountsFromItem(item, forEdit) {
|
||
var topSecrets = normalizeSecretsFromItem(item);
|
||
var accounts = (item && item.accounts) || [];
|
||
if (accounts.length) {
|
||
var anyNested = accounts.some(function (a) {
|
||
return a.secrets && a.secrets.length;
|
||
});
|
||
return accounts.map(function (a, aidx) {
|
||
var nested = (a.secrets || []).map(mapSecretSlot);
|
||
// Legacy: top-level secrets attach to first account when no nested.
|
||
if (!anyNested && aidx === 0 && topSecrets.length) {
|
||
nested = topSecrets.slice();
|
||
} else if (!anyNested && topSecrets.length) {
|
||
nested = topSecrets.filter(function (s) {
|
||
return s.accountId && s.accountId === a.id;
|
||
});
|
||
}
|
||
return {
|
||
id: a.id || '',
|
||
label: a.label || '',
|
||
username: forEdit
|
||
? cipherFieldValue(a.username, a.usernameTemplate)
|
||
: a.username || '',
|
||
email: forEdit
|
||
? cipherFieldValue(a.email, a.emailTemplate)
|
||
: a.email || '',
|
||
passwordHint: a.passwordHint || '',
|
||
password: a.password || { state: 'absent' },
|
||
secrets: nested,
|
||
};
|
||
});
|
||
}
|
||
if (
|
||
(item && (item.username || item.email || item.passwordHint || item.usernameTemplate || item.emailTemplate)) ||
|
||
(item && item.password && item.password.state && item.password.state !== 'absent') ||
|
||
topSecrets.length
|
||
) {
|
||
return [
|
||
{
|
||
id: '',
|
||
label: '',
|
||
username: forEdit
|
||
? cipherFieldValue(item && item.username, item && item.usernameTemplate)
|
||
: (item && item.username) || '',
|
||
email: forEdit
|
||
? cipherFieldValue(item && item.email, item && item.emailTemplate)
|
||
: (item && item.email) || '',
|
||
passwordHint: (item && item.passwordHint) || '',
|
||
password: (item && item.password) || { state: 'absent' },
|
||
secrets: topSecrets,
|
||
},
|
||
];
|
||
}
|
||
return [
|
||
{
|
||
id: '',
|
||
label: '',
|
||
username: '',
|
||
email: '',
|
||
passwordHint: '',
|
||
password: { state: 'absent' },
|
||
secrets: [],
|
||
},
|
||
];
|
||
}
|
||
|
||
function renderDetail() {
|
||
if (!detailEl) return;
|
||
if (!state.rootUri) {
|
||
detailEl.innerHTML =
|
||
'<div class="mnote-vault-empty">请先打开本地文件夹工作区,再使用密码箱。</div>';
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
if (state.mode === 'cipher') {
|
||
renderCipherBook();
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
if (state.mode === 'create') {
|
||
renderForm(emptyForm(state._createDefaults || {}), true);
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
if (!state.item && !state.selectedId) {
|
||
detailEl.innerHTML =
|
||
'<div class="mnote-vault-empty" data-testid="vault-detail-empty">从左侧选择条目,或点击「新建」。</div>';
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
if (!state.item) {
|
||
detailEl.innerHTML = '<div class="mnote-vault-empty">加载中…</div>';
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
if (state.mode === 'edit') {
|
||
renderForm(state.item, false);
|
||
syncHeaderCipherInsertVisibility();
|
||
return;
|
||
}
|
||
renderView(state.item);
|
||
syncHeaderCipherInsertVisibility();
|
||
}
|
||
|
||
function revealKey(field, accountId, secretId) {
|
||
if (secretId) return 'secret:' + secretId;
|
||
if (accountId && field === 'password') return 'account:' + accountId + ':password';
|
||
return field;
|
||
}
|
||
|
||
function secretRow(label, field, secretState, showEmpty, opts) {
|
||
opts = opts || {};
|
||
var accountId = opts.accountId || '';
|
||
var secretId = opts.secretId || '';
|
||
var key = revealKey(field, accountId, secretId);
|
||
var has = secretState && secretState.state === 'masked';
|
||
var canReveal = has || (secretState && secretState.state === 'revealed');
|
||
if (!canReveal && !showEmpty) return '';
|
||
var shown = state.revealed[key]
|
||
? state._revealedValues && state._revealedValues[key]
|
||
? escapeHtml(state._revealedValues[key])
|
||
: '…'
|
||
: maskLabel(secretState);
|
||
var extra =
|
||
(accountId ? ' data-vault-account-id="' + escapeHtml(accountId) + '"' : '') +
|
||
(secretId ? ' data-vault-secret-id="' + escapeHtml(secretId) + '"' : '');
|
||
return (
|
||
'<div class="mnote-vault-field-row" data-vault-secret-row="' +
|
||
escapeHtml(key) +
|
||
'">' +
|
||
'<label>' +
|
||
escapeHtml(label) +
|
||
'</label>' +
|
||
'<div class="mnote-vault-secret-value" data-testid="vault-secret-' +
|
||
escapeHtml(key) +
|
||
'">' +
|
||
shown +
|
||
'</div>' +
|
||
'<div class="mnote-vault-secret-actions">' +
|
||
(canReveal
|
||
? '<button type="button" data-vault-reveal="' +
|
||
escapeHtml(field) +
|
||
'"' +
|
||
extra +
|
||
'>' +
|
||
(state.revealed[key] ? '隐藏' : '显示') +
|
||
'</button>' +
|
||
'<button type="button" data-vault-copy-secret="' +
|
||
escapeHtml(field) +
|
||
'"' +
|
||
extra +
|
||
'>复制</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 accounts = normalizeAccountsFromItem(item, false);
|
||
var accountsHtml = accounts
|
||
.map(function (acc, idx) {
|
||
var title =
|
||
acc.label ||
|
||
acc.username ||
|
||
acc.email ||
|
||
'账号 ' + (idx + 1);
|
||
var body =
|
||
fieldRow('标签名', acc.label, showEmpty) +
|
||
fieldRow('用户名', acc.username, showEmpty) +
|
||
fieldRow('邮箱', acc.email, showEmpty) +
|
||
secretRow('密码', 'password', acc.password, showEmpty, {
|
||
accountId: acc.id || '',
|
||
}) +
|
||
fieldRow('密码提示', acc.passwordHint, showEmpty);
|
||
var secList = acc.secrets || [];
|
||
var nestedSecretsHtml = secList
|
||
.map(function (sec, sidx) {
|
||
var kindLabel =
|
||
sec.kind === 'token'
|
||
? 'Token'
|
||
: sec.kind === 'other'
|
||
? '其他'
|
||
: 'API Key';
|
||
var stitle = sec.label || kindLabel + ' ' + (sidx + 1);
|
||
return (
|
||
'<div class="mnote-vault-nested-secret" data-vault-secret-view="' +
|
||
escapeHtml(sec.id || String(sidx)) +
|
||
'">' +
|
||
'<div class="mnote-vault-nested-secret-title">' +
|
||
escapeHtml(kindLabel) +
|
||
(sec.label ? ' · ' + escapeHtml(sec.label) : '') +
|
||
'</div>' +
|
||
secretRow(
|
||
stitle,
|
||
sec.kind === 'token' ? 'token' : 'apikey',
|
||
sec.value,
|
||
showEmpty,
|
||
{ secretId: sec.id || '' }
|
||
) +
|
||
'</div>'
|
||
);
|
||
})
|
||
.join('');
|
||
if (nestedSecretsHtml) {
|
||
// 附录密钥默认折叠;展开态记在 state,避免显示/隐藏密码时整页重绘后被收起。
|
||
body += renderSecretsPanelHtml({
|
||
accKey: accountUiKey(acc.id, idx),
|
||
title: '附录密钥',
|
||
count: secList.length,
|
||
bodyHtml: nestedSecretsHtml,
|
||
});
|
||
} else if (showEmpty) {
|
||
body +=
|
||
'<div class="mnote-vault-muted mnote-vault-account-secrets-empty">暂无附录密钥</div>';
|
||
}
|
||
if (!body.trim()) {
|
||
body = '<div class="mnote-vault-muted">(空账号组)</div>';
|
||
}
|
||
return (
|
||
'<details class="mnote-vault-slot-group" open data-vault-account-view="' +
|
||
escapeHtml(acc.id || String(idx)) +
|
||
'">' +
|
||
'<summary class="mnote-vault-slot-summary">' +
|
||
'<span class="mnote-vault-slot-title">账号 · ' +
|
||
escapeHtml(title) +
|
||
'</span>' +
|
||
(secList.length
|
||
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
|
||
: '') +
|
||
'</summary>' +
|
||
'<div class="mnote-vault-slot-body">' +
|
||
body +
|
||
'</div></details>'
|
||
);
|
||
})
|
||
.join('');
|
||
if (!accountsHtml && showEmpty) {
|
||
accountsHtml =
|
||
'<div class="mnote-vault-muted">暂无账号组</div>';
|
||
}
|
||
var urls = normalizeUrlsFromItem(item);
|
||
var urlsHtml = '';
|
||
if (urls.length) {
|
||
urlsHtml = urls
|
||
.map(function (u, idx) {
|
||
var label = idx === 0 ? '主网址' : '备用 ' + idx;
|
||
var link =
|
||
'<a href="' +
|
||
escapeHtml(u) +
|
||
'" target="_blank" rel="noopener noreferrer">' +
|
||
escapeHtml(u) +
|
||
'</a>';
|
||
return (
|
||
'<div class="mnote-vault-field-row">' +
|
||
'<label>' +
|
||
escapeHtml(label) +
|
||
'</label>' +
|
||
'<div class="mnote-vault-field-value">' +
|
||
link +
|
||
'</div></div>'
|
||
);
|
||
})
|
||
.join('');
|
||
} else if (showEmpty) {
|
||
urlsHtml = fieldRow('网址', '', true);
|
||
}
|
||
var fieldsHtml =
|
||
fieldRow('分组', item.folderPath, showEmpty) +
|
||
(urls.length > 1
|
||
? '<div class="mnote-vault-section mnote-vault-urls-view">' +
|
||
'<div class="mnote-vault-section-head"><span>网址(等价入口)</span>' +
|
||
'<span class="mnote-vault-muted">' +
|
||
urls.length +
|
||
' 个</span></div>' +
|
||
urlsHtml +
|
||
'</div>'
|
||
: urls.length === 1
|
||
? fieldRow('网址', urls[0], showEmpty)
|
||
: urlsHtml) +
|
||
'<div class="mnote-vault-section">' +
|
||
'<div class="mnote-vault-section-head"><span>登录账号</span>' +
|
||
(accounts.length > 1
|
||
? '<span class="mnote-vault-muted">' + accounts.length + ' 组</span>'
|
||
: '') +
|
||
'</div>' +
|
||
accountsHtml +
|
||
'</div>' +
|
||
(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>'
|
||
: 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(', ');
|
||
// 编辑时用 usernameTemplate/emailTemplate,避免把已展开明文写回。
|
||
var accounts = normalizeAccountsFromItem(item, true);
|
||
var urls = normalizeUrlsFromItem(item);
|
||
if (!urls.length) urls = [''];
|
||
state._formUrls = urls.slice();
|
||
state._formAccounts = accounts.map(function (a) {
|
||
return {
|
||
id: a.id || newClientId('acc'),
|
||
label: a.label || '',
|
||
username: a.username || '',
|
||
email: a.email || '',
|
||
passwordHint: a.passwordHint || '',
|
||
passwordState: a.password || { state: 'absent' },
|
||
passwordClear: false,
|
||
passwordValue: '',
|
||
secrets: (a.secrets || []).map(function (s) {
|
||
return {
|
||
id: s.id || newClientId('sec'),
|
||
kind: s.kind || 'apikey',
|
||
label: s.label || '',
|
||
valueState: s.value || { state: 'absent' },
|
||
valueClear: false,
|
||
valueText: '',
|
||
accountId: a.id || '',
|
||
};
|
||
}),
|
||
};
|
||
});
|
||
if (!state._formAccounts.length) {
|
||
state._formAccounts.push({
|
||
id: newClientId('acc'),
|
||
label: '',
|
||
username: '',
|
||
email: '',
|
||
passwordHint: '',
|
||
passwordState: { state: 'absent' },
|
||
passwordClear: false,
|
||
passwordValue: '',
|
||
secrets: [],
|
||
});
|
||
}
|
||
// Legacy flat list no longer used in UI; kept empty for re-render injects.
|
||
state._formSecrets = [];
|
||
|
||
function urlsEditorHtml() {
|
||
var rows = (state._formUrls || ['']).map(function (u, idx) {
|
||
return (
|
||
'<div class="mnote-vault-url-row" data-vault-url-idx="' +
|
||
idx +
|
||
'">' +
|
||
'<label class="mnote-vault-url-label">' +
|
||
(idx === 0 ? '主网址' : '备用 ' + idx) +
|
||
'</label>' +
|
||
'<input type="url" data-vault-url-input data-url-idx="' +
|
||
idx +
|
||
'" value="' +
|
||
escapeHtml(u || '') +
|
||
'" placeholder="' +
|
||
(idx === 0
|
||
? 'https://example.com'
|
||
: '等价入口,如 FRP / 内网') +
|
||
'" autocomplete="off" />' +
|
||
(idx > 0
|
||
? '<button type="button" class="is-ghost" data-vault-remove-url="' +
|
||
idx +
|
||
'" title="移除">×</button>'
|
||
: '<span class="mnote-vault-url-spacer"></span>') +
|
||
'</div>'
|
||
);
|
||
});
|
||
return (
|
||
'<div class="mnote-vault-section" data-vault-urls-section>' +
|
||
'<div class="mnote-vault-section-head">' +
|
||
'<span>网址(等价入口 / fallback)</span>' +
|
||
'<button type="button" data-vault-add-url data-testid="vault-add-url">+ 网址</button>' +
|
||
'</div>' +
|
||
'<p class="mnote-vault-form-hint mnote-vault-url-hint">同一服务多个可达地址时,第一项为主入口,其余为备用(VPS / FRP / 本地等)。</p>' +
|
||
rows.join('') +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function accountGroupHtml(acc, idx) {
|
||
// Preserve which account groups were open across re-renders (not only idx===0).
|
||
var uiOpen = state._formUi && state._formUi.openAccountIds;
|
||
var open =
|
||
(uiOpen && uiOpen[acc.id] === true) ||
|
||
(uiOpen && uiOpen[acc.id] !== false && idx === 0) ||
|
||
(!uiOpen && idx === 0)
|
||
? ' open'
|
||
: '';
|
||
var title = acc.label || acc.username || acc.email || '账号 ' + (idx + 1);
|
||
var hasPwd = acc.passwordState && acc.passwordState.state === 'masked';
|
||
var secrets = acc.secrets || [];
|
||
var secretsBody =
|
||
secrets.length > 0
|
||
? secrets
|
||
.map(function (sec, sidx) {
|
||
return secretRowHtml(sec, acc.id, sidx);
|
||
})
|
||
.join('')
|
||
: '<div class="mnote-vault-muted mnote-vault-slot-empty">暂无密钥,可点 + 密钥 添加</div>';
|
||
// 附录密钥编辑区默认折叠;展开态跨 re-render 保留(+密钥 / 显示密文等)。
|
||
var secretsPanel = renderSecretsPanelHtml({
|
||
accKey: accountUiKey(acc.id, idx),
|
||
title: '附录密钥(API Key / Token)',
|
||
count: secrets.length,
|
||
bodyHtml: secretsBody,
|
||
extraAttrs:
|
||
' data-vault-account-secrets="' + escapeHtml(acc.id) + '"',
|
||
headExtra:
|
||
'<button type="button" data-vault-add-secret-slot data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" data-testid="vault-add-secret-slot">+ 密钥</button>',
|
||
});
|
||
return (
|
||
'<details class="mnote-vault-slot-group" data-vault-account-slot="' +
|
||
escapeHtml(acc.id) +
|
||
'"' +
|
||
open +
|
||
'>' +
|
||
'<summary class="mnote-vault-slot-summary">' +
|
||
'<span class="mnote-vault-slot-title">账号 · ' +
|
||
escapeHtml(title) +
|
||
'</span>' +
|
||
(secrets.length
|
||
? '<span class="mnote-vault-muted">密钥 ' + secrets.length + '</span>'
|
||
: '') +
|
||
'<span class="mnote-vault-slot-actions" onclick="event.preventDefault()">' +
|
||
(state._formAccounts.length > 1
|
||
? '<button type="button" class="is-danger" data-vault-remove-account="' +
|
||
escapeHtml(acc.id) +
|
||
'">删除</button>'
|
||
: '') +
|
||
'</span></summary>' +
|
||
'<div class="mnote-vault-slot-body">' +
|
||
'<div class="mnote-vault-field-row"><label>组标签</label>' +
|
||
'<input type="text" data-acc-field="label" data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" value="' +
|
||
escapeHtml(acc.label) +
|
||
'" placeholder="可选,如 主号 / 工作" /></div>' +
|
||
'<div class="mnote-vault-field-row"><label>用户名(可用 [Key])</label>' +
|
||
'<input type="text" data-acc-field="username" data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" value="' +
|
||
escapeHtml(acc.username) +
|
||
'" placeholder="明文或 [QY] 等密文片段" autocomplete="off" spellcheck="false" /></div>' +
|
||
'<div class="mnote-vault-field-row"><label>邮箱(可用 [Key])</label>' +
|
||
'<input type="text" data-acc-field="email" data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" value="' +
|
||
escapeHtml(acc.email) +
|
||
'" placeholder="明文或 [QY] 等密文片段" autocomplete="off" spellcheck="false" /></div>' +
|
||
'<div class="mnote-vault-field-row"><label>密码(可用 [Key])</label>' +
|
||
'<div class="mnote-vault-secret-edit">' +
|
||
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" class="mnote-vault-secret-input-plain" placeholder="' +
|
||
escapeHtml(
|
||
!isCreate && hasPwd
|
||
? '留空不改;输入新值覆盖'
|
||
: '可选;可用 [Key] 密文片段'
|
||
) +
|
||
'" />' +
|
||
(!isCreate && hasPwd
|
||
? '<button type="button" data-acc-show-password="' +
|
||
escapeHtml(acc.id) +
|
||
'" data-testid="vault-acc-show-password">显示</button>' +
|
||
'<button type="button" data-acc-clear-password="' +
|
||
escapeHtml(acc.id) +
|
||
'">清空</button>'
|
||
: '') +
|
||
'</div></div>' +
|
||
'<div class="mnote-vault-field-row"><label>密码提示</label>' +
|
||
'<input type="text" data-acc-field="passwordHint" data-acc-id="' +
|
||
escapeHtml(acc.id) +
|
||
'" value="' +
|
||
escapeHtml(acc.passwordHint) +
|
||
'" /></div>' +
|
||
secretsPanel +
|
||
'</div></details>'
|
||
);
|
||
}
|
||
|
||
function secretRowHtml(sec, accountId, idx) {
|
||
var kindLabel =
|
||
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
|
||
var title = sec.label || kindLabel + ' ' + (idx + 1);
|
||
var hasVal = sec.valueState && sec.valueState.state === 'masked';
|
||
return (
|
||
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'">' +
|
||
'<div class="mnote-vault-nested-secret-head">' +
|
||
'<span class="mnote-vault-nested-secret-title">' +
|
||
escapeHtml(kindLabel) +
|
||
' · ' +
|
||
escapeHtml(title) +
|
||
'</span>' +
|
||
'<button type="button" class="is-danger" data-vault-remove-secret="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'">删除</button>' +
|
||
'</div>' +
|
||
'<div class="mnote-vault-field-row"><label>类型</label>' +
|
||
'<select data-sec-field="kind" data-sec-id="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'">' +
|
||
'<option value="apikey"' +
|
||
(sec.kind === 'apikey' ? ' selected' : '') +
|
||
'>API Key</option>' +
|
||
'<option value="token"' +
|
||
(sec.kind === 'token' ? ' selected' : '') +
|
||
'>Token</option>' +
|
||
'<option value="other"' +
|
||
(sec.kind === 'other' ? ' selected' : '') +
|
||
'>其他</option>' +
|
||
'</select></div>' +
|
||
'<div class="mnote-vault-field-row"><label>标签</label>' +
|
||
'<input type="text" data-sec-field="label" data-sec-id="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'" value="' +
|
||
escapeHtml(sec.label) +
|
||
'" placeholder="如 Gemini free / App 密码" /></div>' +
|
||
'<div class="mnote-vault-field-row"><label>值</label>' +
|
||
'<div class="mnote-vault-secret-edit">' +
|
||
'<input type="text" autocomplete="off" spellcheck="false" class="mnote-vault-secret-input-plain" data-sec-field="value" data-sec-id="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'" value="" placeholder="' +
|
||
escapeHtml(hasVal ? '留空不改;输入新值覆盖' : '密钥明文') +
|
||
'" />' +
|
||
(hasVal
|
||
? '<button type="button" data-sec-show-value="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'" data-testid="vault-sec-show-value">显示</button>' +
|
||
'<button type="button" data-sec-clear-value="' +
|
||
escapeHtml(sec.id) +
|
||
'" data-acc-id="' +
|
||
escapeHtml(accountId) +
|
||
'">清空</button>'
|
||
: '') +
|
||
'</div></div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
|
||
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>' +
|
||
'<p class="mnote-vault-form-hint">可先点用户名 / 邮箱 / 密码框,再点顶栏「插入密文」选择已有 [Key];也可手写 <code>[Key]</code>。</p>' +
|
||
'<div class="mnote-vault-fields">' +
|
||
formField('title', '标题 *', item.title || '', true) +
|
||
formFolderPathField(item.folderPath || '') +
|
||
urlsEditorHtml() +
|
||
'<div class="mnote-vault-section" data-vault-accounts-section>' +
|
||
'<div class="mnote-vault-section-head">' +
|
||
'<span>登录账号(用户名 / 邮箱 / 密码 / 提示)</span>' +
|
||
'<button type="button" data-vault-add-account-slot data-testid="vault-add-account-slot">+ 账号</button>' +
|
||
'</div>' +
|
||
state._formAccounts.map(accountGroupHtml).join('') +
|
||
'</div>' +
|
||
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) {
|
||
state.lastCipherInsertTarget = null;
|
||
state.lastCipherInsertRange = null;
|
||
bindFolderPathControls(form);
|
||
bindFormCipherInsertTracking(form);
|
||
form.addEventListener('input', function () {
|
||
state.dirty = true;
|
||
});
|
||
form.addEventListener('change', function () {
|
||
state.dirty = true;
|
||
});
|
||
form.addEventListener('submit', function (ev) {
|
||
ev.preventDefault();
|
||
saveForm(form, isCreate);
|
||
});
|
||
// 顶栏「插入密文」在编辑态刷新 key 列表
|
||
fetchCipherKeys({ silent: true })
|
||
.then(function () {
|
||
refreshCipherInsertSelect();
|
||
})
|
||
.catch(function () {
|
||
/* 静默:下拉仍显示已有缓存或空 */
|
||
});
|
||
}
|
||
}
|
||
|
||
function snapshotFormSlots(form) {
|
||
if (!form) return;
|
||
var urlInputs = form.querySelectorAll('[data-vault-url-input]');
|
||
if (urlInputs && urlInputs.length) {
|
||
state._formUrls = [];
|
||
for (var ui = 0; ui < urlInputs.length; ui++) {
|
||
state._formUrls.push(urlInputs[ui].value || '');
|
||
}
|
||
}
|
||
if (!state._formAccounts) return;
|
||
state._formAccounts.forEach(function (acc) {
|
||
var id = acc.id;
|
||
var labelEl = form.querySelector('[data-acc-field="label"][data-acc-id="' + id + '"]');
|
||
var userEl = form.querySelector('[data-acc-field="username"][data-acc-id="' + id + '"]');
|
||
var emailEl = form.querySelector('[data-acc-field="email"][data-acc-id="' + id + '"]');
|
||
var hintEl = form.querySelector(
|
||
'[data-acc-field="passwordHint"][data-acc-id="' + id + '"]'
|
||
);
|
||
var pwdEl = form.querySelector('[data-acc-field="password"][data-acc-id="' + id + '"]');
|
||
if (labelEl) acc.label = labelEl.value;
|
||
if (userEl) acc.username = userEl.value;
|
||
if (emailEl) acc.email = emailEl.value;
|
||
if (hintEl) acc.passwordHint = hintEl.value;
|
||
if (pwdEl) acc.passwordValue = pwdEl.value;
|
||
(acc.secrets || []).forEach(function (sec) {
|
||
var sid = sec.id;
|
||
var kindEl = form.querySelector(
|
||
'[data-sec-field="kind"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
||
);
|
||
var secLabelEl = form.querySelector(
|
||
'[data-sec-field="label"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
||
);
|
||
var valEl = form.querySelector(
|
||
'[data-sec-field="value"][data-sec-id="' + sid + '"][data-acc-id="' + id + '"]'
|
||
);
|
||
if (kindEl) sec.kind = kindEl.value;
|
||
if (secLabelEl) sec.label = secLabelEl.value;
|
||
if (valEl) sec.valueText = valEl.value;
|
||
sec.accountId = id;
|
||
});
|
||
});
|
||
state._formSecrets = [];
|
||
}
|
||
|
||
function collectFormUrls(form) {
|
||
var raw = [];
|
||
if (form) {
|
||
var inputs = form.querySelectorAll('[data-vault-url-input]');
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
raw.push(String(inputs[i].value || '').trim());
|
||
}
|
||
} else if (state._formUrls) {
|
||
raw = state._formUrls.map(function (u) {
|
||
return String(u || '').trim();
|
||
});
|
||
}
|
||
var out = [];
|
||
raw.forEach(function (t) {
|
||
if (!t) return;
|
||
if (out.indexOf(t) >= 0) return;
|
||
out.push(t);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function reRenderFormKeepingSlots(isCreate) {
|
||
var form = qs('[data-vault-form]', detailEl);
|
||
if (form) snapshotFormSlots(form);
|
||
var base = isCreate
|
||
? emptyForm(state._createDefaults || {})
|
||
: state.item || emptyForm();
|
||
// inject draft slots into a pseudo item for normalize
|
||
base = Object.assign({}, base, {
|
||
urls: (state._formUrls || []).slice(),
|
||
url: (state._formUrls && state._formUrls[0]) || base.url || '',
|
||
accounts: (state._formAccounts || []).map(function (a) {
|
||
return {
|
||
id: a.id,
|
||
label: a.label,
|
||
username: a.username,
|
||
email: a.email,
|
||
passwordHint: a.passwordHint,
|
||
password: a.passwordClear
|
||
? { state: 'absent' }
|
||
: a.passwordValue
|
||
? { state: 'revealed', value: a.passwordValue }
|
||
: a.passwordState,
|
||
secrets: (a.secrets || []).map(function (s) {
|
||
return {
|
||
id: s.id,
|
||
kind: s.kind,
|
||
label: s.label,
|
||
accountId: a.id,
|
||
value: s.valueClear
|
||
? { state: 'absent' }
|
||
: s.valueText
|
||
? { state: 'revealed', value: s.valueText }
|
||
: s.valueState,
|
||
};
|
||
}),
|
||
};
|
||
}),
|
||
secrets: [],
|
||
});
|
||
// preserve title/url etc from form
|
||
if (form) {
|
||
var fd = new FormData(form);
|
||
base.title = String(fd.get('title') || base.title || '');
|
||
var draftUrls = collectFormUrls(form);
|
||
if (draftUrls.length || (state._formUrls && state._formUrls.length)) {
|
||
// Keep empty draft rows while editing multi-url so re-render doesn't drop blanks mid-type.
|
||
base.urls = (state._formUrls || draftUrls).slice();
|
||
base.url = draftUrls[0] || base.urls[0] || '';
|
||
}
|
||
base.folderPath = String(fd.get('folderPath') || base.folderPath || '');
|
||
base.tags = String(fd.get('tags') || '')
|
||
.split(/[,,;;\n]/)
|
||
.map(function (s) {
|
||
return s.trim();
|
||
})
|
||
.filter(Boolean);
|
||
base.notesMarkdown = String(fd.get('notesMarkdown') || base.notesMarkdown || '');
|
||
}
|
||
renderForm(base, isCreate);
|
||
// restore typed secret values after re-render
|
||
var form2 = qs('[data-vault-form]', detailEl);
|
||
if (!form2) return;
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
var pwdEl = form2.querySelector(
|
||
'[data-acc-field="password"][data-acc-id="' + acc.id + '"]'
|
||
);
|
||
if (pwdEl && acc.passwordValue) pwdEl.value = acc.passwordValue;
|
||
var showPwdBtn = form2.querySelector(
|
||
'[data-acc-show-password="' + acc.id + '"]'
|
||
);
|
||
if (showPwdBtn && acc.passwordValue && !acc.passwordClear) {
|
||
showPwdBtn.setAttribute('data-shown', '1');
|
||
showPwdBtn.textContent = '隐藏';
|
||
}
|
||
});
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (sec) {
|
||
var valEl = form2.querySelector(
|
||
'[data-sec-field="value"][data-sec-id="' +
|
||
sec.id +
|
||
'"][data-acc-id="' +
|
||
acc.id +
|
||
'"]'
|
||
);
|
||
if (valEl && sec.valueText) valEl.value = sec.valueText;
|
||
var showSecBtn = form2.querySelector(
|
||
'[data-sec-show-value="' + sec.id + '"]'
|
||
);
|
||
if (showSecBtn && sec.valueText && !sec.valueClear) {
|
||
showSecBtn.setAttribute('data-shown', '1');
|
||
showSecBtn.textContent = '隐藏';
|
||
}
|
||
});
|
||
});
|
||
state.dirty = true;
|
||
}
|
||
|
||
function formField(name, label, value, required) {
|
||
return (
|
||
'<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 };
|
||
// 切换条目时重置附录密钥展开态(同一条目内 reveal 仍保留)。
|
||
ensureFormUi().expandedSecretPanels = {};
|
||
renderList();
|
||
renderDetail();
|
||
} catch (err) {
|
||
setStatus(err.message || String(err), 'error');
|
||
toast(err.message || String(err), 'error');
|
||
}
|
||
}
|
||
|
||
async function saveForm(form, isCreate) {
|
||
snapshotFormSlots(form);
|
||
var fd = new FormData(form);
|
||
var title = String(fd.get('title') || '').trim();
|
||
if (!title) {
|
||
toast('标题不能为空', 'error');
|
||
return;
|
||
}
|
||
var tagsRaw = String(fd.get('tags') || '');
|
||
var tags = tagsRaw
|
||
.split(/[,,;;\n]/)
|
||
.map(function (s) {
|
||
return s.trim();
|
||
})
|
||
.filter(Boolean);
|
||
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
|
||
var accounts = (state._formAccounts || [])
|
||
.map(function (acc) {
|
||
var raw = String(acc.passwordValue || '');
|
||
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
|
||
toast('不能将掩码写回 password', 'error');
|
||
throw new Error('mask');
|
||
}
|
||
var nestedSecrets = (acc.secrets || []).map(function (sec) {
|
||
var sraw = String(sec.valueText || '');
|
||
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
|
||
toast('不能将掩码写回 secret', 'error');
|
||
throw new Error('mask');
|
||
}
|
||
var sslot = {
|
||
id: sec.id || undefined,
|
||
kind: sec.kind || 'apikey',
|
||
label: String(sec.label || '').trim() || null,
|
||
accountId: acc.id || null,
|
||
};
|
||
if (isCreate) {
|
||
if (sraw) sslot.value = sraw;
|
||
} else if (sec.valueClear) {
|
||
sslot.value = { clear: true };
|
||
} else if (sraw) {
|
||
sslot.value = { set: sraw };
|
||
} else if (sec.valueState && sec.valueState.state === 'masked') {
|
||
sslot._keep = true;
|
||
}
|
||
return sslot;
|
||
});
|
||
var slot = {
|
||
id: acc.id && String(acc.id).indexOf('acc_') === 0 ? acc.id : acc.id || undefined,
|
||
label: String(acc.label || '').trim() || null,
|
||
username: String(acc.username || '').trim() || null,
|
||
email: String(acc.email || '').trim() || null,
|
||
passwordHint: String(acc.passwordHint || '').trim() || null,
|
||
secrets: nestedSecrets,
|
||
};
|
||
if (isCreate) {
|
||
if (raw) slot.password = raw;
|
||
} else if (acc.passwordClear) {
|
||
slot.password = { clear: true };
|
||
} else if (raw) {
|
||
slot.password = { set: raw };
|
||
}
|
||
return slot;
|
||
})
|
||
.filter(function (slot) {
|
||
return (
|
||
slot.label ||
|
||
slot.username ||
|
||
slot.email ||
|
||
slot.passwordHint ||
|
||
slot.password ||
|
||
(slot.secrets && slot.secrets.length)
|
||
);
|
||
});
|
||
// Flatten nested secrets for top-level API field (compat + server rebuild).
|
||
var secrets = [];
|
||
accounts.forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
secrets.push(s);
|
||
});
|
||
});
|
||
|
||
// Edit full-replace: masked fields that were not retyped carry `_keep`.
|
||
// Do NOT reveal them client-side (avoid race / drop). Strip `_keep` and omit
|
||
// value/password; server merges previous secrets by id. Explicit clear uses
|
||
// `{ clear: true }` → empty string on the wire and is not restored.
|
||
function stripKeepMarkers(list) {
|
||
return (list || []).map(function (s) {
|
||
var copy = Object.assign({}, s);
|
||
delete copy._keep;
|
||
return copy;
|
||
});
|
||
}
|
||
|
||
if (!isCreate) {
|
||
// accounts keep password when not retyped and not cleared
|
||
for (var ai = 0; ai < accounts.length; ai++) {
|
||
var a = accounts[ai];
|
||
if (!a.password && a.id) {
|
||
var draftA = (state._formAccounts || []).find(function (x) {
|
||
return x.id === a.id;
|
||
});
|
||
if (
|
||
draftA &&
|
||
!draftA.passwordClear &&
|
||
draftA.passwordState &&
|
||
draftA.passwordState.state === 'masked'
|
||
) {
|
||
a._keep = true;
|
||
}
|
||
}
|
||
}
|
||
accounts = stripKeepMarkers(accounts);
|
||
accounts.forEach(function (acc) {
|
||
if (acc.secrets && acc.secrets.length) {
|
||
acc.secrets = stripKeepMarkers(acc.secrets);
|
||
}
|
||
});
|
||
secrets = [];
|
||
accounts.forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
secrets.push(s);
|
||
});
|
||
});
|
||
} else {
|
||
// create: drop empty secret shells
|
||
accounts.forEach(function (acc) {
|
||
acc.secrets = (acc.secrets || []).filter(function (s) {
|
||
return s.value || s.label;
|
||
});
|
||
});
|
||
secrets = [];
|
||
accounts.forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
secrets.push(s);
|
||
});
|
||
});
|
||
}
|
||
|
||
var urls = collectFormUrls(form);
|
||
var body = {
|
||
title: title,
|
||
url: urls[0] || null,
|
||
urls: urls,
|
||
folderPath: folderPath || null,
|
||
tags: tags,
|
||
notesMarkdown: String(fd.get('notesMarkdown') || ''),
|
||
accounts: accounts,
|
||
secrets: secrets,
|
||
syncAiShare: true,
|
||
};
|
||
|
||
try {
|
||
var result;
|
||
if (isCreate) {
|
||
result = await apiJson('POST', '/api/vault/items', state.rootUri, body);
|
||
} else {
|
||
body.baseRevision = state.item && state.item.revision;
|
||
result = await apiJson(
|
||
'PATCH',
|
||
'/api/vault/items/' + encodeURIComponent(state.selectedId),
|
||
state.rootUri,
|
||
body
|
||
);
|
||
}
|
||
state.item = result.item || result;
|
||
state.selectedId = state.item.id;
|
||
state.mode = 'view';
|
||
state.dirty = false;
|
||
state.secretDraft = { password: null, apikey: null, token: null };
|
||
state._createDefaults = null;
|
||
var msg = isCreate ? '已创建' : '已保存';
|
||
var aiSync = result && result.aiShareSync;
|
||
if (aiSync && aiSync.ok) {
|
||
msg += ' · 已同步 AI 副本';
|
||
} else if (aiSync && aiSync.ok === false) {
|
||
msg += ' · AI 同步未完成(' + (aiSync.message || aiSync.code || '') + ')';
|
||
}
|
||
toast(msg, aiSync && aiSync.ok === false ? 'info' : 'success');
|
||
setStatus(msg, 'success');
|
||
await loadList();
|
||
await loadItem(state.selectedId);
|
||
} catch (err) {
|
||
if (err.message === 'mask') return;
|
||
setStatus(err.message || String(err), 'error');
|
||
toast(err.message || String(err), 'error');
|
||
}
|
||
}
|
||
|
||
/** 仅拉取密文 key 列表(L0,无明文),供编辑表单「插入密文」使用。 */
|
||
async function fetchCipherKeys(options) {
|
||
var opts = options || {};
|
||
try {
|
||
var result = await apiJson('GET', '/api/vault/cipher-book', state.rootUri);
|
||
state.cipherKeys = (result && result.keys) || [];
|
||
if (opts.resetRevealed) state.cipherRevealed = {};
|
||
if (state.mode === 'create' || state.mode === 'edit') {
|
||
refreshCipherInsertSelect();
|
||
}
|
||
return state.cipherKeys;
|
||
} catch (err) {
|
||
if (!opts.silent) {
|
||
setStatus(err.message || String(err), 'error');
|
||
toast(err.message || String(err), 'error');
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function loadCipherBook() {
|
||
try {
|
||
await fetchCipherKeys({ resetRevealed: true });
|
||
renderCipherBook();
|
||
setStatus('密文簿 ' + state.cipherKeys.length + ' 个 key', 'info');
|
||
} catch (err) {
|
||
// fetchCipherKeys 已 toast
|
||
}
|
||
}
|
||
|
||
/** 判断输入是否支持插入 [Key] 密文片段(用户名/邮箱/密码/附录密钥值)。 */
|
||
function isCipherInsertTarget(el) {
|
||
if (!el || !(el instanceof HTMLElement)) return false;
|
||
if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') return false;
|
||
if (el.disabled || el.readOnly) return false;
|
||
var acc = el.getAttribute('data-acc-field');
|
||
if (acc === 'username' || acc === 'email' || acc === 'password') return true;
|
||
if (el.getAttribute('data-sec-field') === 'value') return true;
|
||
return false;
|
||
}
|
||
|
||
function rememberCipherInsertTarget(el) {
|
||
if (isCipherInsertTarget(el)) {
|
||
state.lastCipherInsertTarget = el;
|
||
try {
|
||
if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') {
|
||
state.lastCipherInsertRange = {
|
||
start: el.selectionStart,
|
||
end: el.selectionEnd,
|
||
};
|
||
}
|
||
} catch (_e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
|
||
function captureCipherInsertRange(el) {
|
||
if (!isCipherInsertTarget(el)) return;
|
||
state.lastCipherInsertTarget = el;
|
||
try {
|
||
if (typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number') {
|
||
state.lastCipherInsertRange = {
|
||
start: el.selectionStart,
|
||
end: el.selectionEnd,
|
||
};
|
||
}
|
||
} catch (_e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function resolveCipherInsertTarget(form) {
|
||
var active = document.activeElement;
|
||
if (form && active && form.contains(active) && isCipherInsertTarget(active)) {
|
||
captureCipherInsertRange(active);
|
||
return active;
|
||
}
|
||
var remembered = state.lastCipherInsertTarget;
|
||
if (
|
||
remembered &&
|
||
form &&
|
||
form.contains(remembered) &&
|
||
isCipherInsertTarget(remembered)
|
||
) {
|
||
return remembered;
|
||
}
|
||
// 回退:第一个密码框 → 用户名 → 邮箱 → 密钥值
|
||
if (!form) return null;
|
||
state.lastCipherInsertRange = null;
|
||
return (
|
||
form.querySelector('[data-acc-field="password"]') ||
|
||
form.querySelector('[data-acc-field="username"]') ||
|
||
form.querySelector('[data-acc-field="email"]') ||
|
||
form.querySelector('[data-sec-field="value"]')
|
||
);
|
||
}
|
||
|
||
/** 在光标处插入 `[Key]`;不改服务端解析规则。 */
|
||
function insertCipherKeyIntoField(input, key) {
|
||
if (!input || !key) return false;
|
||
var token = '[' + key + ']';
|
||
var start;
|
||
var end;
|
||
var range = state.lastCipherInsertRange;
|
||
if (
|
||
range &&
|
||
state.lastCipherInsertTarget === input &&
|
||
typeof range.start === 'number' &&
|
||
typeof range.end === 'number'
|
||
) {
|
||
start = range.start;
|
||
end = range.end;
|
||
} else {
|
||
start =
|
||
typeof input.selectionStart === 'number'
|
||
? input.selectionStart
|
||
: input.value.length;
|
||
end =
|
||
typeof input.selectionEnd === 'number' ? input.selectionEnd : input.value.length;
|
||
}
|
||
// 钳制到当前值长度(输入可能在失焦后被改过)
|
||
var len = input.value.length;
|
||
if (start < 0) start = 0;
|
||
if (end < 0) end = 0;
|
||
if (start > len) start = len;
|
||
if (end > len) end = len;
|
||
if (end < start) end = start;
|
||
var before = input.value.slice(0, start);
|
||
var after = input.value.slice(end);
|
||
input.value = before + token + after;
|
||
var caret = start + token.length;
|
||
try {
|
||
input.focus();
|
||
input.setSelectionRange(caret, caret);
|
||
} catch (_e) {
|
||
/* ignore non-text inputs */
|
||
}
|
||
state.dirty = true;
|
||
state.lastCipherInsertTarget = input;
|
||
state.lastCipherInsertRange = { start: caret, end: caret };
|
||
// 触发已绑定的 input 监听
|
||
try {
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
} catch (_e2) {
|
||
/* older engines */
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** 顶栏「插入密文」options HTML(与密文簿 key 列表同步)。 */
|
||
function cipherInsertOptionsHtml() {
|
||
var keys = state.cipherKeys || [];
|
||
return (
|
||
'<option value="">插入密文…</option>' +
|
||
keys
|
||
.map(function (k) {
|
||
var key = (k && k.key) || k;
|
||
if (!key) return '';
|
||
return (
|
||
'<option value="' +
|
||
escapeHtml(key) +
|
||
'">[' +
|
||
escapeHtml(key) +
|
||
']</option>'
|
||
);
|
||
})
|
||
.join('')
|
||
);
|
||
}
|
||
|
||
function headerCipherInsertSelect() {
|
||
return qs('[data-vault-insert-cipher]', root);
|
||
}
|
||
|
||
function refreshCipherInsertSelect() {
|
||
var sel = headerCipherInsertSelect();
|
||
if (!sel) return;
|
||
sel.innerHTML = cipherInsertOptionsHtml();
|
||
sel.value = '';
|
||
}
|
||
|
||
/**
|
||
* 仅在新建/编辑表单时显示顶栏「插入密文」;
|
||
* 放在密文簿旁,详情区滚动时仍可见。
|
||
*/
|
||
function syncHeaderCipherInsertVisibility() {
|
||
var wrap = qs('.mnote-vault-insert-cipher', root);
|
||
if (!wrap) return;
|
||
var editing = state.mode === 'create' || state.mode === 'edit';
|
||
wrap.hidden = !editing;
|
||
wrap.setAttribute('aria-hidden', editing ? 'false' : 'true');
|
||
if (editing) refreshCipherInsertSelect();
|
||
}
|
||
|
||
/** 表单内记录焦点/光标,供顶栏下拉插入使用。 */
|
||
function bindFormCipherInsertTracking(form) {
|
||
if (!form || form.getAttribute('data-cipher-insert-bound') === '1') return;
|
||
form.setAttribute('data-cipher-insert-bound', '1');
|
||
form.addEventListener('focusin', function (ev) {
|
||
var t = ev.target;
|
||
if (t instanceof HTMLElement) rememberCipherInsertTarget(t);
|
||
});
|
||
form.addEventListener('focusout', function (ev) {
|
||
var t = ev.target;
|
||
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
||
});
|
||
form.addEventListener('mouseup', function (ev) {
|
||
var t = ev.target;
|
||
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
||
});
|
||
form.addEventListener('keyup', function (ev) {
|
||
var t = ev.target;
|
||
if (t instanceof HTMLElement) captureCipherInsertRange(t);
|
||
});
|
||
}
|
||
|
||
/** 顶栏「插入密文」下拉:绑定一次,插入目标为当前编辑表单字段。 */
|
||
function bindHeaderCipherInsertControls() {
|
||
if (root.getAttribute('data-cipher-insert-header-bound') === '1') return;
|
||
root.setAttribute('data-cipher-insert-header-bound', '1');
|
||
var sel = headerCipherInsertSelect();
|
||
if (!sel) return;
|
||
sel.addEventListener('change', function () {
|
||
var key = String(sel.value || '').trim();
|
||
sel.value = '';
|
||
if (!key) return;
|
||
var form = qs('[data-vault-form]', detailEl);
|
||
if (!form || (state.mode !== 'create' && state.mode !== 'edit')) {
|
||
toast('请先新建或编辑条目', 'info');
|
||
return;
|
||
}
|
||
var target = resolveCipherInsertTarget(form);
|
||
if (!target) {
|
||
toast('请先点击用户名 / 邮箱 / 密码 / 密钥值输入框', 'info');
|
||
return;
|
||
}
|
||
if (insertCipherKeyIntoField(target, key)) {
|
||
toast('已插入 [' + key + ']', 'success');
|
||
}
|
||
});
|
||
// 失焦前点下拉时仍能记住光标:mousedown 捕获阶段
|
||
sel.addEventListener('mousedown', function () {
|
||
var form = qs('[data-vault-form]', detailEl);
|
||
if (!form) return;
|
||
var active = document.activeElement;
|
||
if (active && form.contains(active) && active instanceof HTMLElement) {
|
||
captureCipherInsertRange(active);
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderCipherBook() {
|
||
if (!detailEl) return;
|
||
var rows = state.cipherKeys
|
||
.map(function (k) {
|
||
var key = k.key;
|
||
var revealed = state.cipherRevealed[key];
|
||
return (
|
||
'<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_]+" autocomplete="off" spellcheck="false" />' +
|
||
'<input name="value" type="text" class="mnote-vault-secret-input-plain" placeholder="片段明文(输入可见)" required autocomplete="off" spellcheck="false" />' +
|
||
'<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') || '';
|
||
toggleCollapseKey(fpath);
|
||
renderList();
|
||
return;
|
||
}
|
||
|
||
var itemBtn = t.closest('[data-vault-item-id]');
|
||
if (itemBtn && root.contains(itemBtn)) {
|
||
var id = itemBtn.getAttribute('data-vault-item-id');
|
||
if (!confirmDiscard()) return;
|
||
state.mode = 'view';
|
||
loadItem(id);
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-create], [data-vault-new]')) {
|
||
if (!confirmDiscard()) return;
|
||
state.mode = 'create';
|
||
state.selectedId = '';
|
||
state.item = null;
|
||
state._createDefaults = null;
|
||
state.dirty = false;
|
||
state.secretDraft = { password: null, apikey: null, token: null };
|
||
renderList();
|
||
renderDetail();
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-toggle-empty-fields]')) {
|
||
state.showEmptyFields = !state.showEmptyFields;
|
||
if (state.item && state.mode === 'view') renderView(state.item);
|
||
return;
|
||
}
|
||
|
||
// 附录密钥折叠/展开(详情与编辑共用);写入 state,避免 reveal 重绘后丢失。
|
||
var secretsToggle = t.closest('[data-vault-toggle-secrets]');
|
||
if (secretsToggle && root.contains(secretsToggle)) {
|
||
var panel =
|
||
secretsToggle.closest('[data-vault-secrets-panel]') ||
|
||
secretsToggle.closest('[data-vault-account-secrets]');
|
||
if (panel) {
|
||
var bodyEl = panel.querySelector('.mnote-vault-account-secrets-body');
|
||
var collapsed = panel.classList.contains('is-collapsed');
|
||
var nextExpanded = !!collapsed;
|
||
var accKey =
|
||
panel.getAttribute('data-vault-secrets-acc') ||
|
||
panel.getAttribute('data-vault-account-secrets') ||
|
||
'default';
|
||
setSecretsPanelExpanded(accKey, nextExpanded);
|
||
if (nextExpanded) {
|
||
panel.classList.remove('is-collapsed');
|
||
if (bodyEl) bodyEl.hidden = false;
|
||
secretsToggle.setAttribute('aria-expanded', 'true');
|
||
var chevOpen = secretsToggle.querySelector('.mnote-vault-secrets-chevron');
|
||
if (chevOpen) chevOpen.textContent = '▼';
|
||
} else {
|
||
panel.classList.add('is-collapsed');
|
||
if (bodyEl) bodyEl.hidden = true;
|
||
secretsToggle.setAttribute('aria-expanded', 'false');
|
||
var chevClose = secretsToggle.querySelector('.mnote-vault-secrets-chevron');
|
||
if (chevClose) chevClose.textContent = '▶';
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-add-url]')) {
|
||
var formAddUrl = qs('[data-vault-form]', detailEl);
|
||
if (formAddUrl) snapshotFormSlots(formAddUrl);
|
||
state._formUrls = state._formUrls || [''];
|
||
state._formUrls.push('');
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
var removeUrl = t.closest('[data-vault-remove-url]');
|
||
if (removeUrl) {
|
||
var urlIdx = parseInt(removeUrl.getAttribute('data-vault-remove-url'), 10);
|
||
var formRmUrl = qs('[data-vault-form]', detailEl);
|
||
if (formRmUrl) snapshotFormSlots(formRmUrl);
|
||
state._formUrls = (state._formUrls || []).filter(function (_u, i) {
|
||
return i !== urlIdx;
|
||
});
|
||
if (!state._formUrls.length) state._formUrls = [''];
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-add-account-slot]')) {
|
||
var formAddAcc = qs('[data-vault-form]', detailEl);
|
||
if (formAddAcc) snapshotFormSlots(formAddAcc);
|
||
state._formAccounts = state._formAccounts || [];
|
||
state._formAccounts.push({
|
||
id: newClientId('acc'),
|
||
label: '',
|
||
username: '',
|
||
email: '',
|
||
passwordHint: '',
|
||
passwordState: { state: 'absent' },
|
||
passwordClear: false,
|
||
passwordValue: '',
|
||
secrets: [],
|
||
});
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
var removeAcc = t.closest('[data-vault-remove-account]');
|
||
if (removeAcc) {
|
||
var rid = removeAcc.getAttribute('data-vault-remove-account');
|
||
var formRmAcc = qs('[data-vault-form]', detailEl);
|
||
if (formRmAcc) snapshotFormSlots(formRmAcc);
|
||
state._formAccounts = (state._formAccounts || []).filter(function (a) {
|
||
return a.id !== rid;
|
||
});
|
||
if (!state._formAccounts.length) {
|
||
state._formAccounts.push({
|
||
id: newClientId('acc'),
|
||
label: '',
|
||
username: '',
|
||
email: '',
|
||
passwordHint: '',
|
||
passwordState: { state: 'absent' },
|
||
passwordClear: false,
|
||
passwordValue: '',
|
||
secrets: [],
|
||
});
|
||
}
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
var addSecBtn = t.closest('[data-vault-add-secret-slot]');
|
||
if (addSecBtn) {
|
||
var formAddSec = qs('[data-vault-form]', detailEl);
|
||
if (formAddSec) snapshotFormSlots(formAddSec);
|
||
var accIdForSec =
|
||
addSecBtn.getAttribute('data-acc-id') ||
|
||
(addSecBtn.closest('[data-vault-account-slot]') &&
|
||
addSecBtn.closest('[data-vault-account-slot]').getAttribute('data-vault-account-slot'));
|
||
var targetAcc = (state._formAccounts || []).find(function (a) {
|
||
return a.id === accIdForSec;
|
||
});
|
||
if (!targetAcc && state._formAccounts && state._formAccounts.length) {
|
||
targetAcc = state._formAccounts[0];
|
||
}
|
||
if (targetAcc) {
|
||
targetAcc.secrets = targetAcc.secrets || [];
|
||
targetAcc.secrets.push({
|
||
id: newClientId('sec'),
|
||
kind: 'apikey',
|
||
label: '',
|
||
valueState: { state: 'absent' },
|
||
valueClear: false,
|
||
valueText: '',
|
||
accountId: targetAcc.id,
|
||
});
|
||
}
|
||
// 添加密钥后展开对应附录密钥面板(写入 state,re-render 后仍展开)。
|
||
if (targetAcc) {
|
||
setSecretsPanelExpanded(accountUiKey(targetAcc.id), true);
|
||
} else if (accIdForSec) {
|
||
setSecretsPanelExpanded(accountUiKey(accIdForSec), true);
|
||
}
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
var removeSec = t.closest('[data-vault-remove-secret]');
|
||
if (removeSec) {
|
||
var rsid = removeSec.getAttribute('data-vault-remove-secret');
|
||
var rAccId = removeSec.getAttribute('data-acc-id');
|
||
var formRmSec = qs('[data-vault-form]', detailEl);
|
||
if (formRmSec) snapshotFormSlots(formRmSec);
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
if (rAccId && acc.id !== rAccId) return;
|
||
acc.secrets = (acc.secrets || []).filter(function (s) {
|
||
return s.id !== rsid;
|
||
});
|
||
});
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
return;
|
||
}
|
||
var showAccPwd = t.closest('[data-acc-show-password]');
|
||
if (showAccPwd) {
|
||
var said = showAccPwd.getAttribute('data-acc-show-password');
|
||
var pwdShowInput = detailEl.querySelector(
|
||
'[data-acc-field="password"][data-acc-id="' + said + '"]'
|
||
);
|
||
// 已显示则收起:清空输入框(留空=保存时不改),不触发 passwordClear
|
||
if (showAccPwd.getAttribute('data-shown') === '1') {
|
||
if (pwdShowInput) {
|
||
pwdShowInput.value = '';
|
||
pwdShowInput.placeholder = '留空不改;输入新值覆盖';
|
||
}
|
||
(state._formAccounts || []).forEach(function (a) {
|
||
if (a.id === said) {
|
||
a.passwordValue = '';
|
||
a.passwordClear = false;
|
||
}
|
||
});
|
||
showAccPwd.setAttribute('data-shown', '0');
|
||
showAccPwd.textContent = '显示';
|
||
return;
|
||
}
|
||
if (!state.selectedId) {
|
||
toast('请先保存条目后再查看已存密码', 'info');
|
||
return;
|
||
}
|
||
showAccPwd.disabled = true;
|
||
var revealPwdBody = { field: 'password' };
|
||
if (said) revealPwdBody.accountId = said;
|
||
apiJson(
|
||
'POST',
|
||
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
||
state.rootUri,
|
||
revealPwdBody
|
||
)
|
||
.then(function (result) {
|
||
var secret = result && result.secret;
|
||
showAccPwd.disabled = false;
|
||
if (!secret || secret.state !== 'revealed') {
|
||
toast('该账号未设置密码', 'info');
|
||
return;
|
||
}
|
||
// 编辑优先写回 [Key] 模板,避免把展开明文误存为新值
|
||
var editVal =
|
||
secret.template != null && secret.template !== ''
|
||
? String(secret.template)
|
||
: String(secret.value || '');
|
||
if (pwdShowInput) {
|
||
pwdShowInput.value = editVal;
|
||
pwdShowInput.focus();
|
||
try {
|
||
var len = editVal.length;
|
||
pwdShowInput.setSelectionRange(len, len);
|
||
} catch (_e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
(state._formAccounts || []).forEach(function (a) {
|
||
if (a.id === said) {
|
||
a.passwordValue = editVal;
|
||
a.passwordClear = false;
|
||
}
|
||
});
|
||
state.dirty = true;
|
||
showAccPwd.setAttribute('data-shown', '1');
|
||
showAccPwd.textContent = '隐藏';
|
||
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
||
toast(
|
||
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
||
'info'
|
||
);
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
showAccPwd.disabled = false;
|
||
toast(err.message || String(err), 'error');
|
||
});
|
||
return;
|
||
}
|
||
var clearAccPwd = t.closest('[data-acc-clear-password]');
|
||
if (clearAccPwd) {
|
||
var caid = clearAccPwd.getAttribute('data-acc-clear-password');
|
||
(state._formAccounts || []).forEach(function (a) {
|
||
if (a.id === caid) {
|
||
a.passwordClear = true;
|
||
a.passwordValue = '';
|
||
a.passwordState = { state: 'absent' };
|
||
}
|
||
});
|
||
var pwdInput = detailEl.querySelector(
|
||
'[data-acc-field="password"][data-acc-id="' + caid + '"]'
|
||
);
|
||
if (pwdInput) {
|
||
pwdInput.value = '';
|
||
pwdInput.placeholder = '(将清空)';
|
||
}
|
||
var showBtnAfterClear = detailEl.querySelector(
|
||
'[data-acc-show-password="' + caid + '"]'
|
||
);
|
||
if (showBtnAfterClear) {
|
||
showBtnAfterClear.setAttribute('data-shown', '0');
|
||
showBtnAfterClear.textContent = '显示';
|
||
}
|
||
state.dirty = true;
|
||
toast('该账号密码将在保存时清空', 'info');
|
||
return;
|
||
}
|
||
var showSecVal = t.closest('[data-sec-show-value]');
|
||
if (showSecVal) {
|
||
var ssid = showSecVal.getAttribute('data-sec-show-value');
|
||
var secShowInput = detailEl.querySelector(
|
||
'[data-sec-field="value"][data-sec-id="' + ssid + '"]'
|
||
);
|
||
if (showSecVal.getAttribute('data-shown') === '1') {
|
||
if (secShowInput) {
|
||
secShowInput.value = '';
|
||
secShowInput.placeholder = '留空不改;输入新值覆盖';
|
||
}
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
if (s.id === ssid) {
|
||
s.valueText = '';
|
||
s.valueClear = false;
|
||
}
|
||
});
|
||
});
|
||
showSecVal.setAttribute('data-shown', '0');
|
||
showSecVal.textContent = '显示';
|
||
return;
|
||
}
|
||
if (!state.selectedId) {
|
||
toast('请先保存条目后再查看已存密钥', 'info');
|
||
return;
|
||
}
|
||
showSecVal.disabled = true;
|
||
apiJson(
|
||
'POST',
|
||
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
||
state.rootUri,
|
||
{ secretId: ssid }
|
||
)
|
||
.then(function (result) {
|
||
var secret = result && result.secret;
|
||
showSecVal.disabled = false;
|
||
if (!secret || secret.state !== 'revealed') {
|
||
toast('该密钥未设置', 'info');
|
||
return;
|
||
}
|
||
var editVal =
|
||
secret.template != null && secret.template !== ''
|
||
? String(secret.template)
|
||
: String(secret.value || '');
|
||
if (secShowInput) {
|
||
secShowInput.value = editVal;
|
||
secShowInput.focus();
|
||
}
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
if (s.id === ssid) {
|
||
s.valueText = editVal;
|
||
s.valueClear = false;
|
||
}
|
||
});
|
||
});
|
||
state.dirty = true;
|
||
showSecVal.setAttribute('data-shown', '1');
|
||
showSecVal.textContent = '隐藏';
|
||
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
||
toast(
|
||
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
||
'info'
|
||
);
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
showSecVal.disabled = false;
|
||
toast(err.message || String(err), 'error');
|
||
});
|
||
return;
|
||
}
|
||
var clearSecVal = t.closest('[data-sec-clear-value]');
|
||
if (clearSecVal) {
|
||
var csid = clearSecVal.getAttribute('data-sec-clear-value');
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
(acc.secrets || []).forEach(function (s) {
|
||
if (s.id === csid) {
|
||
s.valueClear = true;
|
||
s.valueText = '';
|
||
s.valueState = { state: 'absent' };
|
||
}
|
||
});
|
||
});
|
||
var secInput = detailEl.querySelector(
|
||
'[data-sec-field="value"][data-sec-id="' + csid + '"]'
|
||
);
|
||
if (secInput) {
|
||
secInput.value = '';
|
||
secInput.placeholder = '(将清空)';
|
||
}
|
||
var showSecAfterClear = detailEl.querySelector(
|
||
'[data-sec-show-value="' + csid + '"]'
|
||
);
|
||
if (showSecAfterClear) {
|
||
showSecAfterClear.setAttribute('data-shown', '0');
|
||
showSecAfterClear.textContent = '显示';
|
||
}
|
||
state.dirty = true;
|
||
toast('该密钥将在保存时清空', 'info');
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-share-ai]')) {
|
||
if (!state.selectedId || !state.rootUri) return;
|
||
if (state.isAiVault) {
|
||
toast('当前已是 AI 密码本,无需再共享', 'info');
|
||
return;
|
||
}
|
||
var shareTitle =
|
||
(state.item && state.item.title) || state.selectedId;
|
||
var alreadyShared = itemIsSharedToAi(state.item);
|
||
if (
|
||
!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 clearSecVal = t.closest('[data-vault-clear-sec-value]');
|
||
if (clearSecVal) {
|
||
var csid = clearSecVal.getAttribute('data-vault-clear-sec-value');
|
||
var csAcc = clearSecVal.getAttribute('data-acc-id');
|
||
var formCs = qs('[data-vault-form]', detailEl);
|
||
if (formCs) snapshotFormSlots(formCs);
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
if (csAcc && acc.id !== csAcc) return;
|
||
(acc.secrets || []).forEach(function (sec) {
|
||
if (sec.id !== csid) return;
|
||
sec.valueClear = true;
|
||
sec.valueState = { state: 'absent' };
|
||
sec.valueText = '';
|
||
});
|
||
});
|
||
state.dirty = true;
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
toast('密钥将在保存时清空', 'info');
|
||
return;
|
||
}
|
||
|
||
var clearNestedSec = t.closest('[data-vault-clear-sec-value]');
|
||
if (clearNestedSec) {
|
||
var csid = clearNestedSec.getAttribute('data-vault-clear-sec-value');
|
||
var cAcc = clearNestedSec.getAttribute('data-acc-id');
|
||
var formClearSec = qs('[data-vault-form]', detailEl);
|
||
if (formClearSec) snapshotFormSlots(formClearSec);
|
||
(state._formAccounts || []).forEach(function (acc) {
|
||
if (cAcc && acc.id !== cAcc) return;
|
||
(acc.secrets || []).forEach(function (sec) {
|
||
if (sec.id !== csid) return;
|
||
sec.valueClear = true;
|
||
sec.valueText = '';
|
||
sec.valueState = { state: 'absent' };
|
||
});
|
||
});
|
||
state.dirty = true;
|
||
reRenderFormKeepingSlots(state.mode === 'create');
|
||
toast('密钥将在保存时清空', 'info');
|
||
return;
|
||
}
|
||
|
||
var clearBtn = t.closest('[data-vault-clear-secret]');
|
||
if (clearBtn) {
|
||
var cf = clearBtn.getAttribute('data-vault-clear-secret');
|
||
state.secretDraft[cf] = 'clear';
|
||
state.dirty = true;
|
||
var input = detailEl.querySelector('[data-secret-field="' + cf + '"]');
|
||
if (input) {
|
||
input.value = '';
|
||
input.placeholder = '(将清空)';
|
||
}
|
||
toast(cf + ' 将在保存时清空', 'info');
|
||
return;
|
||
}
|
||
|
||
if (t.closest('[data-vault-delete]')) {
|
||
if (!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');
|
||
var accountId = revealBtn.getAttribute('data-vault-account-id') || '';
|
||
var secretId = revealBtn.getAttribute('data-vault-secret-id') || '';
|
||
var rkey = revealKey(field, accountId, secretId);
|
||
if (state.revealed[rkey]) {
|
||
state.revealed[rkey] = false;
|
||
renderDetail();
|
||
return;
|
||
}
|
||
var revealBody = { field: field };
|
||
if (accountId) revealBody.accountId = accountId;
|
||
if (secretId) {
|
||
revealBody.secretId = secretId;
|
||
if (!field || field === 'value') {
|
||
// appendix secret: field optional on server when secretId present
|
||
delete revealBody.field;
|
||
}
|
||
}
|
||
apiJson(
|
||
'POST',
|
||
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
||
state.rootUri,
|
||
revealBody
|
||
)
|
||
.then(function (result) {
|
||
var secret = result && result.secret;
|
||
state._revealedValues = state._revealedValues || {};
|
||
if (secret && secret.state === 'revealed') {
|
||
state._revealedValues[rkey] = secret.value || '';
|
||
state.revealed[rkey] = true;
|
||
if (secret.missingCipherKeys && secret.missingCipherKeys.length) {
|
||
toast(
|
||
'密文簿缺少 key: ' + secret.missingCipherKeys.join(', '),
|
||
'info'
|
||
);
|
||
}
|
||
} else {
|
||
toast('该字段未设置', 'info');
|
||
}
|
||
renderDetail();
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || String(err), 'error');
|
||
});
|
||
return;
|
||
}
|
||
|
||
var copyBtn = t.closest('[data-vault-copy-secret]');
|
||
if (copyBtn) {
|
||
var cfield = copyBtn.getAttribute('data-vault-copy-secret');
|
||
var cAccountId = copyBtn.getAttribute('data-vault-account-id') || '';
|
||
var cSecretId = copyBtn.getAttribute('data-vault-secret-id') || '';
|
||
var ckey = revealKey(cfield, cAccountId, cSecretId);
|
||
var doCopy = function (text) {
|
||
if (!text) {
|
||
toast('无可复制内容', 'info');
|
||
return;
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(
|
||
function () {
|
||
toast('已复制', 'success');
|
||
},
|
||
function () {
|
||
toast('复制失败', 'error');
|
||
}
|
||
);
|
||
} else {
|
||
toast('剪贴板不可用', 'error');
|
||
}
|
||
};
|
||
if (state.revealed[ckey] && state._revealedValues && state._revealedValues[ckey]) {
|
||
doCopy(state._revealedValues[ckey]);
|
||
} else {
|
||
var copyBody = { field: cfield };
|
||
if (cAccountId) copyBody.accountId = cAccountId;
|
||
if (cSecretId) {
|
||
copyBody.secretId = cSecretId;
|
||
if (!cfield || cfield === 'value') delete copyBody.field;
|
||
}
|
||
apiJson(
|
||
'POST',
|
||
'/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal',
|
||
state.rootUri,
|
||
copyBody
|
||
)
|
||
.then(function (result) {
|
||
var secret = result && result.secret;
|
||
if (secret && secret.state === 'revealed') {
|
||
doCopy(secret.value || '');
|
||
} else {
|
||
toast('该字段未设置', 'info');
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
toast(err.message || String(err), 'error');
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
var copyPlain = t.closest('[data-vault-copy]');
|
||
if (copyPlain) {
|
||
var text = copyPlain.getAttribute('data-vault-copy') || '';
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(function () {
|
||
toast('已复制', 'success');
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
if (searchEl) {
|
||
searchEl.addEventListener('input', function () {
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
// 顶栏「插入密文」:固定在密文簿旁,仅编辑态显示
|
||
bindHeaderCipherInsertControls();
|
||
syncHeaderCipherInsertVisibility();
|
||
|
||
// deep-link id
|
||
try {
|
||
var params = new URLSearchParams(window.location.search);
|
||
var deepId = params.get('id');
|
||
if (deepId) state.selectedId = deepId;
|
||
var st = params.get('status');
|
||
if (st === 'deleted') {
|
||
state.status = 'deleted';
|
||
if (tabDeleted) tabDeleted.classList.add('is-active');
|
||
if (tabActive) tabActive.classList.remove('is-active');
|
||
}
|
||
} catch (_e) {}
|
||
|
||
// ensure then list
|
||
if (state.rootUri) {
|
||
apiJson('POST', '/api/vault/ensure', state.rootUri, {})
|
||
.then(function () {
|
||
return loadList();
|
||
})
|
||
.catch(function (err) {
|
||
setStatus(err.message || String(err), 'error');
|
||
loadList();
|
||
});
|
||
} else {
|
||
renderList();
|
||
renderDetail();
|
||
setStatus('请先打开本地文件夹', 'error');
|
||
}
|
||
}
|
||
|
||
/** Keep left-rail /vault links in the current local_folder context. */
|
||
function enrichVaultNavLinks() {
|
||
var rootUri =
|
||
(document.body && document.body.getAttribute('data-mnote-root-uri')) ||
|
||
new URLSearchParams(window.location.search).get('rootUri') ||
|
||
'';
|
||
rootUri = String(rootUri || '').trim();
|
||
var sourceKind =
|
||
(document.body && document.body.getAttribute('data-mnote-source-kind')) ||
|
||
new URLSearchParams(window.location.search).get('sourceKind') ||
|
||
(rootUri ? 'local_folder' : '');
|
||
sourceKind = String(sourceKind || '').trim();
|
||
if (!rootUri && !sourceKind) return;
|
||
qsa('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]').forEach(
|
||
function (link) {
|
||
if (!(link instanceof HTMLAnchorElement)) return;
|
||
try {
|
||
var url = new URL(link.getAttribute('href') || '/vault', window.location.origin);
|
||
if (url.pathname !== '/vault') return;
|
||
if (rootUri && !url.searchParams.get('rootUri')) {
|
||
url.searchParams.set('rootUri', rootUri);
|
||
}
|
||
if (sourceKind && !url.searchParams.get('sourceKind')) {
|
||
url.searchParams.set('sourceKind', sourceKind);
|
||
}
|
||
link.setAttribute('href', url.pathname + url.search + url.hash);
|
||
} catch (_e) {}
|
||
}
|
||
);
|
||
}
|
||
|
||
function boot() {
|
||
enrichVaultNavLinks();
|
||
qsa('[data-testid="mnote-vault-workbench"]').forEach(bindWorkbench);
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
} else {
|
||
boot();
|
||
}
|
||
|
||
// Capture-phase enrich only. Soft-open is owned by sidebar-tree-runtime
|
||
// (keeps PageTree/FileTree DOM). Full assign only when soft-open is unavailable.
|
||
if (!window.__mnoteVaultNavCapture) {
|
||
window.__mnoteVaultNavCapture = true;
|
||
document.addEventListener(
|
||
'click',
|
||
function (ev) {
|
||
var t = ev.target;
|
||
if (!(t instanceof Element)) return;
|
||
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||
var link = t.closest(
|
||
'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]'
|
||
);
|
||
if (!(link instanceof HTMLAnchorElement)) return;
|
||
try {
|
||
var url = new URL(link.getAttribute('href') || '/vault', window.location.origin);
|
||
if (url.pathname !== '/vault') return;
|
||
var rootUri =
|
||
(document.body && document.body.getAttribute('data-mnote-root-uri')) ||
|
||
new URLSearchParams(window.location.search).get('rootUri') ||
|
||
'';
|
||
rootUri = String(rootUri || '').trim();
|
||
if (rootUri && !url.searchParams.get('rootUri')) {
|
||
url.searchParams.set('rootUri', rootUri);
|
||
if (!url.searchParams.get('sourceKind')) {
|
||
url.searchParams.set('sourceKind', 'local_folder');
|
||
}
|
||
link.setAttribute('href', url.pathname + url.search + url.hash);
|
||
}
|
||
// Prefer soft-open so sidebar tree expansion is preserved.
|
||
if (typeof window.__mnoteOpenVaultWorkbench === 'function') {
|
||
ev.preventDefault();
|
||
void window.__mnoteOpenVaultWorkbench(url);
|
||
}
|
||
} catch (_e) {}
|
||
},
|
||
true
|
||
);
|
||
}
|
||
|
||
window.mnote = window.mnote || {};
|
||
window.mnote.vaultWorkbench = { boot: boot, enrichVaultNavLinks: enrichVaultNavLinks };
|
||
})();
|