// ==UserScript== // @name AI 对话流转|ChatGPT 对话导出与整理 // @namespace local.only.chatgpt.incremental.exporter // @version 2.9.3 // @description 将 ChatGPT 对话增量同步到本地,支持自动分类、整理和按需提取为 JSON / Markdown。 // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @run-at document_idle // @grant none // @license MIT // ==/UserScript== (() => { "use strict"; const VERSION = "2.9.3"; const SCHEMA_VERSION = "2.4"; const INDEX_SCHEMA_VERSION = "1.0"; const INACTIVE_DAYS = 7; const DAY_MS = 86_400_000; const META_DIR = ".chatgpt-export"; const IDB_NAME = "chatgpt-local-exporter"; const IDB_STORE = "handles"; const IDB_HANDLE_KEY = "export-root"; const IDB_EXTRACT_ENTRY_KEY = "extract-entry"; const EXTRACT_ENTRY_STATE_KEY = "chatgpt-local-exporter-extract-entry-state-v1"; const HOST_ID = "chatgpt-local-sync-host"; const RULES_STORAGE_KEY = "chatgpt-local-exporter-classification-rules-v3"; const LEGACY_RULES_STORAGE_KEYS = [ "chatgpt-local-exporter-classification-rules-v2", "chatgpt-local-exporter-classification-rules-v1", ]; const CHANGE_MANIFEST_PATH = `${META_DIR}/conversation-changes.json`; const SHARED_RULES_PATH = `${META_DIR}/classification-rules.json`; const SHARED_RULES_SCHEMA_VERSION = "1.4"; const CONVERSATION_STATE_PATH = `${META_DIR}/conversation-state.json`; const DELETED_CONVERSATIONS_PATH = `${META_DIR}/deleted-conversations.json`; const FOLDER_STATE_PATH = `${META_DIR}/folder-state.json`; const CONVERSATION_STATE_SCHEMA_VERSION = "1.0"; const FOLDER_STATE_SCHEMA_VERSION = "1.0"; const DELETED_CONVERSATIONS_SCHEMA_VERSION = "1.0"; const KNOWN_AUXILIARY_TYPES = new Set(["thoughts", "reasoning_recap"]); const SYSTEM_INBOX_FOLDER = "未归类"; const SYSTEM_ARCHIVE_FOLDER = "归档"; const SYSTEM_DELETED_FOLDER = "已删除"; const EXTRACTION_HISTORY_FOLDER = "提取历史"; const EXTRACTION_HISTORY_META_DIR = `${META_DIR}/extraction-history`; const LEGACY_ARCHIVE_FOLDER = "Archive"; const LEGACY_DELETED_FOLDER = "Deleted"; // 正式脚本不内置任何个人分类。用户规则只保存在当前浏览器的 localStorage 中。 const DEFAULT_CLASSIFICATION_RULES = []; function emptyConversationState() { return { schema_version: CONVERSATION_STATE_SCHEMA_VERSION, updated_at: null, conversations: {} }; } function emptyDeletedConversations() { return { schema_version: DELETED_CONVERSATIONS_SCHEMA_VERSION, updated_at: null, deleted: {} }; } function emptyFolderState() { return { schema_version: FOLDER_STATE_SCHEMA_VERSION, updated_at: null, folders: {} }; } function normalizeFolderState(value) { const result = emptyFolderState(); result.updated_at = value?.updated_at || null; for (const [path, entry] of Object.entries(value?.folders && typeof value.folders === "object" ? value.folders : {})) { const normalizedPath = String(path || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim(); if (!normalizedPath) continue; result.folders[normalizedPath] = { json: Boolean(entry?.json), markdown: Boolean(entry?.markdown), baseline_at: entry?.baseline_at || entry?.updated_at || null, }; } return result; } async function loadFolderState(root) { const value = await readJson(root, FOLDER_STATE_PATH); if (value === null) return emptyFolderState(); if (!value?.folders || typeof value.folders !== "object") throw new Error("folder-state.json 格式无效"); return normalizeFolderState(value); } async function persistFolderState(root, state) { const normalized = normalizeFolderState(state); normalized.updated_at = new Date().toISOString(); await writeJson(root, FOLDER_STATE_PATH, normalized); return normalized; } function normalizeStatePath(path) { return String(path || "").replace(/\\/g, "/").replace(/^\/+/, "").trim(); } function normalizeStateEntry(entry = {}) { const state = ["active", "archived", "deleted"].includes(entry?.state) ? entry.state : "active"; return { title: String(entry?.title || "未命名对话"), state, last_known: { json_path: normalizeStatePath(entry?.last_known?.json_path || entry?.json_path || ""), markdown_path: normalizeStatePath(entry?.last_known?.markdown_path || entry?.markdown_path || ""), }, classification: entry?.classification && typeof entry.classification === "object" ? entry.classification : null, baseline_at: entry?.baseline_at || entry?.updated_at || null, }; } function normalizeConversationState(value) { const result = emptyConversationState(); result.updated_at = value?.updated_at || null; for (const [conversationId, entry] of Object.entries(value?.conversations && typeof value.conversations === "object" ? value.conversations : {})) { if (!conversationId) continue; result.conversations[String(conversationId)] = normalizeStateEntry(entry); } return result; } function normalizeDeletedConversations(value) { const result = emptyDeletedConversations(); result.updated_at = value?.updated_at || null; for (const [conversationId, entry] of Object.entries(value?.deleted && typeof value.deleted === "object" ? value.deleted : {})) { if (!conversationId) continue; result.deleted[String(conversationId)] = { title: String(entry?.title || "未命名对话"), deleted_at: entry?.deleted_at || null, source: String(entry?.source || "local"), }; } return result; } async function loadConversationState(root) { const value = await readJson(root, CONVERSATION_STATE_PATH); if (value === null) return emptyConversationState(); if (!value?.conversations || typeof value.conversations !== "object") throw new Error("conversation-state.json 格式无效"); return normalizeConversationState(value); } async function persistConversationState(root, state) { const normalized = normalizeConversationState(state); normalized.updated_at = new Date().toISOString(); await writeJson(root, CONVERSATION_STATE_PATH, normalized); return normalized; } async function loadDeletedConversations(root) { const value = await readJson(root, DELETED_CONVERSATIONS_PATH); if (value === null) return emptyDeletedConversations(); if (!value?.deleted || typeof value.deleted !== "object") throw new Error("deleted-conversations.json 格式无效"); return normalizeDeletedConversations(value); } async function persistDeletedConversations(root, data) { const normalized = normalizeDeletedConversations(data); normalized.updated_at = new Date().toISOString(); await writeJson(root, DELETED_CONVERSATIONS_PATH, normalized); return normalized; } function stateEntryFromIndexEntry(entry, now = new Date().toISOString()) { return normalizeStateEntry({ title: entry?.title || "未命名对话", state: "active", last_known: { json_path: entry?.json_path || "", markdown_path: entry?.markdown_path || "", }, classification: entry?.classification || null, baseline_at: now, }); } function buildObservedConversationMap(observedFiles = []) { const result = new Map(); for (const item of observedFiles) { const conversationId = String(item?.conversation_id || ""); const kind = item?.kind === "JSON" ? "json" : item?.kind === "Markdown" ? "markdown" : ""; const path = normalizeStatePath(item?.path); if (!conversationId || !kind || !path) continue; if (!result.has(conversationId)) result.set(conversationId, { json: [], markdown: [] }); const paths = result.get(conversationId)[kind]; if (!paths.includes(path)) paths.push(path); } for (const observed of result.values()) { observed.json.sort(); observed.markdown.sort(); } return result; } function mirrorKeyForPath(path, kind) { let value = normalizeStatePath(path); const root = kind === "json" ? "JSON/" : "Markdown/"; if (value.toLowerCase().startsWith(root.toLowerCase())) value = value.slice(root.length); value = kind === "json" ? value.replace(/\.json$/i, "") : value.replace(/\.md$/i, ""); return value; } function folderForStatePath(path) { const parts = normalizeStatePath(path).split("/").filter(Boolean); if (parts.length) parts.shift(); if (parts.length) parts.pop(); return parts.join("/"); } function representationChange(kind, previousPath, observedPaths = [], unreadablePaths = new Set()) { const from = normalizeStatePath(previousPath); const candidates = [...new Set((observedPaths || []).map(normalizeStatePath).filter(Boolean))].sort(); if (from && unreadablePaths.has(from)) { return { kind, type: "uncertain", from, to: null, candidates, reason: "上次位置仍有文件,但当前无法读取 conversation_id" }; } if (candidates.length > 1) { return { kind, type: "ambiguous", from, to: null, candidates, reason: "发现多个同 conversation_id 副本" }; } const to = candidates[0] || ""; if (from === to) return { kind, type: "none", from, to, candidates }; if (from && !to) return { kind, type: "delete", from, to: "", candidates }; if (from && to) return { kind, type: "move", from, to, candidates }; if (!from && to) return { kind, type: "add", from: "", to, candidates }; return { kind, type: "none", from: "", to: "", candidates }; } function detectConversationChanges(state, observedFiles = [], options = {}) { const normalizedState = normalizeConversationState(state); const observedById = buildObservedConversationMap(observedFiles); const unreadablePaths = new Set((options.unreadableFiles || []).map((item) => normalizeStatePath(item?.path || item)).filter(Boolean)); const trusted = options.scanTrusted !== false; const changes = []; if (!trusted) return { trusted: false, changes, reason: "JSON 或 Markdown 根目录未完整扫描,禁止把缺失解释为用户操作" }; for (const [conversationId, entry] of Object.entries(normalizedState.conversations)) { if (entry.state !== "active") continue; const observed = observedById.get(conversationId) || { json: [], markdown: [] }; const json = representationChange("json", entry.last_known.json_path, observed.json, unreadablePaths); const markdown = representationChange("markdown", entry.last_known.markdown_path, observed.markdown, unreadablePaths); const all = [json, markdown]; if (all.some((item) => ["ambiguous", "uncertain"].includes(item.type))) { changes.push({ conversation_id: conversationId, title: entry.title, type: "uncertain", state: entry.state, representations: all, reason: all.filter((item) => ["ambiguous", "uncertain"].includes(item.type)).map((item) => `${item.kind}: ${item.reason}`).join(";"), }); continue; } const effective = all.filter((item) => item.type !== "none"); if (!effective.length) continue; if (effective.every((item) => item.type === "add")) { changes.push({ conversation_id: conversationId, title: entry.title, type: "repair", state: entry.state, representations: all, reason: "原基线缺少路径,本地现已出现文件" }); continue; } const targets = effective.map((item) => item.type === "delete" ? "__DELETED__" : mirrorKeyForPath(item.to, item.kind)); const uniqueTargets = [...new Set(targets)]; if (uniqueTargets.length === 1) { const target = uniqueTargets[0]; changes.push({ conversation_id: conversationId, title: entry.title, type: target === "__DELETED__" ? "delete" : "move", state: entry.state, target: target === "__DELETED__" ? null : target, target_folder: target === "__DELETED__" ? null : folderForStatePath(effective.find((item) => item.to)?.to || ""), representations: all, reason: effective.length === 1 ? "检测到唯一非零变化向量" : "JSON 与 Markdown 的变化方向一致", }); } else { changes.push({ conversation_id: conversationId, title: entry.title, type: "conflict", state: entry.state, representations: all, reason: "JSON 与 Markdown 同时发生不同方向的非零变化,需要用户决定最终位置", }); } } return { trusted: true, changes, reason: "" }; } function extractConversationId(pathname) { const match = String(pathname || "").match(/(?:^|\/)c\/([^/?#]+)/); return match ? decodeURIComponent(match[1]) : ""; } function toTimestampMs(value) { if (value === null || value === undefined || value === "") return 0; const numeric = Number(value); if (Number.isFinite(numeric)) return numeric > 10_000_000_000 ? numeric : numeric * 1000; const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; } function toIso(value) { const ms = toTimestampMs(value); return ms ? new Date(ms).toISOString() : null; } function cleanSegment(value, fallback = "未命名对话") { const cleaned = String(value || fallback) .replace(/[\\/:*?"<>|]/g, "_") .replace(/[\x00-\x1f\x80-\x9f]/g, "_") .replace(/[. ]+$/g, "") .replace(/\s+/g, " ") .trim(); return (cleaned || fallback).slice(0, 120); } function shortId(id) { const value = String(id || ""); return value.slice(-6) || "unknown"; } const ILLEGAL_WINDOWS_CHARS = /[\\/:*?"<>|]/; const MAX_FOLDER_DEPTH = 12; const MAX_RELATIVE_PATH_LENGTH = 240; function normalizeDelimiter(value) { if (value === null || value === undefined || value === "__unset__") return null; return String(value); } function makeRuleId(seed = "") { const safeSeed = String(seed || "").replace(/[^A-Za-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); return safeSeed ? `rule-${safeSeed.toLowerCase()}` : `rule-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } function validRuleField(field) { const value = String(field || "").trim(); return /^[A-Za-z][A-Za-z0-9]{0,31}$/.test(value) || /^[\u3400-\u9fff]{1,16}$/.test(value); } function normalizeAlias(alias) { const rawField = String(alias?.field || alias?.prefix || "").trim(); const field = /^[A-Za-z0-9]+$/.test(rawField) ? rawField.toUpperCase() : rawField; const connector = normalizeDelimiter(alias?.connector); if (!validRuleField(field) || connector === null) return null; return { field, connector }; } function normalizeRule(rule) { const rawField = String(rule?.field || rule?.prefix || rule?.code || "").trim(); const field = /^[A-Za-z0-9]+$/.test(rawField) ? rawField.toUpperCase() : rawField; const rootFolder = String(rule?.root_folder || rule?.rootFolder || rule?.folder || "") .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim(); if (!validRuleField(field) || !rootFolder) return null; const hasConnector = Object.prototype.hasOwnProperty.call(rule || {}, "connector"); const hasFolderSeparator = Object.prototype.hasOwnProperty.call(rule || {}, "folder_separator") || Object.prototype.hasOwnProperty.call(rule || {}, "folderSeparator"); const legacyFormat = !hasConnector && !hasFolderSeparator; const connector = legacyFormat ? null : normalizeDelimiter(rule.connector); const folderSeparator = legacyFormat ? null : normalizeDelimiter(rule.folder_separator ?? rule.folderSeparator); const hasSubfolderPath = Object.prototype.hasOwnProperty.call(rule || {}, "subfolder_path") || Object.prototype.hasOwnProperty.call(rule || {}, "subfolderPath"); const subfolderPath = folderSeparator === "" ? "" : String(rule?.subfolder_path ?? rule?.subfolderPath ?? "").trim(); const subfolderPending = Boolean(rule?.subfolder_pending) || (!legacyFormat && folderSeparator !== null && folderSeparator !== "" && !hasSubfolderPath); const ruleId = String(rule?.rule_id || rule?.ruleId || makeRuleId(legacyFormat ? `legacy-${field}` : "")).trim(); const aliases = Array.from(new Map((Array.isArray(rule?.aliases) ? rule.aliases : []) .map(normalizeAlias).filter(Boolean) .map((alias) => [`${alias.field}\u0000${alias.connector}`, alias])).values()); return { rule_id: ruleId, field, connector, root_folder: rootFolder, folder_separator: folderSeparator, subfolder_path: subfolderPath, subfolder_pending: subfolderPending, aliases, format_pending: connector === null || folderSeparator === null || subfolderPending, }; } function normalizeRules(rules) { const result = []; const seenIds = new Set(); const seenFormats = new Set(); for (const candidate of Array.isArray(rules) ? rules : []) { const rule = normalizeRule(candidate); if (!rule || seenIds.has(rule.rule_id)) continue; const signature = rule.format_pending ? `pending:${rule.field}` : `${rule.field}\u0000${rule.connector}`; if (seenFormats.has(signature)) continue; seenIds.add(rule.rule_id); seenFormats.add(signature); result.push(rule); } return result; } function delimiterLabel(value, noneLabel = "无") { if (value === "") return `[${noneLabel}]`; if (value === " ") return "[普通空格]"; return String(value); } function ruleFormat(rule) { const normalized = normalizeRule(rule); if (!normalized || normalized.format_pending) return "格式待确认"; const connector = delimiterLabel(normalized.connector, "无连接符"); return `${normalized.field}${connector}{对话名称}`; } function validatePathPart(value, label) { const text = String(value || ""); if (!text) return `${label}不能为空`; if (text !== text.trim()) return `${label}的开头或结尾不能有空格`; if (ILLEGAL_WINDOWS_CHARS.test(text)) return `${label}含有 Windows 文件名禁用字符:\\ : * ? " < > |`; if (/[. ]$/.test(text)) return `${label}不能以句点或空格结尾`; if (text.length > 120) return `${label}超过 120 个字符`; return ""; } function validateRule(rule) { const normalized = normalizeRule(rule); const errors = []; if (!normalized) return { ok: false, errors: ["命名标记可使用英文/数字快捷码(1—32 位)或中文(1—16 字),并且一级文件夹不能为空"] }; if (normalized.connector === null || normalized.folder_separator === null) { errors.push("旧规则还没设置完整:请选择命名标记后怎么分隔,以及要不要使用子文件夹"); } if (normalized.subfolder_pending) errors.push("这条旧规则选择了子文件夹,但还没有填写子文件夹名称"); if (normalized.connector?.includes("\n") || normalized.connector?.includes("\r")) errors.push("分隔符不能包含换行"); if (normalized.folder_separator?.includes("\n") || normalized.folder_separator?.includes("\r")) errors.push("子文件夹分隔符不能包含换行"); if (normalized.folder_separator && normalized.folder_separator.length > 4) errors.push("子文件夹分隔符最多 4 个字符"); if (normalized.connector && normalized.connector.length > 4) errors.push("分隔符最多 4 个字符"); const rootParts = normalized.root_folder.split("/"); if (rootParts.some((part) => !part)) errors.push("一级文件夹中存在空文件夹名称"); for (const [index, part] of rootParts.entries()) { const error = validatePathPart(part, `一级文件夹第 ${index + 1} 层`); if (error) errors.push(error); } const subfolders = parseSubfolderPath(normalized); if (!subfolders.ok) errors.push(subfolders.error); if (rootParts.length + subfolders.parts.length > MAX_FOLDER_DEPTH) errors.push(`文件夹总层级最多 ${MAX_FOLDER_DEPTH} 层`); return { ok: errors.length === 0, errors, rule: normalized }; } function parseSubfolderPath(rule) { const folderSeparator = normalizeDelimiter(rule?.folder_separator ?? rule?.folderSeparator); const subfolderPath = String(rule?.subfolder_path ?? rule?.subfolderPath ?? "").trim(); if (folderSeparator === null) return { ok: false, parts: [], error: "请选择是否使用子文件夹" }; if (folderSeparator === "") return { ok: true, parts: [], path: "" }; if (!subfolderPath) return { ok: false, parts: [], error: "已经选择使用子文件夹,请填写子文件夹名称" }; const parts = subfolderPath.split(folderSeparator); const emptyIndex = parts.findIndex((part) => part === ""); if (emptyIndex >= 0) { const before = parts[emptyIndex - 1] || "开头"; const after = parts[emptyIndex + 1] || "结尾"; return { ok: false, parts: [], error: `${before} 和 ${after} 之间存在空文件夹名称` }; } for (const [index, part] of parts.entries()) { const error = validatePathPart(part, `子文件夹第 ${index + 1} 层`); if (error) return { ok: false, parts: [], error }; } return { ok: true, parts, path: parts.join("/") }; } function parseTitleWithFormat(title, format, rootFolder) { const raw = String(title || ""); const field = String(format.field || "").toUpperCase(); const connector = normalizeDelimiter(format.connector); const folderSeparator = normalizeDelimiter(format.folder_separator); if (!raw || !field || connector === null || folderSeparator === null) { return { ok: false, code: "format-pending", error: "规则格式待确认" }; } if (raw.slice(0, field.length).toUpperCase() !== field) { return { ok: false, code: "field-mismatch", error: `标题不是以识别字段 ${field} 开头` }; } if (raw.slice(field.length, field.length + connector.length) !== connector) { return { ok: false, code: "connector-mismatch", error: `识别字段 ${field} 后面应当使用${delimiterLabel(connector, "无连接符")}`, }; } const remainder = raw.slice(field.length + connector.length); if (!remainder) return { ok: false, code: "missing-title", error: "缺少{对话名称}" }; if (remainder !== remainder.trim()) { return { ok: false, code: "boundary-space", error: "字段连接符之后、对话名称末尾不能再有多余空格" }; } const conversationName = remainder; const titleError = validatePathPart(conversationName, "对话名称"); if (titleError) return { ok: false, code: "invalid-title", error: titleError }; const subfolders = parseSubfolderPath(format); if (!subfolders.ok) return { ok: false, code: "invalid-subfolder", error: subfolders.error }; const childFolders = subfolders.parts; const rootParts = String(rootFolder || "").split("/").filter(Boolean); if (rootParts.length + childFolders.length > MAX_FOLDER_DEPTH) { return { ok: false, code: "too-deep", error: `文件夹总层级不能超过 ${MAX_FOLDER_DEPTH} 层` }; } const folder = joinPath(rootFolder, ...childFolders); const approximatePath = joinPath(folder, `${field}${connector}${conversationName}__唯一ID.json`); if (approximatePath.length > MAX_RELATIVE_PATH_LENGTH) { return { ok: false, code: "path-too-long", error: `预计相对路径超过 ${MAX_RELATIVE_PATH_LENGTH} 个字符` }; } return { ok: true, field, connector, folder_separator: folderSeparator, child_folders: childFolders, conversation_name: conversationName, folder }; } function parseTitleByRule(title, rule) { const normalized = normalizeRule(rule); if (!normalized || normalized.format_pending) return { ok: false, code: "format-pending", error: "规则格式待确认" }; const primary = parseTitleWithFormat(title, normalized, normalized.root_folder); if (primary.ok) return { ...primary, rule: normalized, matched_alias: false }; for (const alias of normalized.aliases) { const parsed = parseTitleWithFormat(title, { ...alias, folder_separator: normalized.folder_separator, subfolder_path: normalized.subfolder_path, }, normalized.root_folder); if (parsed.ok) return { ...parsed, rule: normalized, matched_alias: true }; } return primary; } function titleHasPrefix(title, prefix, connector = "") { return parseTitleWithFormat(title, { field: String(prefix || "").trim().toUpperCase(), connector, folder_separator: "", subfolder_path: "", }, "_").ok; } function classifyTitle(title, rules = DEFAULT_CLASSIFICATION_RULES) { const candidates = normalizeRules(rules) .filter((rule) => !rule.format_pending) .sort((a, b) => b.field.length - a.field.length); for (const rule of candidates) { const parsed = parseTitleByRule(title, rule); if (!parsed.ok) continue; return { kind: "标题规则", name: rule.field, folder: parsed.folder, root_folder: rule.root_folder, child_folders: parsed.child_folders, conversation_name: parsed.conversation_name, file_title: `${parsed.field}${parsed.connector}${parsed.conversation_name}`, prefix: rule.field, field: rule.field, rule_id: rule.rule_id, matched_alias: parsed.matched_alias, source: "title-format", }; } return null; } function normalizeClassificationOverrides(overrides) { const result = {}; for (const [conversationId, value] of Object.entries(overrides && typeof overrides === "object" ? overrides : {})) { const ruleId = String(value?.rule_id || "").trim(); const folder = String(value?.folder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim(); if (!conversationId || (!ruleId && !folder)) continue; result[String(conversationId)] = { rule_id: ruleId || null, folder: folder || null, source: ["manual-folder", "manual-folder-direct", "user-selection", "agent"].includes(value?.source) ? value.source : (folder ? "manual-folder-direct" : "manual-folder"), updated_at: value?.updated_at || null, }; } return result; } function normalizeIgnoredFolders(folders) { return [...new Set((Array.isArray(folders) ? folders : []) .map((folder) => String(folder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim()) .filter(Boolean))].sort(); } function normalizeIgnoredTitleFormats(formats) { const result = []; const seen = new Set(); for (const value of Array.isArray(formats) ? formats : []) { const field = String(value?.field || "").trim().toUpperCase(); const connector = normalizeDelimiter(value?.connector); if (!field || connector === null) continue; const key = `${field}\u0000${connector}`; if (seen.has(key)) continue; seen.add(key); result.push({ field, connector }); } return result.sort((a, b) => a.field.localeCompare(b.field) || a.connector.localeCompare(b.connector)); } function ignoredTitleFormatKey(field, connector) { return `${String(field || "").trim().toUpperCase()}\u0000${String(connector ?? "")}`; } function sharedRulesContentSignature(shared) { return JSON.stringify({ rules: normalizeRules(shared?.rules), conversation_overrides: normalizeClassificationOverrides(shared?.conversation_overrides), ignored_folders: normalizeIgnoredFolders(shared?.ignored_folders), ignored_title_formats: normalizeIgnoredTitleFormats(shared?.ignored_title_formats), }); } function prepareSharedRulesWrite(current, expected, payload, now = new Date().toISOString()) { const currentRevision = Number(current?.revision || 0); const expectedRevision = Number(expected?.revision || 0); const expectedSignature = String(expected?.signature || ""); if (currentRevision !== expectedRevision || (expectedSignature && sharedRulesContentSignature(current) !== expectedSignature)) { throw new Error("共享分类规则已被其他写入者修改。已停止覆盖,请重新扫描并处理差异。"); } return { schema_version: SHARED_RULES_SCHEMA_VERSION, exporter_version: VERSION, revision: currentRevision + 1, base_revision: currentRevision, updated_at: now, updated_by: "browser", rules: normalizeRules(payload?.rules), conversation_overrides: normalizeClassificationOverrides(payload?.conversation_overrides), ignored_folders: normalizeIgnoredFolders(payload?.ignored_folders), ignored_title_formats: normalizeIgnoredTitleFormats(payload?.ignored_title_formats), }; } function classificationFromRule(rule, title, source = "manual-folder") { const normalized = normalizeRule(rule); const folder = ruleTargetFolder(normalized); if (!normalized || !folder) return null; return { kind: "标题规则", name: normalized.field, folder, root_folder: normalized.root_folder, child_folders: parseSubfolderPath(normalized).parts, conversation_name: String(title || "未命名对话"), file_title: String(title || "未命名对话"), prefix: normalized.field, field: normalized.field, rule_id: normalized.rule_id, matched_alias: false, source, }; } function joinPath(...parts) { return parts.filter(Boolean).join("/").replace(/\/{2,}/g, "/"); } const PINYIN_BOUNDARIES = [ ["A", "阿"], ["B", "八"], ["C", "嚓"], ["D", "搭"], ["E", "蛾"], ["F", "发"], ["G", "噶"], ["H", "哈"], ["J", "击"], ["K", "喀"], ["L", "垃"], ["M", "妈"], ["N", "拿"], ["O", "哦"], ["P", "啪"], ["Q", "期"], ["R", "然"], ["S", "撒"], ["T", "塌"], ["W", "挖"], ["X", "昔"], ["Y", "压"], ["Z", "匝"], ]; function pinyinInitial(character) { if (/^[A-Za-z]$/.test(character)) return character.toUpperCase(); if (/^[0-9]$/.test(character)) return character; if (!/[\u3400-\u9fff]/.test(character)) return ""; try { const collator = new Intl.Collator("zh-CN-u-co-pinyin"); let initial = ""; for (const [letter, boundary] of PINYIN_BOUNDARIES) { if (collator.compare(character, boundary) >= 0) initial = letter; else break; } return initial; } catch { return ""; } } function suggestRuleField(folderName) { const suggestion = Array.from(String(folderName || "")) .map((character) => pinyinInitial(character)) .join("") .replace(/[^A-Z0-9]/g, "") .slice(0, 32); if (!suggestion) return ""; return /^[A-Z]/.test(suggestion) ? suggestion : `F${suggestion}`.slice(0, 32); } function folderRuleDraft(relativeFolder) { const parts = String(relativeFolder || "").replace(/\\/g, "/").split("/").filter(Boolean); const rootFolder = parts[0] || ""; const childFolders = parts.slice(1); return { path: parts.join("/"), root_folder: rootFolder, child_folders: childFolders, folder_separator: childFolders.length ? "/" : "", subfolder_path: childFolders.join("/"), suggested_field: suggestRuleField(parts.at(-1) || rootFolder), }; } function ruleTargetFolder(rule) { const normalized = normalizeRule(rule); if (!normalized) return ""; const parsed = parseSubfolderPath(normalized); return parsed.ok ? joinPath(normalized.root_folder, ...parsed.parts) : normalized.root_folder; } function buildFolderAudit(inventory, rules = []) { const folders = { JSON: new Set(inventory?.folders?.JSON || []), Markdown: new Set(inventory?.folders?.Markdown || []), }; const files = { JSON: new Set(inventory?.files?.JSON || []), Markdown: new Set(inventory?.files?.Markdown || []), }; const onlyJsonFolders = [...folders.JSON].filter((folder) => !folders.Markdown.has(folder)).sort(); const onlyMarkdownFolders = [...folders.Markdown].filter((folder) => !folders.JSON.has(folder)).sort(); const ruleTargets = normalizeRules(rules).map(ruleTargetFolder).filter(Boolean); const targetSet = new Set(ruleTargets); const missingRuleTargets = [...new Set(ruleTargets)] .filter((folder) => !folders.JSON.has(folder) || !folders.Markdown.has(folder)) .sort(); const makeFileMap = (entries, extension) => { const map = new Map(); for (const path of entries) { const parts = String(path).split("/"); const filename = parts.pop() || ""; const stem = filename.replace(extension, ""); if (!map.has(stem)) map.set(stem, []); map.get(stem).push({ path, folder: parts.join("/") }); } return map; }; const jsonMap = makeFileMap(files.JSON, /\.json$/i); const markdownMap = makeFileMap(files.Markdown, /\.md$/i); const jsonWithoutMarkdown = [...jsonMap.keys()].filter((stem) => !markdownMap.has(stem)).sort(); const markdownWithoutJson = [...markdownMap.keys()].filter((stem) => !jsonMap.has(stem)).sort(); const pathMismatches = []; for (const stem of [...jsonMap.keys()].filter((name) => markdownMap.has(name))) { const jsonFolders = new Set(jsonMap.get(stem).map((item) => item.folder)); const markdownFolders = new Set(markdownMap.get(stem).map((item) => item.folder)); if (![...jsonFolders].some((folder) => markdownFolders.has(folder))) { pathMismatches.push({ stem, json_paths: jsonMap.get(stem).map((item) => item.path), markdown_paths: markdownMap.get(stem).map((item) => item.path), }); } } const allFolders = [...new Set([...folders.JSON, ...folders.Markdown])].sort(); const hasDirectFile = (folder) => [...files.JSON, ...files.Markdown] .some((path) => path.split("/").slice(0, -1).join("/") === folder); const unregisteredFolders = allFolders .filter((folder) => { if (!folder || folder === "未归类" || targetSet.has(folder)) return false; const hasChild = allFolders.some((candidate) => candidate.startsWith(`${folder}/`)); const oneSided = !folders.JSON.has(folder) || !folders.Markdown.has(folder); return !hasChild || hasDirectFile(folder); }) .map((folder) => ({ ...folderRuleDraft(folder), present_in: [ ...(folders.JSON.has(folder) ? ["JSON"] : []), ...(folders.Markdown.has(folder) ? ["Markdown"] : []), ], })); return { folders: { JSON: [...folders.JSON].sort(), Markdown: [...folders.Markdown].sort() }, files: { JSON: [...files.JSON].sort(), Markdown: [...files.Markdown].sort() }, onlyJsonFolders, onlyMarkdownFolders, missingRuleTargets, jsonWithoutMarkdown, markdownWithoutJson, pathMismatches, unregisteredFolders, hasDrift: Boolean( onlyJsonFolders.length || onlyMarkdownFolders.length || missingRuleTargets.length || jsonWithoutMarkdown.length || markdownWithoutJson.length || pathMismatches.length || unregisteredFolders.length ), }; } function folderTreeHasContent(audit, kind, folderPath) { const folder = String(folderPath || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); if (!folder) return true; const folders = audit?.folders?.[kind] || []; const files = audit?.files?.[kind] || []; return folders.some((path) => path.startsWith(`${folder}/`)) || files.some((path) => path === folder || path.startsWith(`${folder}/`)); } function folderSnapshotFromAudit(audit, now = new Date().toISOString()) { const result = emptyFolderState(); const json = new Set(audit?.folders?.JSON || []); const markdown = new Set(audit?.folders?.Markdown || []); for (const path of [...new Set([...json, ...markdown])].sort()) { result.folders[path] = { json: json.has(path), markdown: markdown.has(path), baseline_at: now }; } result.updated_at = now; return result; } function detectFolderChanges(folderState, audit, rules = []) { const state = normalizeFolderState(folderState); const json = new Set(audit?.folders?.JSON || []); const markdown = new Set(audit?.folders?.Markdown || []); const managed = new Set([SYSTEM_INBOX_FOLDER, ...normalizeRules(rules).map(ruleTargetFolder).filter(Boolean)]); const allPaths = [...new Set([...Object.keys(state.folders), ...json, ...markdown])].sort(); const changes = []; for (const folder of allPaths) { const hasJson = json.has(folder); const hasMarkdown = markdown.has(folder); if (hasJson === hasMarkdown) continue; const presentKind = hasJson ? "JSON" : "Markdown"; const missingKind = hasJson ? "Markdown" : "JSON"; const previous = state.folders[folder] || null; if (managed.has(folder)) { changes.push({ type: "create-mirror", folder, create_in: missingKind, present_in: presentKind, managed: true, reason: "这是系统目录或已经登记的分类,两边都应该存在", }); continue; } // 目录里还有文件/子目录时,不让“空目录逻辑”介入;Conversation 自己的移动/删除逻辑优先。 if (folderTreeHasContent(audit, presentKind, folder)) continue; const previouslyPaired = Boolean(previous?.json && previous?.markdown); // 普通空目录只有一边时,不替用户猜“这是新建还是删除”。 // 给两个明确动作:删掉剩下的空目录,或者把缺的一边补回来。 changes.push({ type: "decide-empty-folder", folder, present_in: presentKind, missing_in: missingKind, previously_paired: previouslyPaired, suggested_action: previouslyPaired ? "delete" : "mirror", reason: previouslyPaired ? "这个空目录上次两边都有,现在只剩一边" : "这个空目录现在只在一边存在,没有可靠历史可以判断你是新建还是删除", }); } return { trusted: true, changes }; } function conversationIdsInFolder(observedFiles = [], folderPath = "") { const folder = String(folderPath || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); const result = new Set(); for (const item of observedFiles || []) { const path = normalizeStatePath(item?.path); const parts = path.split("/").filter(Boolean); if (parts.length < 2) continue; parts.shift(); parts.pop(); if (parts.join("/") === folder && item?.conversation_id) result.add(String(item.conversation_id)); } return [...result]; } function buildConversationIssues(index, observedFiles = [], rules = [], overrides = {}) { const observedById = new Map(); for (const item of observedFiles) { const conversationId = String(item?.conversation_id || ""); const kind = item?.kind === "Markdown" ? "markdown" : item?.kind === "JSON" ? "json" : ""; const path = String(item?.path || ""); if (!conversationId || !kind || !path) continue; if (!observedById.has(conversationId)) observedById.set(conversationId, { json: [], markdown: [] }); const paths = observedById.get(conversationId)[kind]; if (!paths.includes(path)) paths.push(path); } const entries = index?.conversations && typeof index.conversations === "object" ? index.conversations : {}; const conversationIds = new Set([...Object.keys(entries), ...observedById.keys()]); const issues = []; for (const conversationId of conversationIds) { const entry = entries[conversationId] || { conversation_id: conversationId, title: "索引外对话" }; const observed = observedById.get(conversationId) || { json: [], markdown: [] }; observed.json.sort(); observed.markdown.sort(); const classification = detectClassification( { id: conversationId, title: entry.title }, { id: conversationId, title: entry.title }, rules, overrides, ) || entry.classification || null; const desired = classificationHasExplicitTarget(classification) ? archivePaths(entry.title, conversationId, classification) : null; const types = []; if (!observed.json.length) types.push("missing-json"); if (!observed.markdown.length) types.push("missing-markdown"); if (observed.json.length > 1) types.push("duplicate-json"); if (observed.markdown.length > 1) types.push("duplicate-markdown"); const jsonFolders = new Set(observed.json.map((path) => path.split("/").slice(1, -1).join("/"))); const markdownFolders = new Set(observed.markdown.map((path) => path.split("/").slice(1, -1).join("/"))); if (observed.json.length && observed.markdown.length && ![...jsonFolders].some((folder) => markdownFolders.has(folder))) types.push("split-folders"); if ((observed.json.length && !observed.json.includes(entry.json_path)) || (observed.markdown.length && !observed.markdown.includes(entry.markdown_path))) types.push("index-drift"); // “未归类”是合法收件箱状态,不再作为异常。 if (desired && ((observed.json.length && !observed.json.includes(desired.json)) || (observed.markdown.length && !observed.markdown.includes(desired.markdown)))) types.push("wrong-target"); if (!types.length) continue; issues.push({ conversation_id: conversationId, title: entry.title || "未命名对话", types: [...new Set(types)], json_paths: observed.json, markdown_paths: observed.markdown, indexed_json: entry.json_path || null, indexed_markdown: entry.markdown_path || null, classification, desired_paths: desired, }); } return issues.sort((a, b) => a.title.localeCompare(b.title, "zh-CN") || a.conversation_id.localeCompare(b.conversation_id)); } function partToText(part, depth = 0) { if (depth > 8 || part === null || part === undefined) return ""; if (["string", "number", "boolean"].includes(typeof part)) return String(part); if (Array.isArray(part)) return part.map((item) => partToText(item, depth + 1)).filter(Boolean).join("\n"); if (typeof part !== "object") return ""; for (const key of ["text", "content", "caption", "result", "output", "value", "transcript", "code"]) { if (typeof part[key] === "string" && part[key].trim()) return part[key]; } for (const key of ["parts", "content", "result", "output", "items"]) { if (part[key] && typeof part[key] === "object") { const nested = partToText(part[key], depth + 1); if (nested) return nested; } } const pointer = part.asset_pointer || part.file_id || part.image_url || part.url || ""; const kind = part.content_type || part.type || "attachment"; return pointer ? `[${kind}: ${pointer}]` : ""; } function contentToText(content) { return partToText(content).trim(); } function hasMeaningfulRawContent(value, depth = 0) { if (depth > 8 || value === null || value === undefined) return false; if (typeof value === "string") return Boolean(value.trim()); if (["number", "boolean"].includes(typeof value)) return true; if (Array.isArray(value)) return value.some((item) => hasMeaningfulRawContent(item, depth + 1)); if (typeof value !== "object") return false; return Object.entries(value).some(([key, item]) => { if (["content_type", "type"].includes(key) && typeof item === "string") return false; return hasMeaningfulRawContent(item, depth + 1); }); } function collectFiles(message) { const candidateFiles = []; const metadata = message?.metadata || {}; for (const key of ["attachments", "files", "file_ids", "content_references"]) { const value = metadata[key]; if (Array.isArray(value)) candidateFiles.push(...value); else if (value) candidateFiles.push(value); } const seen = new Set(); return candidateFiles.map((item) => { const obj = item && typeof item === "object" ? item : { id: String(item) }; return { id: obj.id || obj.file_id || obj.asset_pointer || null, name: obj.name || obj.filename || null, mime_type: obj.mime_type || obj.type || null, size: obj.size || obj.size_bytes || null, }; }).filter((file) => { const key = JSON.stringify(file); if (seen.has(key)) return false; seen.add(key); return true; }); } function getActiveBranch(conversation) { const mapping = conversation?.mapping; if (!mapping || typeof mapping !== "object" || !Object.keys(mapping).length) { throw new Error("接口返回内容缺少有效 mapping"); } let nodeId = conversation.current_node; if (!nodeId || !mapping[nodeId]) throw new Error("current_node 缺失或不在 mapping 中"); const reverse = []; const seen = new Set(); while (nodeId) { if (seen.has(nodeId)) throw new Error(`父链循环:${nodeId}`); seen.add(nodeId); const node = mapping[nodeId]; if (!node) throw new Error(`父链断裂:${nodeId}`); reverse.push(node); if (!node.parent) break; if (!mapping[node.parent]) throw new Error(`父节点不存在:${node.parent}`); nodeId = node.parent; } return reverse.reverse(); } function normalizeMessageNode(node, warnings) { const message = node?.message; const role = message?.author?.role; if (!message || !["user", "assistant", "system", "tool"].includes(role)) return null; const text = contentToText(message.content); const files = collectFiles(message); const contentType = message.content?.content_type || message.content?.type || "unknown"; if (!text && !files.length) { if (!KNOWN_AUXILIARY_TYPES.has(contentType) && hasMeaningfulRawContent(message.content)) { warnings.push(`节点 ${node.id || message.id || "unknown"} 的内容结构无法转换:${contentType}`); } return null; } return { message_id: String(node.id || message.id || ""), role, occurred_at: toIso(message.create_time), content: text, files, model: message.metadata?.model_slug || null, }; } function allMessageNodesByTime(conversation) { return Object.entries(conversation?.mapping || {}).map(([id, node]) => ({ ...(node || {}), id: node?.id || id })) .filter((node) => node?.message) .sort((a, b) => { const time = toTimestampMs(a.message?.create_time) - toTimestampMs(b.message?.create_time); return time || String(a.id).localeCompare(String(b.id)); }); } function normalizeMessages(conversation) { const warnings = []; const normalizedMessages = []; let nodes; let mode = "active-branch"; try { const mappingValidation = validateMapping(conversation?.mapping || {}); if (!mappingValidation.ok) { nodes = allMessageNodesByTime(conversation); mode = "all-nodes-time-order"; warnings.push(`mapping 引用不完整,已忽略父子关系并按时间保存全部可读消息:${mappingValidation.errors.slice(0, 8).join(";")}`); } else { nodes = getActiveBranch(conversation); } } catch (error) { nodes = allMessageNodesByTime(conversation); mode = "all-nodes-time-order"; warnings.push(`mapping 父子链不可用,已忽略父子关系并按时间保存全部可读消息:${error.message}`); } const seen = new Set(); for (const node of nodes) { const normalized = normalizeMessageNode(node, warnings); if (!normalized || !normalized.message_id || seen.has(normalized.message_id)) continue; seen.add(normalized.message_id); normalizedMessages.push(normalized); } return { messages: normalizedMessages, warnings, mode }; } function detectClassification(item, conversation, rules = DEFAULT_CLASSIFICATION_RULES, overrides = {}) { const conversationId = String(conversation?.id || item?.id || item?.conversation_id || ""); const override = normalizeClassificationOverrides(overrides)[conversationId]; if (override?.folder) { const parts = override.folder.split("/").filter(Boolean); const title = conversation?.title || item?.title || "未命名对话"; return { kind: "本地目录", name: parts.at(-1) || override.folder, folder: override.folder, root_folder: parts[0] || "", child_folders: parts.slice(1), conversation_name: title, file_title: title, prefix: "", field: "", rule_id: null, matched_alias: false, source: "manual-folder-direct", }; } if (override?.rule_id) { const rule = normalizeRules(rules).find((candidate) => candidate.rule_id === override.rule_id); const manual = classificationFromRule(rule, conversation?.title || item?.title || "", "manual-folder"); if (manual) return manual; } const fromTitle = classifyTitle(conversation?.title || item?.title || "", rules); if (fromTitle) return fromTitle; const projectId = item?.project_id || conversation?.project_id; const projectTitle = item?.project_title || item?.project_name || conversation?.project_title; if (projectId && projectTitle) return { kind: "项目", name: cleanSegment(projectTitle) }; const gizmoId = item?.gizmo_id || conversation?.gizmo_id; const gizmoName = item?.gizmo_name || item?.gpt_name || conversation?.gizmo_name; if (gizmoId && gizmoName) return { kind: "自定义GPT", name: cleanSegment(gizmoName) }; return { kind: "未归类", name: "", source: "none" }; } function archivePaths(title, conversationId, classification) { const stemTitle = classification?.file_title || title; const stem = `${cleanSegment(stemTitle)}__${shortId(conversationId)}`; const subdir = classification?.folder ? classification.folder.split("/").filter(Boolean).map((part) => cleanSegment(part)).join("/") : classification.kind === "未归类" ? classification.kind : joinPath(classification.kind, classification.name); return { json: joinPath("JSON", subdir, `${stem}.json`), markdown: joinPath("Markdown", subdir, `${stem}.md`), }; } function buildArchive(conversation, inventoryItem = {}, rules = DEFAULT_CLASSIFICATION_RULES, overrides = {}) { const conversationId = String(conversation.id || inventoryItem.id || ""); if (!conversationId) throw new Error("完整对话数据缺少 conversation_id"); const normalized = normalizeMessages(conversation); const title = conversation.title || inventoryItem.title || "未命名对话"; const classification = detectClassification(inventoryItem, conversation, rules, overrides); return { schema_version: SCHEMA_VERSION, exporter_version: VERSION, conversation_id: conversationId, title, create_time: toIso(conversation.create_time || inventoryItem.create_time), update_time: toIso(conversation.update_time || inventoryItem.update_time), classification, messages: normalized.messages, raw: { mapping: conversation.mapping, current_node: conversation.current_node }, normalization_mode: normalized.mode, conversion_warnings: normalized.warnings, }; } function isEmptyReplacement(value) { return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0); } function fileIdentity(file) { return String(file?.id || file?.file_id || file?.asset_pointer || JSON.stringify(file)); } function mergeFiles(oldFiles, newFiles) { const merged = new Map(); for (const file of [...(oldFiles || []), ...(newFiles || [])]) { if (!file) continue; const key = fileIdentity(file); merged.set(key, { ...(merged.get(key) || {}), ...file }); } return Array.from(merged.values()); } function orderedUnion(oldItems, newItems) { return Array.from(new Set([...(oldItems || []), ...(newItems || [])])); } function mergeAdditive(oldValue, newValue, key = "") { if (isEmptyReplacement(newValue) && !isEmptyReplacement(oldValue)) return oldValue; if (oldValue === undefined || oldValue === null) return newValue; if (Array.isArray(oldValue) && Array.isArray(newValue)) { if (key === "children") return orderedUnion(oldValue, newValue); if (["files", "attachments", "content_references"].includes(key)) return mergeFiles(oldValue, newValue); return newValue.length >= oldValue.length ? newValue : oldValue; } if (typeof oldValue === "object" && typeof newValue === "object" && !Array.isArray(oldValue) && !Array.isArray(newValue)) { const merged = { ...oldValue }; for (const [childKey, value] of Object.entries(newValue)) merged[childKey] = mergeAdditive(oldValue[childKey], value, childKey); return merged; } return newValue; } function validateMapping(mapping) { const errors = []; if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) { return { ok: false, errors: ["mapping 不是对象"], roots: [], reachable: [] }; } const entries = Object.entries(mapping); const roots = entries.filter(([, node]) => !node?.parent).map(([id]) => id); if (!roots.length && entries.length) errors.push("mapping 没有根节点"); for (const [id, node] of entries) { if (!node || typeof node !== "object" || Array.isArray(node)) { errors.push(`${id} 的 mapping 节点不是对象`); continue; } if (node.parent && !mapping[node.parent]) errors.push(`${id} 的 parent ${node.parent} 不存在`); if (node.children !== undefined && node.children !== null && !Array.isArray(node.children)) { errors.push(`${id} 的 children 不是数组`); continue; } for (const child of node.children || []) { if (!mapping[child]) errors.push(`${id} 的 child ${child} 不存在`); else if (mapping[child]?.parent && mapping[child].parent !== id) errors.push(`${id} 与 child ${child} 的 parent 引用不一致`); } } const reachable = new Set(); const pendingIds = [...roots]; for (let cursor = pendingIds.length - 1; cursor >= 0; cursor = pendingIds.length - 1) { const id = pendingIds.pop(); if (reachable.has(id) || !mapping[id] || typeof mapping[id] !== "object") continue; reachable.add(id); const children = Array.isArray(mapping[id].children) ? mapping[id].children : []; for (const child of children) if (mapping[child]) pendingIds.push(child); } for (const [id] of entries) if (!reachable.has(id)) errors.push(`${id} 无法从根节点到达`); return { ok: errors.length === 0, errors, roots, reachable: Array.from(reachable) }; } function mergeMappingsConservatively(oldMappingInput, newMappingInput) { const warnings = []; const oldMapping = oldMappingInput && typeof oldMappingInput === "object" && !Array.isArray(oldMappingInput) ? oldMappingInput : {}; const newMapping = newMappingInput && typeof newMappingInput === "object" && !Array.isArray(newMappingInput) ? newMappingInput : {}; if (oldMappingInput && oldMapping !== oldMappingInput) warnings.push("旧 mapping 不是对象,已忽略无法安全解释的容器值"); if (newMappingInput && newMapping !== newMappingInput) warnings.push("最新 mapping 不是对象,已忽略无法安全解释的容器值"); const oldIds = Object.keys(oldMapping); const newIds = Object.keys(newMapping); const allIds = orderedUnion(newIds, oldIds); const preservedNodeIds = oldIds.filter((id) => !Object.prototype.hasOwnProperty.call(newMapping, id)); const merged = {}; const hasNodeObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); for (const id of allIds) { const oldNode = oldMapping[id]; const newNode = newMapping[id]; let node; if (hasNodeObject(oldNode) && hasNodeObject(newNode)) node = mergeAdditive(oldNode, newNode); else if (hasNodeObject(newNode)) node = { ...newNode }; else if (hasNodeObject(oldNode)) node = { ...oldNode }; else { node = { id, parent: null, children: [] }; warnings.push(`mapping 节点 ${id} 不是可合并对象,已保留为最小节点并标记警告`); } merged[id] = node; } const hasId = (id) => Boolean(id && Object.prototype.hasOwnProperty.call(merged, id)); const normalizeRef = (value) => { if (value === undefined || value === null || value === "") return ""; return String(value); }; const childrenFor = (mapping, label) => { const result = new Map(); for (const id of allIds) { const node = mapping[id]; if (!hasNodeObject(node) || node.children === undefined || node.children === null) { result.set(id, []); continue; } if (!Array.isArray(node.children)) { warnings.push(`${label} mapping 节点 ${id} 的 children 不是数组,已忽略该关系`); result.set(id, []); continue; } const list = []; for (const rawChild of node.children) { const child = normalizeRef(rawChild); if (!child || child === id) { if (child === id) warnings.push(`${label} mapping 节点 ${id} 包含自引用 child,已忽略`); continue; } if (!hasId(child)) { warnings.push(`${label} mapping 节点 ${id} 的 child ${child} 不存在,已忽略该损坏引用`); continue; } if (!list.includes(child)) list.push(child); } result.set(id, list); } return result; }; const oldChildren = childrenFor(oldMapping, "旧"); const newChildren = childrenFor(newMapping, "最新"); const reverseParents = (childrenMap) => { const reverse = new Map(); for (const [parentId, children] of childrenMap.entries()) { for (const childId of children) { if (!reverse.has(childId)) reverse.set(childId, []); reverse.get(childId).push(parentId); } } return reverse; }; const oldChildParents = reverseParents(oldChildren); const newChildParents = reverseParents(newChildren); const parents = {}; let usedOldFallback = false; for (const id of allIds) { const newParent = normalizeRef(newMapping[id]?.parent); const oldParent = normalizeRef(oldMapping[id]?.parent); let chosen = ""; if (newParent) { if (newParent !== id && hasId(newParent)) chosen = newParent; else warnings.push(`最新 mapping 节点 ${id} 的 parent ${newParent} 无效,不用它覆盖旧关系`); } if (!chosen && oldParent && oldParent !== id && hasId(oldParent)) { chosen = oldParent; if (newParent || Object.prototype.hasOwnProperty.call(newMapping, id)) usedOldFallback = true; } if (!chosen && oldParent && (oldParent === id || !hasId(oldParent))) { warnings.push(`旧 mapping 节点 ${id} 的 parent ${oldParent} 无效,已停止传播该损坏关系`); } if (!chosen) { const latestRefs = newChildParents.get(id) || []; const oldRefs = oldChildParents.get(id) || []; if (latestRefs.length === 1) { chosen = latestRefs[0]; if (!newParent) warnings.push(`最新 mapping 节点 ${id} 缺少 parent,已从 children 关系推定为 ${chosen}`); } else if (latestRefs.length > 1) { warnings.push(`最新 mapping 节点 ${id} 同时被多个 parent 引用,无法安全推定 parent`); } else if (oldRefs.length === 1) { chosen = oldRefs[0]; if (Object.prototype.hasOwnProperty.call(newMapping, id)) usedOldFallback = true; } else if (oldRefs.length > 1) { warnings.push(`旧 mapping 节点 ${id} 同时被多个 parent 引用,仅保留节点数据并标记警告`); } } parents[id] = chosen || null; } const findParentCycle = () => { const complete = new Set(); for (const start of allIds) { const localIndex = new Map(); const path = []; let current = start; while (current && parents[current]) { if (localIndex.has(current)) return path.slice(localIndex.get(current)); if (complete.has(current)) break; localIndex.set(current, path.length); path.push(current); current = parents[current]; } for (const id of path) complete.add(id); } return null; }; for (let guard = 0; guard <= allIds.length; guard++) { const cycle = findParentCycle(); if (!cycle) break; let repaired = false; for (const id of cycle) { const oldParent = normalizeRef(oldMapping[id]?.parent); if (oldParent && oldParent !== id && hasId(oldParent) && oldParent !== parents[id] && !cycle.includes(oldParent)) { warnings.push(`mapping parent 出现循环,节点 ${id} 已回退到旧 parent ${oldParent}`); parents[id] = oldParent; usedOldFallback = true; repaired = true; break; } } if (!repaired) { const id = cycle[0]; warnings.push(`mapping parent 出现无法可靠回退的循环,已保留节点 ${id} 但清除该 parent 关系`); parents[id] = null; } } const children = {}; for (const id of allIds) children[id] = orderedUnion(newChildren.get(id) || [], oldChildren.get(id) || []); const mergedChildParents = reverseParents(new Map(allIds.map((id) => [id, children[id] || []]))); for (const childId of allIds) { const parentId = parents[childId]; if (!parentId) continue; for (const candidateParent of mergedChildParents.get(childId) || []) { if (candidateParent === parentId) continue; children[candidateParent] = (children[candidateParent] || []).filter((id) => id !== childId); } if (!children[parentId].includes(childId)) children[parentId].push(childId); } for (const id of allIds) { merged[id] = { ...merged[id], parent: parents[id], children: children[id] || [], }; } const validation = validateMapping(merged); if (!validation.ok) warnings.push(`合并后 mapping 仍有结构警告:${validation.errors.slice(0, 8).join(";")}`); const repaired = warnings.length > 0 || usedOldFallback; if (preservedNodeIds.length) { warnings.push(`mapping 合并保留了 ${preservedNodeIds.length} 个最新响应中缺失的旧节点:${preservedNodeIds.slice(0, 8).join("、")}${preservedNodeIds.length > 8 ? "…" : ""}`); } return { mapping: merged, validation, warnings, preservedNodeIds, usedOldFallback, mode: preservedNodeIds.length ? (repaired ? "conservative-node-union-preserved-repaired" : "conservative-node-union-preserved") : (repaired ? "conservative-node-union-repaired" : "conservative-node-union"), }; } function mergeArchives(oldArchive, newArchive) { if (!oldArchive) return { archive: newArchive, preserved: false }; const oldMapping = oldArchive.raw?.mapping || {}; const newMapping = newArchive.raw?.mapping || {}; const oldMessages = oldArchive.messages || []; const newMessages = newArchive.messages || []; const messageMap = new Map(oldMessages.map((message) => [String(message.message_id), message])); for (const message of newMessages) { const id = String(message.message_id); const previous = messageMap.get(id); const merged = mergeAdditive(previous || {}, message); merged.files = mergeFiles(previous?.files, message.files); messageMap.set(id, merged); } const messages = Array.from(messageMap.values()).sort((a, b) => { const time = toTimestampMs(a.occurred_at) - toTimestampMs(b.occurred_at); return time || String(a.message_id).localeCompare(String(b.message_id)); }); const oldValidation = validateMapping(oldMapping); const newValidation = validateMapping(newMapping); const mappingWarnings = []; if (!oldValidation.ok && Object.keys(oldMapping).length) mappingWarnings.push(`旧 mapping 结构异常:${oldValidation.errors.slice(0, 8).join(";")}`); if (!newValidation.ok && Object.keys(newMapping).length) mappingWarnings.push(`最新 mapping 结构异常:${newValidation.errors.slice(0, 8).join(";")}`); const mappingMerge = mergeMappingsConservatively(oldMapping, newMapping); const mergedMapping = mappingMerge.mapping; const currentWarnings = []; const newCurrent = String(newArchive.raw?.current_node || ""); const oldCurrent = String(oldArchive.raw?.current_node || ""); let currentNode = null; if (newCurrent && Object.prototype.hasOwnProperty.call(mergedMapping, newCurrent)) currentNode = newCurrent; else { if (newCurrent) currentWarnings.push(`最新 current_node ${newCurrent} 不在合并后 mapping 中,已尝试回退旧 current_node`); if (oldCurrent && Object.prototype.hasOwnProperty.call(mergedMapping, oldCurrent)) currentNode = oldCurrent; } if (!currentNode && Object.keys(mergedMapping).length) currentWarnings.push("合并后 mapping 没有可用 current_node,已写入 null 并保留警告"); const preserved = mappingMerge.preservedNodeIds.length > 0 || newMessages.length < oldMessages.length || mappingMerge.usedOldFallback; const mergeMode = mappingMerge.validation.ok ? "message-union-conservative-mapping" : "message-union-invalid-mapping-preserved"; return { preserved, archive: { ...oldArchive, ...newArchive, create_time: oldArchive.create_time || newArchive.create_time, update_time: toTimestampMs(newArchive.update_time) >= toTimestampMs(oldArchive.update_time) ? newArchive.update_time : oldArchive.update_time, messages, raw: { mapping: mergedMapping, current_node: currentNode, }, previous_raw_summary: { node_count: Object.keys(oldMapping).length, current_node: oldArchive.raw?.current_node || null, update_time: oldArchive.update_time || null, }, merge_mode: mergeMode, mapping_merge_mode: mappingMerge.mode, mapping_validation: mappingMerge.validation, conversion_warnings: Array.from(new Set([ ...(oldArchive.conversion_warnings || []), ...(newArchive.conversion_warnings || []), ...mappingWarnings, ...mappingMerge.warnings, ...currentWarnings, ])), }, }; } function computeSyncPlan(remoteItems, localEntries, now = Date.now(), rules = null, overrides = null) { const cutoff = now - INACTIVE_DAYS * DAY_MS; const local = localEntries || {}; const plan = { active: [], add: [], update: [], skip: [], unknown: [] }; for (const item of remoteItems) { const existing = local[item.id]; const updateMs = toTimestampMs(item.update_time || item.updateTimeMs); const isActive = Boolean(updateMs && updateMs >= cutoff); const isNew = !existing; const localUpdateMs = toTimestampMs(existing?.update_time); const isUpdated = Boolean(existing && updateMs && updateMs > localUpdateMs); const isRenamed = Boolean(existing && item.title && item.title !== existing.title); const desiredClassification = existing && rules ? detectClassification(item, { id: item.id, title: item.title }, rules, overrides || {}) : null; const desiredPaths = desiredClassification ? archivePaths(item.title, item.id, desiredClassification) : null; const isRuleReclassified = Boolean(existing && desiredPaths && ( desiredPaths.json !== existing.json_path || desiredPaths.markdown !== existing.markdown_path || desiredClassification.rule_id !== existing.classification?.rule_id )); const enriched = { ...item, updateTimeMs: updateMs, isActive, reason: "" }; if (!updateMs) plan.unknown.push(enriched); if (isNew) { enriched.reason = "本地缺少"; plan.add.push(enriched); } else if (isUpdated || isRenamed || isRuleReclassified) { enriched.reason = isRuleReclassified && !isUpdated && !isRenamed ? "分类规则或目标文件夹已修改" : isRenamed && !isUpdated ? "网页端标题已修改" : "网页端已更新"; plan.update.push(enriched); } else { enriched.reason = updateMs ? "更新时间未变化" : "更新时间未知,本地已有"; plan.skip.push(enriched); } if (isActive) plan.active.push(enriched); } const queue = []; const seen = new Set(); const needed = [...plan.add, ...plan.update]; for (const group of [needed.filter((item) => item.isActive), needed.filter((item) => !item.isActive)]) { for (const item of group) { if (!seen.has(item.id)) { seen.add(item.id); queue.push(item); } } } return { ...plan, queue, localCount: Object.keys(local).length }; } function previewText(value, limit = 100) { const text = String(value || "").replace(/\s+/g, " ").trim(); return text.length > limit ? `${text.slice(0, limit)}…` : text; } function timelineFromArchives(archives) { const byId = new Map(); for (const archive of archives) { const sourcePath = archive._json_path || ""; for (const message of archive.messages || []) { const id = String(message.message_id || ""); if (!id) continue; byId.set(id, { occurred_at: message.occurred_at, conversation_id: archive.conversation_id, conversation_title: archive.title, message_id: id, role: message.role, preview: previewText(message.content), source_path: sourcePath, }); } } return Array.from(byId.values()).sort((a, b) => { const time = toTimestampMs(a.occurred_at) - toTimestampMs(b.occurred_at); return time || a.message_id.localeCompare(b.message_id); }); } function pendingPath(conversationId) { return `${META_DIR}/pending/${cleanSegment(conversationId, "unknown")}.json`; } function recoveryPath(conversationId, now = Date.now()) { const stamp = new Date(now).toISOString().replace(/[:.]/g, "-"); return `${META_DIR}/recovery/${cleanSegment(conversationId, "unknown")}__${stamp}.json`; } function createExportRevision() { return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`; } function createPendingCommit(conversationId, paths, updateTime, messageCount, exportRevision) { return { schema_version: "1.0", conversation_id: conversationId, export_revision: exportRevision, expected_update_time: updateTime || null, expected_message_count: Number(messageCount || 0), json_path: paths.json, markdown_path: paths.markdown, stage: "prepared", started_at: new Date().toISOString(), }; } function parseMarkdownMetadata(text) { const source = String(text || ""); if (!source.startsWith("---\n")) throw new Error("Markdown YAML 头缺失"); const end = source.indexOf("\n---", 4); if (end < 0) throw new Error("Markdown YAML 头未闭合"); const meta = {}; for (const line of source.slice(4, end).split(/\r?\n/)) { const match = line.match(/^([a-z_]+):\s*(.*)$/i); if (!match) continue; meta[match[1]] = match[2].replace(/^"|"$/g, ""); } meta.message_count = Number(meta.message_count); return meta; } function assessPendingCommit(record, state) { const errors = [...(state.errors || [])]; const json = state.jsonArchive; const markdown = state.markdownMeta; const index = state.indexEntry; const expectedTime = String(record.expected_update_time || ""); const expectedCount = Number(record.expected_message_count || 0); if (!json) errors.push("JSON 缺失、损坏或读取失败"); if (!markdown) errors.push("Markdown 缺失、损坏或读取失败"); if (!index) errors.push("索引条目缺失或读取失败"); const check = (source, label) => { if (!source) return; if (String(source.conversation_id || "") !== String(record.conversation_id)) errors.push(`${label} conversation_id 不一致`); if (String(source.export_revision || "") !== String(record.export_revision)) errors.push(`${label} export_revision 不一致`); if (String(source.update_time || "") !== expectedTime) errors.push(`${label} update_time 不一致`); const count = label === "JSON" ? source.messages?.length : source.message_count; if (Number(count) !== expectedCount) errors.push(`${label} message_count 不一致`); if (label === "JSON" && source.json_path && source.json_path !== record.json_path) errors.push("JSON 路径不一致"); if (label === "Markdown" && source.markdown_path && source.markdown_path !== record.markdown_path) errors.push("Markdown 路径不一致"); if (label === "索引" && (source.json_path !== record.json_path || source.markdown_path !== record.markdown_path)) errors.push("索引路径不一致"); }; check(json, "JSON"); check(markdown, "Markdown"); check(index, "索引"); return { complete: errors.length === 0, errors }; } function classificationHasExplicitTarget(classification) { return Boolean( classification?.folder && ["title-format", "manual-folder", "manual-folder-direct", "user-selection"].includes(classification.source), ); } async function assertRelocationTargetSafe(io, path, conversationId, kind) { if (!io.exists || !(await io.exists(path))) return; let targetId = ""; try { if (kind === "JSON") targetId = String((await io.readJson(path))?.conversation_id || ""); else targetId = String(parseMarkdownMetadata(await io.readText(path))?.conversation_id || ""); } catch (error) { throw new Error(`目标 ${kind} 已存在但无法验证,禁止覆盖:${path}(${error.message})`); } if (targetId !== String(conversationId)) { throw new Error(`目标 ${kind} 属于其他对话,禁止覆盖:${path}`); } } async function stageConversationWrite({ io, freshArchive, oldEntry, markdownText, now = Date.now(), exportRevision = createExportRevision() }) { const writeRecovery = async (originalPath, error) => { const recovery = recoveryPath(freshArchive.conversation_id, now); await io.writeJson(recovery, freshArchive); return { kind: "recovery", partial: true, recovery_path: recovery, original_path: originalPath, parse_error: error.message || String(error), conversation_id: freshArchive.conversation_id, }; }; let oldArchive = null; if (oldEntry?.json_path) { try { oldArchive = await io.readJson(oldEntry.json_path); if (!oldArchive?.conversation_id) throw new Error("缺少 conversation_id"); } catch (error) { return writeRecovery(oldEntry.json_path, error); } } const merged = mergeArchives(oldArchive, freshArchive); const archive = merged.archive; const desiredPaths = archivePaths(archive.title, archive.conversation_id, archive.classification); const explicitRelocation = Boolean( oldEntry?.json_path && oldEntry?.markdown_path && classificationHasExplicitTarget(archive.classification) && (oldEntry.json_path !== desiredPaths.json || oldEntry.markdown_path !== desiredPaths.markdown), ); const paths = explicitRelocation ? desiredPaths : oldEntry?.json_path && oldEntry?.markdown_path ? { json: oldEntry.json_path, markdown: oldEntry.markdown_path } : desiredPaths; if (explicitRelocation) { if (paths.json !== oldEntry.json_path) await assertRelocationTargetSafe(io, paths.json, archive.conversation_id, "JSON"); if (paths.markdown !== oldEntry.markdown_path) await assertRelocationTargetSafe(io, paths.markdown, archive.conversation_id, "Markdown"); } if (!oldEntry && io.exists && await io.exists(paths.json)) { return writeRecovery(paths.json, new Error("目标 JSON 已存在但不在可用索引中,禁止覆盖")); } archive.json_path = paths.json; archive.markdown_path = paths.markdown; archive.export_revision = exportRevision; const pending = createPendingCommit(archive.conversation_id, paths, archive.update_time, archive.messages.length, exportRevision); const pendingFile = pendingPath(archive.conversation_id); await io.writeJson(pendingFile, pending); await io.writeJson(paths.json, archive); pending.stage = "json_written"; await io.writeJson(pendingFile, pending); await io.writeText(paths.markdown, markdownText(archive)); pending.stage = "markdown_written"; await io.writeJson(pendingFile, pending); return { kind: "staged", archive, paths, pending, pending_path: pendingFile, preserved: merged.preserved, relocated_from: explicitRelocation ? { json: oldEntry.json_path, markdown: oldEntry.markdown_path } : null, }; } function indexEntryFromArchive(archive, paths = { json: archive.json_path, markdown: archive.markdown_path }) { return { conversation_id: archive.conversation_id, title: archive.title, create_time: archive.create_time, update_time: archive.update_time, message_count: archive.messages?.length || 0, export_revision: archive.export_revision, json_path: paths.json, markdown_path: paths.markdown, classification: archive.classification || { kind: "未归类", name: "" }, }; } async function recoverPendingCommit({ io, pendingFile, record, index, renderMarkdown, persistIndex }) { const errors = []; let jsonArchive = null; let markdownMeta = null; try { jsonArchive = await io.readJson(record.json_path); } catch (error) { errors.push(`JSON 读取失败:${error.message}`); } try { markdownMeta = parseMarkdownMetadata(await io.readText(record.markdown_path)); } catch (error) { errors.push(`Markdown 读取失败:${error.message}`); } let assessment = assessPendingCommit(record, { jsonArchive, markdownMeta, indexEntry: index.conversations[record.conversation_id], errors, }); if (!assessment.complete) { const jsonOnly = assessPendingCommit(record, { jsonArchive, markdownMeta: null, indexEntry: null }); const jsonValid = jsonArchive && !jsonOnly.errors.some((error) => error.startsWith("JSON ")); if (!jsonValid) return { complete: false, repaired: false, errors: assessment.errors }; try { await io.writeText(record.markdown_path, renderMarkdown(jsonArchive)); record.stage = "markdown_written"; await io.writeJson(pendingFile, record); index.conversations[record.conversation_id] = indexEntryFromArchive(jsonArchive, { json: record.json_path, markdown: record.markdown_path }); await persistIndex(index); record.stage = "index_written"; await io.writeJson(pendingFile, record); const verifiedJson = await io.readJson(record.json_path); const verifiedMarkdown = parseMarkdownMetadata(await io.readText(record.markdown_path)); assessment = assessPendingCommit(record, { jsonArchive: verifiedJson, markdownMeta: verifiedMarkdown, indexEntry: index.conversations[record.conversation_id], }); } catch (error) { return { complete: false, repaired: false, errors: [...assessment.errors, `恢复失败:${error.message}`] }; } } if (!assessment.complete) return { complete: false, repaired: false, errors: assessment.errors }; record.stage = "committed"; await io.writeJson(pendingFile, record); await io.remove(pendingFile); return { complete: true, repaired: true, errors: [] }; } async function loadOrRebuildIndex({ load, rebuild }) { try { const index = await load(); if (index?.conversations && typeof index.conversations === "object") { return { index, rebuilt: false, persisted: true, issues: [] }; } } catch { /* rebuild below */ } const result = await rebuild(); if (!result?.index?.conversations || result.persisted === false) throw new Error("索引重建或持久化失败"); return { ...result, rebuilt: true, persisted: true }; } function syncState({ written = 0, failures = [], stageFailures = [], cancelled = false, recoveries = [] }) { const safelyWritten = written + recoveries.length; if (!safelyWritten && (failures.length || stageFailures.length || cancelled)) return "同步失败"; if (failures.length || stageFailures.length || cancelled || recoveries.length) return "部分同步"; return "完整同步"; } function validatePaginationCount(actual, total) { if (total !== null && total !== undefined && actual !== total) { throw new Error(`目录分页不完整:接口 total=${total},实际读取=${actual}`); } return true; } async function fetchAllPages({ fetchPage, limit = 100, maxItems = 10000 }) { const items = []; let total = null; for (let offset = 0; offset < maxItems; offset += limit) { const page = await fetchPage({ offset, limit }); if (!page || !Array.isArray(page.items)) throw new Error(`分页 ${offset} 缺少 items`); const pageTotal = Number.isFinite(Number(page.total)) ? Number(page.total) : null; if (total === null && pageTotal !== null) total = pageTotal; else if (pageTotal !== null && total !== pageTotal) throw new Error(`分页 total 不一致:${total} -> ${pageTotal}`); if (page.items.length === 0 && total !== null && items.length < total) throw new Error(`分页提前空页:已读取 ${items.length}/${total}`); items.push(...page.items); if (total !== null) { if (items.length > total) throw new Error(`分页读取超过 total:${items.length}/${total}`); if (items.length === total) break; if (page.items.length < limit) throw new Error(`分页提前结束:已读取 ${items.length}/${total}`); } else if (page.items.length < limit) { break; } } validatePaginationCount(items.length, total); return { items, total }; } function computeCanSync({ directoryPermission, sessionOk, interfaceOk, paginationComplete, indexUsable, rebuildOk, indexPersisted }) { return Boolean(directoryPermission && sessionOk && interfaceOk && paginationComplete && indexUsable && rebuildOk && indexPersisted); } async function runQueue(items, worker, shouldCancel = () => false) { const successes = []; const failures = []; let cancelled = false; for (const item of items) { if (shouldCancel()) { cancelled = true; break; } try { successes.push(await worker(item)); } catch (error) { failures.push({ id: item.id, reason: error.message || String(error) }); } } return { successes, failures, cancelled }; } async function executeSyncWorkflow({ items, shouldCancel = () => false, processItem, applyIndex, persistIndex, finalizeCommit, writeTimeline, writeHistory, writeReport, onProgress = () => {}, }) { let position = 0; const queue = await runQueue(items, async (item) => { onProgress(++position, items.length, item); const result = await processItem(item); if (result?.kind === "staged") applyIndex(result); return result; }, shouldCancel); const staged = queue.successes.filter((item) => item?.kind === "staged"); const recoveries = queue.successes.filter((item) => item?.kind === "recovery"); const stageFailures = []; let indexPersisted = false; try { await persistIndex(); indexPersisted = true; for (const item of staged) await finalizeCommit(item); } catch (error) { stageFailures.push({ stage: "conversation-index", reason: error.message || String(error) }); } if (indexPersisted) { try { await writeTimeline(); } catch (error) { stageFailures.push({ stage: "timeline", reason: error.message || String(error) }); } } const snapshot = () => ({ written: staged.length, failures: queue.failures, recoveries, stageFailures, cancelled: queue.cancelled, state: syncState({ written: staged.length, failures: queue.failures, recoveries, stageFailures, cancelled: queue.cancelled }), }); try { await writeHistory(snapshot()); } catch (error) { stageFailures.push({ stage: "sync-history", reason: error.message || String(error) }); } try { await writeReport(snapshot()); } catch (error) { stageFailures.push({ stage: "last-report", reason: error.message || String(error) }); try { await writeHistory(snapshot()); } catch { /* 首次 history 结果已单独计入 */ } } return snapshot(); } function extractionFolderFromEntry(entry = {}) { const path = normalizeStatePath(entry.json_path || entry.markdown_path || ""); const parts = path.split("/").filter(Boolean); if (parts.length) parts.shift(); if (parts.length) parts.pop(); return parts.join("/"); } function extractionFieldFromEntry(entry = {}) { const classification = entry.classification || {}; return String(classification.field || classification.prefix || (classification.kind === "标题规则" ? classification.name : "") || "").trim(); } function normalizeExtractionOptions(options = {}) { const days = Number(options.days || 0); return { days: Number.isFinite(days) && days > 0 ? days : 0, start: String(options.start || "").trim(), end: String(options.end || "").trim(), field: String(options.field || "").trim(), folder: String(options.folder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim(), include_subfolders: options.include_subfolders !== false, keyword: String(options.keyword || "").trim().toLowerCase(), }; } function filterExtractionEntries(indexOrEntries, options = {}, now = Date.now()) { const normalized = normalizeExtractionOptions(options); const source = Array.isArray(indexOrEntries) ? indexOrEntries : Object.values(indexOrEntries?.conversations && typeof indexOrEntries.conversations === "object" ? indexOrEntries.conversations : indexOrEntries || {}); const startMs = normalized.start ? Date.parse(`${normalized.start}T00:00:00`) : 0; const endMs = normalized.end ? Date.parse(`${normalized.end}T23:59:59.999`) : 0; const cutoff = normalized.days ? now - normalized.days * DAY_MS : 0; return source.filter((entry) => { if (!entry?.conversation_id) return false; const updateMs = toTimestampMs(entry.update_time || entry.create_time); if (cutoff && (!updateMs || updateMs < cutoff)) return false; if (startMs && (!updateMs || updateMs < startMs)) return false; if (endMs && (!updateMs || updateMs > endMs)) return false; if (normalized.field && extractionFieldFromEntry(entry).toUpperCase() !== normalized.field.toUpperCase()) return false; const folder = extractionFolderFromEntry(entry); if (normalized.folder) { const matches = folder === normalized.folder || (normalized.include_subfolders && folder.startsWith(`${normalized.folder}/`)); if (!matches) return false; } if (normalized.keyword) { const haystack = `${entry.title || ""}\n${entry.conversation_id || ""}`.toLowerCase(); if (!haystack.includes(normalized.keyword)) return false; } return true; }).sort((a, b) => toTimestampMs(b.update_time || b.create_time) - toTimestampMs(a.update_time || a.create_time) || String(a.title || "").localeCompare(String(b.title || ""))); } function utf8Bytes(value) { return new TextEncoder().encode(String(value ?? "")); } let CRC32_TABLE = null; function crc32(bytes) { if (!CRC32_TABLE) { CRC32_TABLE = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); CRC32_TABLE[n] = c >>> 0; } } let crc = 0xffffffff; for (const byte of bytes) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function concatBytes(parts) { const total = parts.reduce((sum, part) => sum + part.length, 0); const output = new Uint8Array(total); let offset = 0; for (const part of parts) { output.set(part, offset); offset += part.length; } return output; } function le16(value) { return Uint8Array.of(value & 0xff, (value >>> 8) & 0xff); } function le32(value) { return Uint8Array.of(value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff); } function zipDosDateTime(date = new Date()) { const year = Math.max(1980, date.getFullYear()); const dosTime = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((Math.floor(date.getSeconds() / 2)) & 0x1f); const dosDate = (((year - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0x0f) << 5) | (date.getDate() & 0x1f); return { dosTime, dosDate }; } function createStoreZip(entries = [], date = new Date()) { const localParts = []; const centralParts = []; let offset = 0; const { dosTime, dosDate } = zipDosDateTime(date); for (const entry of entries) { const nameBytes = utf8Bytes(String(entry.name || "").replace(/\\/g, "/").replace(/^\/+/, "")); const data = entry.data instanceof Uint8Array ? entry.data : utf8Bytes(entry.data); const crc = crc32(data); const flags = 0x0800; const local = concatBytes([ le32(0x04034b50), le16(20), le16(flags), le16(0), le16(dosTime), le16(dosDate), le32(crc), le32(data.length), le32(data.length), le16(nameBytes.length), le16(0), nameBytes, data, ]); localParts.push(local); const central = concatBytes([ le32(0x02014b50), le16(20), le16(20), le16(flags), le16(0), le16(dosTime), le16(dosDate), le32(crc), le32(data.length), le32(data.length), le16(nameBytes.length), le16(0), le16(0), le16(0), le16(0), le32(0), le32(offset), nameBytes, ]); centralParts.push(central); offset += local.length; } const central = concatBytes(centralParts); const eocd = concatBytes([ le32(0x06054b50), le16(0), le16(0), le16(entries.length), le16(entries.length), le32(central.length), le32(offset), le16(0), ]); return concatBytes([...localParts, central, eocd]); } function validatePlatformAdapter(adapter) { const required = ["id", "displayName", "matches", "getConversationId", "getSession", "fetchInventory", "fetchConversation"]; const missing = required.filter((key) => { if (["id", "displayName"].includes(key)) return typeof adapter?.[key] !== "string" || !adapter[key].trim(); return typeof adapter?.[key] !== "function"; }); return { ok: missing.length === 0, missing }; } const Core = { extractConversationId, toTimestampMs, toIso, cleanSegment, computeSyncPlan, mergeArchives, mergeFiles, validateMapping, mergeMappingsConservatively, timelineFromArchives, normalizeMessages, archivePaths, normalizeRule, normalizeRules, validateRule, parseSubfolderPath, delimiterLabel, ruleFormat, parseTitleWithFormat, parseTitleByRule, titleHasPrefix, classifyTitle, detectClassification, buildArchive, pinyinInitial, suggestRuleField, folderRuleDraft, ruleTargetFolder, buildFolderAudit, buildConversationIssues, normalizeClassificationOverrides, normalizeIgnoredFolders, normalizeIgnoredTitleFormats, sharedRulesContentSignature, prepareSharedRulesWrite, classificationFromRule, pendingPath, recoveryPath, createPendingCommit, assessPendingCommit, classificationHasExplicitTarget, stageConversationWrite, createExportRevision, parseMarkdownMetadata, recoverPendingCommit, indexEntryFromArchive, loadOrRebuildIndex, syncState, validatePaginationCount, fetchAllPages, computeCanSync, runQueue, executeSyncWorkflow, validatePlatformAdapter, emptyConversationState, normalizeConversationState, emptyDeletedConversations, normalizeDeletedConversations, emptyFolderState, normalizeFolderState, stateEntryFromIndexEntry, buildObservedConversationMap, mirrorKeyForPath, representationChange, detectConversationChanges, loadConversationState, persistConversationState, loadDeletedConversations, persistDeletedConversations, loadFolderState, persistFolderState, folderTreeHasContent, folderSnapshotFromAudit, detectFolderChanges, conversationIdsInFolder, conversationIdsDirectlyInFolder, validRuleField, extractionFolderFromEntry, extractionFieldFromEntry, normalizeExtractionOptions, filterExtractionEntries, uniqueFlatExtractionName, collectDirectoryFilesFlat, copyDirectoryContentsFlat, crc32, createStoreZip, }; if (typeof module !== "undefined" && module.exports) module.exports = Core; if (typeof window === "undefined" || typeof document === "undefined") return; let ui = null; let running = false; let cancelRequested = false; let cachedDirectoryHandle = null; let directoryHandleLoaded = false; let classificationRules = loadClassificationRules(); let classificationOverrides = {}; let ignoredFolders = []; let ignoredRemoteTitleFormats = []; let sharedRulesBase = { revision: 0, signature: "" }; function loadClassificationRules() { try { const currentText = localStorage.getItem(RULES_STORAGE_KEY); if (currentText !== null) return normalizeRules(JSON.parse(currentText)); const legacyText = LEGACY_RULES_STORAGE_KEYS .map((key) => localStorage.getItem(key)) .find((text) => text !== null); const legacyRules = legacyText != null ? JSON.parse(legacyText) : DEFAULT_CLASSIFICATION_RULES; const pendingRules = normalizeRules(legacyRules); localStorage.setItem(RULES_STORAGE_KEY, JSON.stringify(pendingRules)); return pendingRules; } catch { /* use defaults */ } return normalizeRules(DEFAULT_CLASSIFICATION_RULES); } function saveClassificationRules(rules) { classificationRules = normalizeRules(rules); localStorage.setItem(RULES_STORAGE_KEY, JSON.stringify(classificationRules)); renderClassificationRules(); } async function readSharedClassificationRules(root, { create = true } = {}) { const shared = await readJson(root, SHARED_RULES_PATH); if (shared && Array.isArray(shared.rules)) { const rules = normalizeRules(shared.rules); classificationOverrides = normalizeClassificationOverrides(shared.conversation_overrides); ignoredFolders = normalizeIgnoredFolders(shared.ignored_folders); ignoredRemoteTitleFormats = normalizeIgnoredTitleFormats(shared.ignored_title_formats); sharedRulesBase = { revision: Number(shared.revision || 0), signature: sharedRulesContentSignature(shared), }; saveClassificationRules(rules); return { rules, overrides: classificationOverrides, source: "shared-file" }; } if (create) { sharedRulesBase = { revision: 0, signature: "" }; const rules = normalizeRules(classificationRules); const created = prepareSharedRulesWrite(null, sharedRulesBase, { rules, conversation_overrides: classificationOverrides, ignored_folders: ignoredFolders, ignored_title_formats: ignoredRemoteTitleFormats, }); await writeJson(root, SHARED_RULES_PATH, created); sharedRulesBase = { revision: created.revision, signature: sharedRulesContentSignature(created) }; return { rules, overrides: classificationOverrides, source: "created-from-browser-rules" }; } return { rules: classificationRules, overrides: classificationOverrides, source: "browser-rules" }; } async function writeSharedClassificationRules(root) { const current = await readJson(root, SHARED_RULES_PATH); const next = prepareSharedRulesWrite(current, sharedRulesBase, { rules: classificationRules, conversation_overrides: classificationOverrides, ignored_folders: ignoredFolders, ignored_title_formats: ignoredRemoteTitleFormats, }); if (current) { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); await writeJson(root, `${META_DIR}/backups/classification-rules-r${Number(current.revision || 0)}__${stamp}.json`, current); } await writeJson(root, SHARED_RULES_PATH, next); const verified = await readJson(root, SHARED_RULES_PATH); if (Number(verified?.revision || 0) !== next.revision || sharedRulesContentSignature(verified) !== sharedRulesContentSignature(next)) { throw new Error("共享分类规则写入后校验失败,已保留上一版备份。"); } sharedRulesBase = { revision: next.revision, signature: sharedRulesContentSignature(next) }; } async function readObservedFileIdentity(handle, kind, path) { const file = await handle.getFile(); const head = await file.slice(0, Math.min(file.size, 65_536)).text(); let conversationId = ""; if (kind === "JSON") { conversationId = head.match(/"conversation_id"\s*:\s*"([^"]+)"/)?.[1] || ""; if (!conversationId && file.size > head.length) { const data = JSON.parse(await file.text()); conversationId = String(data?.conversation_id || ""); } } else { conversationId = String(parseMarkdownMetadata(head)?.conversation_id || ""); } return conversationId ? { conversation_id: conversationId, kind, path: joinPath(kind, path) } : null; } async function conversationIdsDirectlyInFolder(root, folderPath) { const folder = String(folderPath || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); const ids = new Set(); if (!folder) return []; for (const kind of ["JSON", "Markdown"]) { try { const directory = await getDirectory(root, joinPath(kind, folder), false); for await (const [name, handle] of directory.entries()) { if (handle.kind !== "file") continue; if (kind === "JSON" && !/\.json$/i.test(name)) continue; if (kind === "Markdown" && !/\.md$/i.test(name)) continue; try { const observed = await readObservedFileIdentity(handle, kind, joinPath(folder, name)); if (observed?.conversation_id) ids.add(String(observed.conversation_id)); } catch { /* 单个异常文件不阻止登记其他正常对话 */ } } } catch (error) { if (error?.name !== "NotFoundError") throw error; } } return [...ids]; } async function auditExportFolders(root, onProgress = () => {}) { const inventory = { folders: { JSON: new Set(), Markdown: new Set() }, files: { JSON: new Set(), Markdown: new Set() }, }; const observedFiles = []; const unreadableFiles = []; const unreadableEntries = []; const rootsPresent = { JSON: false, Markdown: false }; let scannedFiles = 0; const scan = async (directory, kind, prefix = "") => { for await (const [name, handle] of directory.entries()) { if (cancelRequested) throw new Error("用户已取消扫描"); const path = joinPath(prefix, name); if (handle.kind === "directory") { inventory.folders[kind].add(path); await scan(handle, kind, path); } else if ((kind === "JSON" && /\.json$/i.test(path)) || (kind === "Markdown" && /\.md$/i.test(path))) { inventory.files[kind].add(path); scannedFiles++; onProgress({ scannedFiles, kind, path }); try { const observed = await readObservedFileIdentity(handle, kind, path); if (observed) observedFiles.push(observed); else { unreadableFiles.push(`${kind}/${path}: 缺少 conversation_id`); unreadableEntries.push({ kind, path: joinPath(kind, path), reason: "缺少 conversation_id" }); } } catch (error) { unreadableFiles.push(`${kind}/${path}: ${error.message}`); unreadableEntries.push({ kind, path: joinPath(kind, path), reason: error.message || String(error) }); } } if (cancelRequested) throw new Error("用户已取消扫描"); } }; for (const kind of ["JSON", "Markdown"]) { try { const directory = await getDirectory(root, kind, false); rootsPresent[kind] = true; await scan(directory, kind); } catch (error) { if (error?.name !== "NotFoundError") throw error; } } const audit = buildFolderAudit({ folders: { JSON: [...inventory.folders.JSON], Markdown: [...inventory.folders.Markdown], }, files: { JSON: [...inventory.files.JSON], Markdown: [...inventory.files.Markdown], }, }, classificationRules); const index = await loadIndex(root); const stateLayer = await ensureConversationState(root, index, observedFiles); const hasStateBaseline = Object.keys(stateLayer.state.conversations || {}).length > 0; const scanTrusted = (rootsPresent.JSON && rootsPresent.Markdown) || (!hasStateBaseline && observedFiles.length === 0); const stateChangeResult = detectConversationChanges(stateLayer.state, observedFiles, { unreadableFiles: unreadableEntries, scanTrusted, }); audit.indexPathDrifts = []; audit.observedFiles = observedFiles; audit.unreadableFiles = unreadableFiles; audit.unreadableEntries = unreadableEntries; audit.rootsPresent = rootsPresent; audit.scanTrusted = scanTrusted; audit.conversationState = stateLayer.state; audit.stateBaselineCreated = stateLayer.created; audit.stateChangeResult = stateChangeResult; audit.conversationChanges = stateChangeResult.changes; const folderState = await loadFolderState(root); const folderChangeResult = detectFolderChanges(folderState, audit, classificationRules); audit.folderState = folderState; audit.folderChangeResult = folderChangeResult; audit.folderChanges = folderChangeResult.changes; const autoHandledIds = new Set(stateChangeResult.changes .filter((item) => ["move", "delete"].includes(item.type)) .map((item) => item.conversation_id)); const detectedIssues = buildConversationIssues(index, observedFiles, classificationRules, classificationOverrides) .filter((issue) => !autoHandledIds.has(issue.conversation_id)); audit.missingFileIssues = detectedIssues.filter(isMissingOnlyIssue); audit.conversationIssues = detectedIssues.filter((issue) => !isMissingOnlyIssue(issue)); const folderIsIgnored = (folder) => ignoredFolders.some((ignored) => folder === ignored || folder.startsWith(`${ignored}/`)); // “忽略”既可以表示不再提示登记,也可以暂存一个空目录决定。 // 暂存的决定从当前待处理队列移走,但保留在 audit 里供“之前忽略的项目”展示和恢复。 audit.ignoredFolderDecisions = (audit.folderChanges || []) .filter((item) => item.type === "decide-empty-folder" && folderIsIgnored(item.folder)); audit.folderChanges = (audit.folderChanges || []) .filter((item) => !(item.type === "decide-empty-folder" && folderIsIgnored(item.folder))); if (audit.folderChangeResult) audit.folderChangeResult = { ...audit.folderChangeResult, changes: audit.folderChanges }; audit.onlyJsonFolders = audit.onlyJsonFolders.filter((folder) => folder !== SYSTEM_INBOX_FOLDER && !folderIsIgnored(folder)); audit.onlyMarkdownFolders = audit.onlyMarkdownFolders.filter((folder) => folder !== SYSTEM_INBOX_FOLDER && !folderIsIgnored(folder)); // 新建/手动移动到一个尚未登记规则的目录后,这条建议应一直保留, // 直到用户明确“登记”或“忽略”。不能因为索引已经跟着移动就把建议吃掉。 // 但如果这个目录当前正处于“空目录只剩一边”的选择状态,就不要在下面重复显示一遍登记建议。 const pendingEmptyFolderPaths = new Set((audit.folderChanges || []) .filter((item) => item.type === "decide-empty-folder") .map((item) => item.folder)); audit.unregisteredFolders = audit.unregisteredFolders .filter((item) => !folderIsIgnored(item.path) && !pendingEmptyFolderPaths.has(item.path)); if (index?.conversations) { audit.indexPathDrifts = audit.conversationIssues .filter((issue) => issue.types.includes("index-drift")) .map((issue) => ({ conversation_id: issue.conversation_id, title: issue.title, expected_json: issue.indexed_json, actual_json: issue.json_paths[0] || null, expected_markdown: issue.indexed_markdown, actual_markdown: issue.markdown_paths[0] || null, })); } const unresolvedFileIssue = audit.conversationIssues.some((issue) => issue.types.some((type) => [ "missing-json", "missing-markdown", "duplicate-json", "duplicate-markdown", "split-folders", "index-drift", "wrong-target", ].includes(type))); audit.hasDrift = Boolean( audit.indexPathDrifts.length || unresolvedFileIssue || audit.missingFileIssues.length || audit.unreadableFiles.length || stateChangeResult.changes.some((item) => ["conflict", "uncertain"].includes(item.type)) || (audit.folderChanges || []).some((item) => item.type === "decide-empty-folder") ); return audit; } function folderAuditLines(audit) { const lines = []; if (audit.unregisteredFolders.length) lines.push(`可选分类目录:${audit.unregisteredFolders.map((item) => item.path).join("、")}`); if (audit.folderChanges?.some((item) => item.type === "decide-empty-folder")) lines.push(`有 ${audit.folderChanges.filter((item) => item.type === "decide-empty-folder").length} 个空目录需要你选择:删除剩下的一边,或把缺的一边补回来。`); if (audit.missingRuleTargets.length) lines.push(`规则目标目录待自动补齐:${audit.missingRuleTargets.join("、")}`); if (audit.missingFileIssues?.length) lines.push(`本地文件缺失待确认:${audit.missingFileIssues.length} 个对话`); if (audit.pathMismatches.length) lines.push(`JSON 与 Markdown 位于不同分类目录:${audit.pathMismatches.length} 对`); if (audit.indexPathDrifts?.length) lines.push(`发现文件管理器手动移动、但索引尚未同步:${audit.indexPathDrifts.length} 个对话`); if (audit.conversationIssues?.length) lines.push(`需要整理的对话:${audit.conversationIssues.length} 个`); if (audit.unreadableFiles?.length) lines.push(`无法识别或读取的文件:${audit.unreadableFiles.length} 个`); if (audit.stateChangeResult?.trusted === false) lines.push(`本地变化判断暂停:${audit.stateChangeResult.reason}`); if (audit.conversationChanges?.length) { const counts = audit.conversationChanges.reduce((acc, item) => { acc[item.type] = (acc[item.type] || 0) + 1; return acc; }, {}); lines.push(`本地对话变化:移动 ${counts.move || 0}|删除 ${counts.delete || 0}|冲突 ${counts.conflict || 0}|无法判断 ${counts.uncertain || 0}|补齐 ${counts.repair || 0}`); } return lines; } const ISSUE_LABELS = { "missing-json": "缺少 JSON", "missing-markdown": "缺少 Markdown", "duplicate-json": "多个 JSON 副本", "duplicate-markdown": "多个 Markdown 副本", "split-folders": "双格式分居", "index-drift": "索引位置不一致", "unclassified": "未归类", "wrong-target": "不在规则目标目录", }; function isMissingOnlyIssue(issue) { const types = new Set(issue?.types || []); return types.size > 0 && [...types].every((type) => ["missing-json", "missing-markdown"].includes(type)); } function missingIssueText(issue) { const missingJson = issue?.types?.includes("missing-json"); const missingMarkdown = issue?.types?.includes("missing-markdown"); if (missingJson && missingMarkdown) return "JSON 和 Markdown 都已经找不到。若这是你之前手动删除的,直接确认即可;插件会清理索引并记录删除状态。"; if (missingJson) return "JSON 已经找不到,但 Markdown 还在。若你本来就是要删除这个对话,可以删除整个对话;否则先不要操作。"; if (missingMarkdown) return "Markdown 已经找不到,但 JSON 还在。若你本来就是要删除这个对话,可以删除整个对话;否则先不要操作。"; return "本地文件状态需要确认。"; } function renderConversationIssues(audit = ui?._folderAudit) { if (!ui?.organizer || !ui?.issueList || !ui?.issueTargetRule) return; const issues = (audit?.conversationIssues || []).filter((issue) => !isMissingOnlyIssue(issue)); ui.organizer.hidden = !issues.length; ui.issueTargetRule.innerHTML = '' + classificationRules.filter((rule) => !rule.format_pending).map((rule) => ``).join(""); ui.issueList.innerHTML = issues.map((issue) => { const types = issue.types.map((type) => ISSUE_LABELS[type] || type).join("、"); const paths = [...issue.json_paths, ...issue.markdown_paths].join("\n") || "未找到实际文件"; return ``; }).join(""); ui.issuePreview.hidden = true; ui.issuePreview.textContent = ""; } function selectedConversationIssueIds() { return [...(ui?.issueList?.querySelectorAll('[data-role="issue-select"]:checked') || [])] .map((input) => input.value); } function selectConversationIssuesForFolder(folderPath) { const issueIds = new Set((ui?._folderAudit?.conversationIssues || []) .filter((issue) => [...issue.json_paths, ...issue.markdown_paths] .some((path) => path.split("/").slice(1, -1).join("/") === folderPath)) .map((issue) => issue.conversation_id)); for (const input of ui.issueList.querySelectorAll('[data-role="issue-select"]')) input.checked = issueIds.has(input.value); if (issueIds.size) setManageScene("organizer", 1); setStatus(issueIds.size ? `已选中 ${issueIds.size} 个对话,请选择最终分类。` : "这个文件夹中没有需要按 conversation_id 整理的对话。", issueIds.size ? "normal" : "warning"); } function buildSelectedMigrationPreview() { const ids = selectedConversationIssueIds(); const rule = classificationRules.find((item) => item.rule_id === ui.issueTargetRule.value); if (!ids.length) throw new Error("请先选择至少一个对话"); if (!rule) throw new Error("请选择目标分类"); const issueMap = new Map((ui._folderAudit?.conversationIssues || []).map((issue) => [issue.conversation_id, issue])); const lines = ids.map((id) => { const issue = issueMap.get(id); const classification = classificationFromRule(rule, issue?.title, "user-selection"); const desired = archivePaths(issue?.title, id, classification); return `${issue?.title || id}\n JSON -> ${desired.json}\n Markdown -> ${desired.markdown}`; }); const text = `将 ${ids.length} 个对话归入 ${ruleTargetFolder(rule)}\n\n${lines.slice(0, 30).join("\n\n")}${lines.length > 30 ? `\n\n另有 ${lines.length - 30} 个对话` : ""}`; ui.issuePreview.hidden = false; ui.issuePreview.textContent = text; return { ids, rule, text }; } function stateChangeDetailText(item) { const label = item.kind === "json" ? "JSON" : "Markdown"; if (item.type === "none") return `${label}:未变化`; if (item.type === "delete") return `${label}:${item.from || "(无)"} -> [删除]`; if (item.type === "move") return `${label}:${item.from || "(无)"} -> ${item.to || "(无)"}`; if (item.type === "add") return `${label}:[无基线] -> ${item.to || "(无)"}`; if (item.type === "ambiguous") return `${label}:发现多个副本 ${item.candidates.join(" | ")}`; if (item.type === "uncertain") return `${label}:无法确认(${item.reason || "状态不明确"})`; return `${label}:${item.type}`; } function renderConversationStateChanges(audit = ui?._folderAudit) { if (!ui?.stateChanges || !ui?.stateChangesList) return; const result = audit?.stateChangeResult || { trusted: true, changes: [] }; const changes = result.changes || []; const missingIssues = audit?.missingFileIssues || []; const folderDecisions = (audit?.folderChanges || []).filter((item) => item.type === "decide-empty-folder"); if (!result.trusted) { ui.stateChanges.hidden = false; if (ui.stateChangesProgress) ui.stateChangesProgress.textContent = "需要重新检查"; ui.stateChanges.dataset.kind = "warning"; ui.stateChangesList.innerHTML = `
本次扫描不完整为了避免误删,插件没有自动判断本地变化。
`; return; } const rows = []; for (const change of changes) { const css = ["conflict", "uncertain"].includes(change.type) ? " conflict" : ""; let detail = ""; let button = ""; if (change.type === "delete") { const deletedSide = (change.representations || []).filter((item) => item.type === "delete").map((item) => item.kind === "json" ? "JSON" : "Markdown").join("、"); detail = `${deletedSide || "本地文件"} 已删除。确认后会把剩余文件移入“已删除”,并记住这次删除。`; button = ``; } else if (change.type === "move") { detail = `这个对话被移动到了:${change.target_folder ? escapeHtml(change.target_folder) : "根目录"}。另一种格式还没跟过去。`; button = ``; } else if (change.type === "conflict") { detail = "JSON 和 Markdown 被改到了不同位置,需要你决定最终放在哪里。"; button = ``; } else if (change.type === "uncertain") { detail = "发现重复文件或无法确认的位置,插件先停在这里,不自动改文件。"; button = ``; } else { detail = "发现需要补齐的本地状态。"; button = ``; } rows.push(`
${escapeHtml(change.title || change.conversation_id)}${detail}
${button}
`); } for (const issue of missingIssues) { const bothMissing = issue.types.includes("missing-json") && issue.types.includes("missing-markdown"); rows.push(`
${escapeHtml(issue.title || issue.conversation_id)}${escapeHtml(missingIssueText(issue))}
`); } for (const change of folderDecisions) { const where = change.present_in || "这一边"; const missing = change.missing_in || "另一边"; const detail = change.previously_paired ? `你删掉了 ${missing} 里的这个空目录,${where} 里还留着一个。` : `这个空目录现在只在 ${where} 里。`; rows.push(`
${escapeHtml(change.folder)}${escapeHtml(detail)}
`); } ui.stateChanges.hidden = !rows.length; ui.stateChanges.dataset.kind = rows.some((row) => row.includes("conflict")) ? "warning" : "normal"; ui.stateChangesList.innerHTML = rows[0] || ""; if (ui.stateChangesProgress) ui.stateChangesProgress.textContent = rows.length > 1 ? `1 / ${rows.length}` : rows.length ? "当前这一件" : ""; } function renderFolderDiscoveries(audit = ui?._folderAudit) { renderConversationStateChanges(audit); if (audit?.folders) populateRuleFolderChoices([...(audit.folders.JSON || []), ...(audit.folders.Markdown || [])]); renderIgnoredItems(); if (!ui?.folderDiscovery || !ui?.folderDiscoveryList) return; const unregistered = audit?.unregisteredFolders || []; ui.folderDiscovery.hidden = !unregistered.length; ui.folderDiscovery.dataset.kind = "normal"; if (!unregistered.length) { ui.folderDiscoveryList.innerHTML = ""; if (ui.folderDiscoveryProgress) ui.folderDiscoveryProgress.textContent = ""; renderConversationIssues(audit); updateManageHomeSummary(audit); return; } const item = unregistered[0]; const location = item.present_in.length === 2 ? "JSON、Markdown 两边都有" : `目前只在 ${item.present_in.join("、")} 里`; const hasContent = (item.present_in || []).some((kind) => folderTreeHasContent(audit, kind, item.path)); const canOfferDelete = item.present_in.length === 1 || !hasContent; const deleteButton = canOfferDelete ? `` : ""; ui.folderDiscoveryList.innerHTML = `
${escapeHtml(item.path)}${escapeHtml(location)}。如果以后想让这类对话自动进这里,就登记;不想管就忽略。
${deleteButton}
`; if (ui.folderDiscoveryProgress) ui.folderDiscoveryProgress.textContent = unregistered.length > 1 ? `1 / ${unregistered.length}` : "当前这一件"; renderConversationIssues(audit); updateManageHomeSummary(audit); } function currentPathForRepresentation(change, kind) { const item = (change?.representations || []).find((candidate) => candidate.kind === kind); if (!item) return ""; if (["none", "move", "add"].includes(item.type)) return normalizeStatePath(item.to || item.from || ""); return ""; } function deletedDestination(path, kind) { const normalized = normalizeStatePath(path); const prefix = kind === "json" ? "JSON/" : "Markdown/"; const relative = normalized.toLowerCase().startsWith(prefix.toLowerCase()) ? normalized.slice(prefix.length) : normalized; return joinPath(SYSTEM_DELETED_FOLDER, kind === "json" ? "JSON" : "Markdown", relative); } async function moveTextSafely(root, source, destination, conversationId, kind) { if (!source || source === destination) return destination; const io = directoryIo(root); await assertRelocationTargetSafe(io, destination, conversationId, kind === "json" ? "JSON" : "Markdown"); const text = await readText(root, source); if (text === null) return ""; await writeText(root, destination, text); await removeFile(root, source); try { await removeEmptyAncestorDirectories(root, source); } catch { /* best effort */ } return destination; } async function applyDetectedDelete(root, change) { const id = String(change.conversation_id || ""); const index = (await loadIndex(root)) || emptyIndex(); const state = await loadConversationState(root); const deleted = await loadDeletedConversations(root); const jsonSource = currentPathForRepresentation(change, "json"); const markdownSource = currentPathForRepresentation(change, "markdown"); let deletedJson = ""; let deletedMarkdown = ""; if (jsonSource) deletedJson = await moveTextSafely(root, jsonSource, deletedDestination(jsonSource, "json"), id, "json"); if (markdownSource) deletedMarkdown = await moveTextSafely(root, markdownSource, deletedDestination(markdownSource, "markdown"), id, "markdown"); const previous = state.conversations[id] || stateEntryFromIndexEntry(index.conversations[id] || {}); state.conversations[id] = normalizeStateEntry({ ...previous, title: change.title || previous.title, state: "deleted", last_known: { json_path: deletedJson || "", markdown_path: deletedMarkdown || "" }, baseline_at: new Date().toISOString(), }); deleted.deleted[id] = { title: change.title || previous.title || "未命名对话", deleted_at: new Date().toISOString(), source: "local-manager" }; delete index.conversations[id]; if (classificationOverrides[id]) { delete classificationOverrides[id]; await writeSharedClassificationRules(root); } await persistIndexes(root, index); await persistConversationState(root, state); await persistDeletedConversations(root, deleted); try { const archives = await readIndexedArchives(root, index); if (!archives.issues.length) await writeJson(root, "timeline.json", timelineFromArchives(archives.archives)); } catch { /* keep old timeline on non-critical failure */ } return { conversation_id: id, type: "delete" }; } async function confirmMissingIssueDeleted(conversationId) { const issue = (ui?._folderAudit?.missingFileIssues || []).find((item) => item.conversation_id === conversationId); if (!issue) throw new Error("这条文件缺失记录已经不存在,请重新检查"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const makeRepresentation = (kind, actualPaths, indexedPath) => { const actual = actualPaths?.[0] || ""; return actual ? { kind, type: "none", from: actual, to: actual, candidates: [actual] } : { kind, type: "delete", from: indexedPath || "", to: "", candidates: [] }; }; const change = { conversation_id: issue.conversation_id, title: issue.title, type: "delete", representations: [ makeRepresentation("json", issue.json_paths, issue.indexed_json), makeRepresentation("markdown", issue.markdown_paths, issue.indexed_markdown), ], }; setStatus(`正在确认删除:${issue.title}`, "normal", true); await applyDetectedDelete(directory.handle, change); const audit = await auditExportFolders(directory.handle); ui._folderAudit = audit; renderFolderDiscoveries(audit); setStatus(`已确认删除:${issue.title}\n已记住这次删除;以后导出不会把它自动加回来。`, "ok"); } async function applyDetectedMove(root, change) { const id = String(change.conversation_id || ""); const target = normalizeStatePath(change.target || ""); if (!target) throw new Error("无法确定移动目标"); const targetFolder = String(change.target_folder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); const index = (await loadIndex(root)) || emptyIndex(); const jsonSource = currentPathForRepresentation(change, "json"); if (!jsonSource) throw new Error("移动需要可读取的 JSON 档案;当前 JSON 不存在或状态不明确"); const archive = await readJson(root, jsonSource); if (String(archive?.conversation_id || "") !== id) throw new Error("当前 JSON conversation_id 与待移动对话不一致"); const jsonDestination = `JSON/${target}.json`; const markdownDestination = `Markdown/${target}.md`; const folderParts = targetFolder.split("/").filter(Boolean); archive.classification = { kind: "本地目录", name: folderParts.at(-1) || targetFolder || "根目录", folder: targetFolder, root_folder: folderParts[0] || "", child_folders: folderParts.slice(1), conversation_name: archive.title || change.title || "未命名对话", file_title: archive.title || change.title || "未命名对话", prefix: "", field: "", rule_id: null, matched_alias: false, source: "manual-folder-direct", }; archive.json_path = jsonDestination; archive.markdown_path = markdownDestination; const io = directoryIo(root); await assertRelocationTargetSafe(io, jsonDestination, id, "JSON"); await assertRelocationTargetSafe(io, markdownDestination, id, "Markdown"); await writeJson(root, jsonDestination, archive); await writeText(root, markdownDestination, buildMarkdown(archive)); for (const kind of ["json", "markdown"]) { const source = currentPathForRepresentation(change, kind); const destination = kind === "json" ? jsonDestination : markdownDestination; if (source && source !== destination) { try { await removeFile(root, source); // 这里故意只移动文件,不顺手删除原文件夹。 // 用户在资源管理器里“把最后一个文件移走”并不等于“删除这个文件夹”; // 保留两边的空目录,后续如果用户真的删除其中一边,再由目录状态逻辑同步删除另一边。 } catch (error) { if (error?.name !== "NotFoundError") throw error; } } } index.conversations[id] = indexEntryFromArchive(archive, { json: jsonDestination, markdown: markdownDestination }); classificationOverrides[id] = { rule_id: null, folder: targetFolder, source: "manual-folder-direct", updated_at: new Date().toISOString() }; await writeSharedClassificationRules(root); await persistIndexes(root, index); const state = await loadConversationState(root); state.conversations[id] = stateEntryFromIndexEntry(index.conversations[id], new Date().toISOString()); await persistConversationState(root, state); return { conversation_id: id, type: "move", json_path: jsonDestination, markdown_path: markdownDestination }; } async function applyDetectedConversationChange(conversationId) { const change = (ui?._folderAudit?.conversationChanges || []).find((item) => item.conversation_id === conversationId); if (!change) throw new Error("这条本地变化已经不存在,请重新扫描"); if (!["move", "delete"].includes(change.type)) throw new Error("这条变化需要人工判断,不能自动应用"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); setStatus(change.type === "delete" ? `正在处理删除:${change.title}` : `正在处理移动:${change.title}`, "normal", true); if (change.type === "delete") await applyDetectedDelete(directory.handle, change); else await applyDetectedMove(directory.handle, change); const remaining = (ui._folderAudit.conversationChanges || []).filter((item) => item.conversation_id !== conversationId); ui._folderAudit.conversationChanges = remaining; if (ui._folderAudit.stateChangeResult) ui._folderAudit.stateChangeResult.changes = remaining; renderConversationStateChanges(ui._folderAudit); setStatus(`${change.type === "delete" ? "已删除" : "已处理移动"}:${change.title}\n只处理了这一个对话,没有重新下载其他网页对话。`, "ok"); } async function applyAllDetectedConversationChanges() { const changes = (ui?._folderAudit?.conversationChanges || []).filter((item) => ["move", "delete"].includes(item.type)); if (!changes.length) throw new Error("没有可以自动处理的本地变化"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); let done = 0; const failures = []; for (const change of changes) { try { setStatus(`正在应用本地变化 ${done + 1}/${changes.length}\n${change.title}`, "normal", true); if (change.type === "delete") await applyDetectedDelete(directory.handle, change); else await applyDetectedMove(directory.handle, change); done++; } catch (error) { failures.push(`${change.title}:${error.message || error}`); } } const audit = await auditExportFolders(directory.handle); ui._folderAudit = audit; populateRuleFolderChoices([...(audit.folders?.JSON || []), ...(audit.folders?.Markdown || [])]); renderFolderDiscoveries(audit); setStatus(`本地管理完成\n已处理 ${done} 个|失败 ${failures.length} 个\n这里只处理本地变化,没有下载网页端对话正文。${failures.length ? `\n${failures.slice(0, 8).join("\n")}` : ""}`, failures.length ? "warning" : "ok"); } function inferRemoteTitleCandidate(title) { const text = String(title || "").trim(); let match = text.match(/^([A-Z][A-Z0-9]{0,15})(\s+|[-/_::])(.+)$/); if (!match) match = text.match(/^([\u3400-\u9fff]{1,8})([-/_::])(.+)$/); if (!match) return null; const connector = /^\s+$/.test(match[2]) ? " " : match[2] === ":" ? ":" : match[2]; return { field: match[1].toUpperCase(), connector, example: text }; } async function scanRemoteTitleRules() { setStatus("正在读取 ChatGPT 对话标题;只读取目录标题,不下载对话正文……", "normal", true); const adapter = resolvePlatformAdapter(); const session = await adapter.getSession(); if (!session.ok) throw new Error("当前 ChatGPT 登录状态不可用"); const inventory = await adapter.fetchInventory(session.token); const groups = new Map(); for (const item of inventory.items || []) { if (classifyTitle(item.title || "", classificationRules)) continue; const candidate = inferRemoteTitleCandidate(item.title); if (!candidate) continue; const key = ignoredTitleFormatKey(candidate.field, candidate.connector); if (ignoredRemoteTitleFormats.some((ignored) => ignoredTitleFormatKey(ignored.field, ignored.connector) === key)) continue; if (!groups.has(key)) groups.set(key, { ...candidate, count: 0, examples: [] }); const group = groups.get(key); group.count++; if (group.examples.length < 3) group.examples.push(item.title); } const candidates = [...groups.values()].sort((a, b) => b.count - a.count || a.field.localeCompare(b.field)); ui._remoteRuleCandidates = candidates; renderRemoteRuleCandidates(candidates); setStatus(candidates.length ? `网页标题扫描完成\n发现 ${candidates.length} 组可选命名建议;可以登记,也可以忽略。` : "网页标题扫描完成:没有新的命名建议。", candidates.length ? "normal" : "ok"); } function renderRemoteRuleCandidates(candidates = ui?._remoteRuleCandidates || []) { if (!ui?.remoteRuleCandidates) return; ui.remoteRuleCandidates.hidden = !candidates.length; ui.remoteRuleCandidates.innerHTML = candidates.map((item, index) => `
${escapeHtml(item.field)} ${escapeHtml(delimiterLabel(item.connector))}${item.count} 个网页对话 · ${escapeHtml(item.examples[0] || "")}
`).join(""); } function prefillRemoteRuleCandidate(index) { const candidate = (ui?._remoteRuleCandidates || [])[Number(index)]; if (!candidate) throw new Error("网页命名候选已失效,请重新扫描"); ui.ruleField.value = candidate.field; const connectorMap = { "": "__none__", " ": "__space__", "-": "-", "/": "/", "_": "_", ":": ":" }; ui.ruleConnector.value = connectorMap[candidate.connector] || "__custom__"; ui.ruleConnectorCustom.hidden = ui.ruleConnector.value !== "__custom__"; if (!ui.ruleConnectorCustom.hidden) ui.ruleConnectorCustom.value = candidate.connector; ui.ruleRootFolder.value = ""; if (ui.ruleRootFolderChoice) ui.ruleRootFolderChoice.value = ""; ui.ruleFolderSeparator.value = "__none__"; ui.ruleSubfolderField.hidden = true; updateRulePreview(); openRuleEditorAndJump(); setTimeout(() => ui?.ruleRootFolder?.focus(), 220); setStatus(`网页命名已经填进分类设置:${candidate.field}${delimiterLabel(candidate.connector)}。再选它要进入的文件夹即可。`, "normal"); } async function ignoreRemoteRuleCandidate(index) { const candidate = (ui?._remoteRuleCandidates || [])[Number(index)]; if (!candidate) throw new Error("这个网页命名建议已经不存在"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const key = ignoredTitleFormatKey(candidate.field, candidate.connector); ignoredRemoteTitleFormats = normalizeIgnoredTitleFormats([ ...ignoredRemoteTitleFormats, { field: candidate.field, connector: candidate.connector }, ]); await writeSharedClassificationRules(directory.handle); ui._remoteRuleCandidates = (ui._remoteRuleCandidates || []).filter((item) => ignoredTitleFormatKey(item.field, item.connector) !== key); renderRemoteRuleCandidates(ui._remoteRuleCandidates); renderIgnoredItems(); setStatus(`已忽略网页命名建议:${candidate.field}${delimiterLabel(candidate.connector)}\n它以后不会再作为待登记项目提醒你。`, "ok"); } async function listLogicalFolderPaths(root) { const folders = new Set(); const walk = async (directory, prefix = "") => { for await (const [name, handle] of directory.entries()) { if (handle.kind !== "directory") continue; const path = joinPath(prefix, name); folders.add(path); await walk(handle, path); } }; for (const kind of ["JSON", "Markdown"]) { try { await walk(await getDirectory(root, kind, false)); } catch (error) { if (error?.name !== "NotFoundError") throw error; } } return [...folders].filter((path) => path && path !== SYSTEM_INBOX_FOLDER && !path.startsWith(`${SYSTEM_INBOX_FOLDER}/`)).sort((a, b) => a.localeCompare(b, "zh-CN")); } function populateRuleSubfolderChoices() { if (!ui?.ruleSubfolderChoice) return; const root = String(ui.ruleRootFolder?.value || "").trim().replace(/^\/+|\/+$/g, ""); const prefix = root ? `${root}/` : ""; const options = (ui._ruleFolderPaths || []) .filter((path) => prefix && path.startsWith(prefix)) .map((path) => path.slice(prefix.length)) .filter(Boolean); ui.ruleSubfolderChoice.innerHTML = '' + [...new Set(options)].map((path) => ``).join(""); } function populateRuleFolderChoices(paths = ui?._ruleFolderPaths || []) { if (!ui?.ruleRootFolderChoice) return; ui._ruleFolderPaths = [...new Set(paths || [])].sort((a, b) => a.localeCompare(b, "zh-CN")); const roots = [...new Set(ui._ruleFolderPaths.map((path) => path.split("/")[0]).filter((root) => root && root !== SYSTEM_INBOX_FOLDER))]; ui.ruleRootFolderChoice.innerHTML = '' + roots.map((root) => ``).join(""); populateRuleSubfolderChoices(); } async function refreshRuleFolderChoices({ interactive = false } = {}) { const directory = await getAuthorizedDirectory(interactive); if (!directory.handle) return []; const paths = await listLogicalFolderPaths(directory.handle); populateRuleFolderChoices(paths); return paths; } async function restoreIgnoredFolder(folderPath) { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const previous = [...ignoredFolders]; ignoredFolders = ignoredFolders.filter((item) => item !== folderPath); try { await writeSharedClassificationRules(directory.handle); } catch (error) { ignoredFolders = previous; throw error; } const audit = await refreshFolderDiscoveries({ interactive: false, announce: false }); renderIgnoredItems(); if (audit) routeManageAfterAudit(audit); setStatus(`已重新拿出来处理:${folderPath}`, "normal"); } async function registerIgnoredFolder(folderPath) { // 不在“点登记”这一刻移出忽略;只有真正保存规则后才移出。 // 这样用户进入编辑器后取消,不会丢掉原来的忽略状态。 const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); prefillRuleFromFolder(folderPath, { returnScene: "ignored" }); } async function restoreIgnoredRemoteTitle(index) { const item = ignoredRemoteTitleFormats[Number(index)]; if (!item) throw new Error("这个已忽略命名不存在"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const key = ignoredTitleFormatKey(item.field, item.connector); ignoredRemoteTitleFormats = ignoredRemoteTitleFormats.filter((candidate) => ignoredTitleFormatKey(candidate.field, candidate.connector) !== key); await writeSharedClassificationRules(directory.handle); renderIgnoredItems(); setStatus(`已恢复网页命名建议:${item.field}${delimiterLabel(item.connector)}`, "ok"); } async function registerIgnoredRemoteTitle(index) { const item = ignoredRemoteTitleFormats[Number(index)]; if (!item) throw new Error("这个已忽略命名不存在"); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const key = ignoredTitleFormatKey(item.field, item.connector); ignoredRemoteTitleFormats = ignoredRemoteTitleFormats.filter((candidate) => ignoredTitleFormatKey(candidate.field, candidate.connector) !== key); await writeSharedClassificationRules(directory.handle); renderIgnoredItems(); const candidate = { field: item.field, connector: item.connector, count: 0, examples: [] }; ui._remoteRuleCandidates = [candidate, ...(ui._remoteRuleCandidates || []).filter((entry) => ignoredTitleFormatKey(entry.field, entry.connector) !== key)]; prefillRemoteRuleCandidate(0); } function renderIgnoredItems() { if (!ui?.ignoredDetails || !ui?.ignoredList) return; const audit = ui?._folderAudit; const decisionMap = new Map((audit?.ignoredFolderDecisions || []).map((item) => [item.folder, item])); const folderPresence = (folder) => ["JSON", "Markdown"].filter((kind) => (audit?.folders?.[kind] || []).includes(folder)); const folderRows = ignoredFolders.map((folder) => { const decision = decisionMap.get(folder); const presentIn = decision ? [decision.present_in] : folderPresence(folder); const existenceKnown = Boolean(audit?.folders); const exists = !existenceKnown || presentIn.length > 0; if (!exists) return ""; const hasContent = Boolean(audit) && presentIn.some((kind) => folderTreeHasContent(audit, kind, folder)); const canDelete = Boolean(audit) && presentIn.length > 0 && !hasContent; const deleteButton = canDelete ? `` : ""; if (decision) { return `
${escapeHtml(folder)}这个空目录先放在这里;目前只剩 ${escapeHtml(decision.present_in || "一边")}。
${deleteButton}
`; } return `
${escapeHtml(folder)}之前选择了不提醒
${deleteButton}
`; }).filter(Boolean); const titleRows = ignoredRemoteTitleFormats.map((item, index) => `
${escapeHtml(item.field)} ${escapeHtml(delimiterLabel(item.connector))}之前选择了不提醒
`); const rows = [...folderRows, ...titleRows]; ui.ignoredDetails.hidden = false; ui.ignoredList.innerHTML = rows.length ? rows.join("") : '
这里是空的已经从本地删除的目录会自动从这里清掉。
'; if (ui.ignoredCount) ui.ignoredCount.textContent = `${rows.length} 项`; if (ui.manageIgnoredButton) ui.manageIgnoredButton.hidden = !rows.length; } async function pruneMissingIgnoredFolders(root, audit) { if (!root || !audit?.folders || !ignoredFolders.length) return false; const present = new Set([...(audit.folders.JSON || []), ...(audit.folders.Markdown || [])]); const next = ignoredFolders.filter((folder) => present.has(folder)); if (next.length === ignoredFolders.length) return false; const previous = [...ignoredFolders]; ignoredFolders = next; try { await writeSharedClassificationRules(root); } catch (error) { ignoredFolders = previous; throw error; } return true; } async function removeEmptyLogicalFolder(root, kind, folderPath) { const parts = String(folderPath || "").split("/").filter(Boolean); if (!parts.length) return false; const directory = await getDirectory(root, joinPath(kind, ...parts), false); for await (const _entry of directory.values()) throw new Error(`${kind}/${folderPath} 不是空目录,已停止自动删除`); const name = parts.pop(); const parent = await getDirectory(root, joinPath(kind, ...parts), false); await parent.removeEntry(name); return true; } async function applyFolderChange(root, change) { if (change.type === "create-mirror") { await getDirectory(root, joinPath(change.create_in, change.folder), true); return { type: change.type, folder: change.folder }; } if (change.type === "delete-mirror") { try { await removeEmptyLogicalFolder(root, change.delete_in, change.folder); } catch (error) { if (error?.name !== "NotFoundError") throw error; } return { type: change.type, folder: change.folder }; } return null; } async function ignoreEmptyFolderDecision(folderPath) { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const change = (ui?._folderAudit?.folderChanges || []) .find((item) => item.type === "decide-empty-folder" && item.folder === folderPath); if (!change) throw new Error("这个空目录状态已经变化,请重新检查一次"); const previous = [...ignoredFolders]; ignoredFolders = normalizeIgnoredFolders([...ignoredFolders, folderPath]); try { await writeSharedClassificationRules(directory.handle); } catch (error) { ignoredFolders = previous; throw error; } const audit = await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus(`已先忽略:${folderPath} 它不会挡住后面的事项;可以从“之前忽略的项目”回来处理。`, "ok"); return audit; } async function applyEmptyFolderDecision(folderPath, action) { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); const change = (ui?._folderAudit?.folderChanges || []) .find((item) => item.type === "decide-empty-folder" && item.folder === folderPath); if (!change) throw new Error("这个空目录状态已经变化,请重新检查一次"); if (action === "delete") { try { await removeEmptyLogicalFolder(directory.handle, change.present_in, change.folder); } catch (error) { if (error?.name !== "NotFoundError") throw error; } } else if (action === "mirror") { await getDirectory(directory.handle, joinPath(change.missing_in, change.folder), true); } else { throw new Error("未知的空目录处理方式"); } const nextAudit = await auditExportFolders(directory.handle); await persistFolderState(directory.handle, folderSnapshotFromAudit(nextAudit)); ui._folderAudit = nextAudit; renderFolderDiscoveries(nextAudit); setStatus( action === "delete" ? `空目录已删除:${change.folder}` : `已补齐:${change.folder}\nJSON 和 Markdown 现在都有这个目录。`, "ok", ); return nextAudit; } async function settleDetectedLocalChanges(root, initialAudit, { statusPrefix = "正在处理本地变化" } = {}) { let audit = initialAudit; let conversationDone = 0; let folderDone = 0; const failures = []; if (audit.stateChangeResult?.trusted !== false) { const autoChanges = (audit.conversationChanges || []).filter((item) => ["move", "delete"].includes(item.type)); for (const change of autoChanges) { try { setStatus(`${statusPrefix} ${conversationDone + 1}/${autoChanges.length}\n${change.title}`, "normal", true); if (change.type === "delete") await applyDetectedDelete(root, change); else await applyDetectedMove(root, change); conversationDone++; } catch (error) { failures.push(`${change.title}:${error.message || error}`); } } if (autoChanges.length) audit = await auditExportFolders(root); } // 空目录没有 conversation_id,所以单独按“上次两边都有 / 现在只剩一边”的状态判断。 // 最多循环 4 次,处理嵌套空目录从叶子到父目录的连锁变化。 for (let pass = 0; pass < 4; pass++) { const folderChanges = (audit.folderChanges || []).filter((item) => item.type === "create-mirror"); if (!folderChanges.length) break; let changedThisPass = 0; const ordered = [...folderChanges].sort((a, b) => { if (a.type === b.type) return b.folder.split("/").length - a.folder.split("/").length; return a.type === "delete-mirror" ? -1 : 1; }); for (const change of ordered) { try { await applyFolderChange(root, change); folderDone++; changedThisPass++; } catch (error) { failures.push(`${change.folder}:${error.message || error}`); } } if (!changedThisPass) break; audit = await auditExportFolders(root); } const hasPendingFolderDecision = (audit.folderChanges || []).some((item) => item.type === "decide-empty-folder"); // 有待用户选择的空目录时保留旧基线;否则下一次扫描会失去“之前两边都有”的证据。 if (!failures.length && !hasPendingFolderDecision) await persistFolderState(root, folderSnapshotFromAudit(audit)); return { audit, conversationDone, folderDone, failures }; } async function refreshFolderDiscoveries({ interactive = false, announce = false, autoApply = false } = {}) { const directory = await getAuthorizedDirectory(interactive); if (!directory.handle) { if (announce) setStatus(`无法检查本地文件:${directory.reason}`, "warning"); return null; } await ensureArchiveStructure(directory.handle); await readSharedClassificationRules(directory.handle); if (announce) setStatus("正在检查本地文件……", "normal", true); let audit = await auditExportFolders(directory.handle, ({ scannedFiles, path }) => { if (announce && (scannedFiles === 1 || scannedFiles % 20 === 0)) { setStatus(`正在检查:${scannedFiles} 个文件\n${path}`, "normal", true); } }); let settled = { audit, conversationDone: 0, folderDone: 0, failures: [] }; if (autoApply && audit.stateChangeResult?.trusted !== false) { settled = await settleDetectedLocalChanges(directory.handle, audit); audit = settled.audit; } try { await pruneMissingIgnoredFolders(directory.handle, audit); } catch (error) { settled.failures.push(`清理已不存在的忽略目录:${error.message || error}`); } ui._folderAudit = audit; renderFolderDiscoveries(audit); if (announce) { const pending = managePendingCounts(audit); const autoParts = []; if (settled.conversationDone) autoParts.push(`${settled.conversationDone} 个对话变化`); if (settled.folderDone) autoParts.push(`${settled.folderDone} 个目录变化`); const autoText = autoParts.length ? `\n已自动处理:${autoParts.join("、")}` : ""; const failureText = settled.failures.length ? `\n有 ${settled.failures.length} 项没处理成功。` : ""; setStatus( pending.total ? `检查完成${autoText}\n还有 ${pending.total} 件事需要你看一下。${failureText}` : `检查完成${autoText || "\n没有需要处理的变化。"}${failureText}`, pending.blocking || settled.failures.length ? "warning" : "ok", ); } updateManageHomeSummary(audit); return audit; } async function applySelectedConversationRule() { const preview = buildSelectedMigrationPreview(); if (!window.confirm(`${preview.text}\n\n确认后会同时整理 JSON 和 Markdown,确认新位置没问题后再清理旧副本。`)) return; const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const latestRule = classificationRules.find((item) => item.rule_id === preview.rule.rule_id); if (!latestRule) throw new Error("目标规则已被其他写入者删除,请重新扫描"); const result = await migrateSelectedConversations( directory.handle, preview.ids, latestRule, ui._folderAudit?.conversationIssues || [], ); await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus( `整理完成\n成功:${result.migrated.length} 个\n失败:${result.failed.length} 个` + (result.failed.length ? `\n${result.failed.slice(0, 8).map((item) => `${item.title || item.conversation_id}:${item.reason}`).join("\n")}` : ""), result.failed.length ? "warning" : "ok", ); } async function ignoreDiscoveredFolder(folderPath) { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const previous = ignoredFolders; ignoredFolders = normalizeIgnoredFolders([...ignoredFolders, folderPath]); try { await writeSharedClassificationRules(directory.handle); } catch (error) { ignoredFolders = previous; throw error; } await refreshFolderDiscoveries({ interactive: false, announce: false }); renderIgnoredItems(); setStatus(`已忽略目录建议:${folderPath}\n它以后不会再作为待登记项目提醒你。`, "ok"); } async function removeEmptyDiscoveredFolder(folderPath) { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); const presentIn = ui._folderAudit?.unregisteredFolders ?.find((item) => item.path === folderPath)?.present_in || ["JSON", "Markdown"]; const targets = []; for (const kind of presentIn) { try { const handle = await getDirectory(directory.handle, joinPath(kind, folderPath), false); for await (const _entry of handle.values()) throw new Error(`${kind}/${folderPath} 不是空目录,不能删除`); targets.push({ kind, parts: folderPath.split("/").filter(Boolean) }); } catch (error) { if (error?.name !== "NotFoundError") throw error; } } if (!targets.length) throw new Error("没有找到可删除的空目录"); if (!window.confirm(`删除以下空目录?\n${targets.map((item) => `${item.kind}/${folderPath}`).join("\n")}\n\n只删除空目录,不删除任何文件。`)) return; for (const target of targets) { const name = target.parts.pop(); const parent = await getDirectory(directory.handle, joinPath(target.kind, ...target.parts), false); await parent.removeEntry(name); } // 用户明确点了“删除空目录”,这就是新的可靠基线,避免旧 folder-state 把它再次补回来。 try { const folderState = await loadFolderState(directory.handle); if (folderState?.folders) delete folderState.folders[folderPath]; await persistFolderState(directory.handle, folderState); } catch { /* 删除动作本身已经完成;状态文件更新失败不反向恢复目录 */ } await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus(`空目录已删除:${folderPath}`, "ok"); } function openRuleEditorAndJump() { if (!ui?.ruleDetails) return; setManageScene("rule-editor", 1, { focus: ui.ruleField }); } function prefillRuleFromFolder(folderPath, { returnScene = "discover" } = {}) { const draft = folderRuleDraft(folderPath); if (!draft.root_folder) throw new Error("无法识别这个文件夹路径"); resetRuleForm(); ui.ruleField.value = draft.suggested_field; ui.ruleRootFolder.value = draft.root_folder; if (ui.ruleRootFolderChoice) ui.ruleRootFolderChoice.value = draft.root_folder; ui.ruleSubfolderPath.value = draft.subfolder_path; populateRuleSubfolderChoices(); setDelimiterForm(ui.ruleConnector, ui.ruleConnectorCustom, " "); setDelimiterForm(ui.ruleFolderSeparator, ui.ruleFolderSeparatorCustom, draft.folder_separator); ui._discoveredFolderPath = draft.path; ui._ruleReturnScene = returnScene; if (ui.ruleBackButton) ui.ruleBackButton.textContent = returnScene === "ignored" ? "‹ 之前忽略的项目" : "‹ 发现新目录"; ui.ruleSave.textContent = "应用规则、补齐双侧文件夹并迁移"; updateRulePreview(); openRuleEditorAndJump(); setStatus(`正在登记“${draft.path}” 当前页面就是分类设置;保存后会自动继续下一项。`, "normal"); } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character]); } function delimiterFromForm(select, customInput) { const selected = select?.value; if (selected === "__unset__") return null; if (selected === "__none__") return ""; if (selected === "__space__") return " "; if (selected === "__custom__") return String(customInput?.value || ""); return String(selected || ""); } function setDelimiterForm(select, customInput, value) { if (value === null || value === undefined) select.value = "__unset__"; else if (value === "") select.value = "__none__"; else if (value === " ") select.value = "__space__"; else if (Array.from(select.options).some((option) => option.value === value)) select.value = value; else { select.value = "__custom__"; customInput.value = value; } customInput.hidden = select.value !== "__custom__"; } function ruleFromForm() { const editing = classificationRules.find((item) => item.rule_id === ui._editingRuleId); const connector = delimiterFromForm(ui.ruleConnector, ui.ruleConnectorCustom); const folderSeparator = delimiterFromForm(ui.ruleFolderSeparator, ui.ruleFolderSeparatorCustom); const aliases = editing?.aliases ? [...editing.aliases] : []; if (editing && ui.keepOldAlias?.checked && !editing.format_pending) { const oldSignature = `${editing.field}\u0000${editing.connector}`; const nextSignature = `${String(ui.ruleField?.value || "").trim().toUpperCase()}\u0000${connector}`; if (oldSignature !== nextSignature) { aliases.push({ field: editing.field, connector: editing.connector }); } } else if (editing && !ui.keepOldAlias?.checked) { aliases.length = 0; } return normalizeRule({ rule_id: editing?.rule_id || makeRuleId(), field: ui.ruleField?.value, connector, root_folder: ui.ruleRootFolder?.value, folder_separator: folderSeparator, subfolder_path: ui.ruleSubfolderPath?.value, aliases, }); } function titleTemplateForRule(rule) { if (!rule || rule.format_pending) return ""; return `${rule.field}${delimiterLabel(rule.connector, "无连接符")}{对话名称}`; } function updateRulePreview() { if (!ui?.rulePreview) return; const selectedFolderSeparator = delimiterFromForm(ui.ruleFolderSeparator, ui.ruleFolderSeparatorCustom); if (ui.ruleSubfolderField) ui.ruleSubfolderField.hidden = selectedFolderSeparator === null || selectedFolderSeparator === ""; const rule = ruleFromForm(); const validation = validateRule(rule); if (!validation.ok) { ui.rulePreview.textContent = `最终导出位置预览\n\n尚不能预览:${validation.errors.join(";")}`; ui.rulePreview.dataset.kind = "error"; return; } const subfolders = parseSubfolderPath(rule); const filename = `${cleanSegment(`${rule.field}${rule.connector}{对话名称}`, "对话名称")}__{ID}`; const folderPath = joinPath(rule.root_folder, ...subfolders.parts); const lines = [ "最终导出位置预览", "", `对话标题:${titleTemplateForRule(rule)}`, "", `JSON/${folderPath}/${filename}.json`, `Markdown/${folderPath}/${filename}.md`, ]; ui.rulePreview.dataset.kind = "ok"; ui.rulePreview.textContent = lines.join("\n"); } function renderClassificationRules() { if (!ui?.rulesList) return; ui.rulesList.innerHTML = classificationRules.length ? classificationRules.map((rule) => { const format = rule.format_pending ? "格式待确认" : ruleFormat(rule); const alias = rule.aliases.length ? `|兼容旧格式 ${rule.aliases.length} 个` : ""; const subfolders = parseSubfolderPath(rule); const destination = subfolders.ok ? joinPath(rule.root_folder, ...subfolders.parts) : rule.root_folder; return ``; }).join("") : '
还没有分类规则未匹配的对话会继续放在“未归类”。
'; if (ui.ruleCount) ui.ruleCount.textContent = `${classificationRules.length} 条`; } async function addClassificationRule() { const sourceFolder = String(ui?._discoveredFolderPath || "").trim(); let sourceConversationIds = sourceFolder ? conversationIdsInFolder(ui?._folderAudit?.observedFiles || [], sourceFolder) : []; const rule = ruleFromForm(); const validation = validateRule(rule); if (!validation.ok) throw new Error(validation.errors.join(";")); const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(`规则尚未保存:${directory.reason}`); await readSharedClassificationRules(directory.handle); if (sourceFolder) sourceConversationIds = await conversationIdsDirectlyInFolder(directory.handle, sourceFolder); const conflict = classificationRules.find((item) => item.rule_id !== rule.rule_id && !item.format_pending && item.field === rule.field && item.connector === rule.connector); if (conflict) throw new Error(`规则冲突:${rule.field}${delimiterLabel(rule.connector, "无连接符")} 已经被另一条规则使用`); const previous = classificationRules.find((item) => item.rule_id === rule.rule_id); const targetSubfolders = parseSubfolderPath(rule).parts; const targetFolder = joinPath(rule.root_folder, ...targetSubfolders); const previousSubfolders = previous ? parseSubfolderPath(previous).parts : []; const previousFolder = previous ? joinPath(previous.root_folder, ...previousSubfolders) : ""; if (previous && previousFolder !== targetFolder) { const previousParent = previousFolder.split("/").slice(0, -1).join("/"); const nextParent = targetFolder.split("/").slice(0, -1).join("/"); const operation = previousParent === nextParent ? "重命名文件夹" : "移动到新的嵌套位置"; if (!window.confirm(`${operation}\n旧位置:${previousFolder}\n新位置:${targetFolder}\n\n保存后,下一次同步会逐个校验并迁移相关对话。`)) return; } await getDirectory(directory.handle, joinPath("JSON", targetFolder), true); await getDirectory(directory.handle, joinPath("Markdown", targetFolder), true); await refreshRuleFolderChoices({ interactive: false }); const previousRules = classificationRules; const previousOverrides = { ...classificationOverrides }; const previousIgnoredFolders = [...ignoredFolders]; const nextRules = [ ...classificationRules.filter((item) => item.rule_id !== rule.rule_id), rule, ]; saveClassificationRules(nextRules); if (sourceFolder) { ignoredFolders = ignoredFolders.filter((folder) => folder !== sourceFolder); for (const conversationId of sourceConversationIds) { classificationOverrides[conversationId] = { rule_id: rule.rule_id, folder: targetFolder, source: "manual-folder", updated_at: new Date().toISOString(), }; } } try { await writeSharedClassificationRules(directory.handle); } catch (error) { classificationOverrides = previousOverrides; ignoredFolders = previousIgnoredFolders; saveClassificationRules(previousRules); throw error; } if (ui?._remoteRuleCandidates?.length) { ui._remoteRuleCandidates = ui._remoteRuleCandidates.filter((candidate) => ignoredTitleFormatKey(candidate.field, candidate.connector) !== ignoredTitleFormatKey(rule.field, rule.connector)); renderRemoteRuleCandidates(ui._remoteRuleCandidates); } setStatus(`规则已保存,正在处理标题匹配和已有明确分类的对话……`, "normal", true); const migration = await migrateLocalConversationsForRule(directory.handle, rule); resetRuleForm(); await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus( `规则已保存:${ruleFormat(rule)}\n目标文件夹:${targetFolder}\nJSON 和 Markdown 文件夹均已创建。` + (sourceConversationIds.length ? `\n这个目录里已有 ${sourceConversationIds.length} 个对话,已把它们一起登记到这条规则。` : "") + `\n已成对迁移或补齐:${migration.migrated.length} 个对话。` + (migration.failed.length ? `\n未能自动处理:${migration.failed.length} 个\n${migration.failed.slice(0, 8).join("\n")}` : "") + `\n不会修改 ChatGPT 网页端标题。`, migration.failed.length ? "warning" : "ok", ); } function resetRuleForm() { ui.ruleField.value = ""; ui.ruleRootFolder.value = ""; ui.ruleSubfolderPath.value = ""; if (ui.ruleRootFolderChoice) ui.ruleRootFolderChoice.value = ""; if (ui.ruleSubfolderChoice) ui.ruleSubfolderChoice.value = ""; setDelimiterForm(ui.ruleConnector, ui.ruleConnectorCustom, null); setDelimiterForm(ui.ruleFolderSeparator, ui.ruleFolderSeparatorCustom, null); ui.keepOldAlias.checked = false; ui._editingRuleId = ""; ui._discoveredFolderPath = ""; ui._ruleReturnScene = "rules"; ui.ruleSave.textContent = "新增规则并创建文件夹"; if (ui.ruleDeleteCurrent) { ui.ruleDeleteCurrent.textContent = "取消"; ui.ruleDeleteCurrent.classList.remove("btn-danger"); } if (ui.ruleBackButton) ui.ruleBackButton.textContent = "‹ 分类规则"; updateRulePreview(); } function clearStatus() { if (!ui?.status) return; ui.status.hidden = true; ui.status.textContent = ""; ui.cancel.hidden = true; } function cancelRuleEditor() { if (!ui) return; const sourceFolder = String(ui._discoveredFolderPath || ""); const returnScene = ui._ruleReturnScene || (sourceFolder ? "discover" : "rules"); resetRuleForm(); clearStatus(); if (returnScene === "discover") { renderFolderDiscoveries(ui._folderAudit); setManageScene((ui?._folderAudit?.unregisteredFolders || []).length ? "discover" : "home", -1); return; } if (returnScene === "ignored") { renderIgnoredItems(); setManageScene("ignored", -1); return; } renderClassificationRules(); setManageScene("rules", -1); } function editClassificationRule(ruleId) { const rule = classificationRules.find((item) => item.rule_id === ruleId); if (!rule) throw new Error(`找不到规则:${ruleId}`); ui.ruleField.value = rule.field; ui.ruleRootFolder.value = rule.root_folder; ui.ruleSubfolderPath.value = rule.subfolder_path || ""; if (ui.ruleRootFolderChoice) ui.ruleRootFolderChoice.value = rule.root_folder; populateRuleSubfolderChoices(); setDelimiterForm(ui.ruleConnector, ui.ruleConnectorCustom, rule.connector); setDelimiterForm(ui.ruleFolderSeparator, ui.ruleFolderSeparatorCustom, rule.folder_separator); ui.keepOldAlias.checked = rule.aliases.length > 0; ui._editingRuleId = rule.rule_id; ui._ruleReturnScene = "rules"; if (ui.ruleBackButton) ui.ruleBackButton.textContent = "‹ 分类规则"; ui.ruleSave.textContent = "保存修改并准备安全迁移"; if (ui.ruleDeleteCurrent) { ui.ruleDeleteCurrent.textContent = "删除这条规则"; ui.ruleDeleteCurrent.classList.add("btn-danger"); } updateRulePreview(); openRuleEditorAndJump(); setStatus( rule.format_pending ? `旧规则 ${rule.field} 的字段和根文件夹已保留。请选择设置;使用子文件夹时还要填写子文件夹名称。` : `正在编辑规则 ${rule.field}。修改子文件夹名称时,绿色框会立即显示新的完整路径。`, rule.format_pending ? "warning" : "normal", ); } async function deleteClassificationRule(ruleId) { const rule = classificationRules.find((item) => item.rule_id === ruleId); if (!rule) throw new Error(`找不到规则:${ruleId}`); if (!window.confirm(`删除规则 ${rule.field}?本地已经导出的文件不会被删除。`)) return; const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(`规则尚未删除:${directory.reason}`); await readSharedClassificationRules(directory.handle); const previousRules = classificationRules; const previousOverrides = classificationOverrides; const nextOverrides = { ...classificationOverrides }; saveClassificationRules(classificationRules.filter((item) => item.rule_id !== ruleId)); for (const [conversationId, override] of Object.entries(nextOverrides)) { if (override.rule_id === ruleId) delete nextOverrides[conversationId]; } classificationOverrides = nextOverrides; try { await writeSharedClassificationRules(directory.handle); } catch (error) { classificationOverrides = previousOverrides; saveClassificationRules(previousRules); throw error; } if (ui._editingRuleId === ruleId) resetRuleForm(); await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus(`分类规则已删除:${rule.field}。本地文件未删除。`, "warning"); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function fetchWithRetry(url, options = {}, retries = 3) { let lastError; for (let attempt = 0; attempt <= retries; attempt++) { if (cancelRequested) throw new Error("用户已取消同步"); try { const response = await fetch(url, options); if (response.ok || ![429, 500, 502, 503, 504].includes(response.status)) return response; lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } if (attempt < retries) await sleep(Math.min(8000, 600 * (2 ** attempt)) + Math.floor(Math.random() * 250)); } throw lastError || new Error("网络请求失败"); } async function getSession() { try { const response = await fetchWithRetry("/api/auth/session", { credentials: "include", headers: { Accept: "application/json" }, cache: "no-store", }, 1); if (!response.ok) return { ok: false, status: response.status, token: null }; const data = await response.json(); return { ok: Boolean(data?.user || data?.accessToken || data?.access_token), status: response.status, token: data?.accessToken || data?.access_token || null }; } catch (error) { return { ok: false, status: 0, token: null, error: error.message }; } } function authHeaders(token) { const headers = { Accept: "application/json" }; if (token) headers.Authorization = `Bearer ${token}`; return headers; } async function fetchConversation(conversationId, token) { const errors = []; for (const base of ["/backend-api/conversation/", "/api/conversation/"]) { const endpoint = `${base}${encodeURIComponent(conversationId)}`; try { const response = await fetchWithRetry(endpoint, { credentials: "include", headers: authHeaders(token), cache: "no-store", }); if (!response.ok) { errors.push(`${endpoint}: HTTP ${response.status}`); continue; } const data = await response.json(); if (data?.mapping && data?.current_node) return { data, endpoint }; errors.push(`${endpoint}: 缺少 mapping/current_node`); } catch (error) { errors.push(`${endpoint}: ${error.message}`); } } throw new Error(errors.join(";")); } async function fetchInventoryMode(token, archived) { const limit = 100; let endpointUsed = ""; const result = await fetchAllPages({ limit, fetchPage: async ({ offset }) => { let page = null; const errors = []; for (const base of ["/backend-api/conversations", "/api/conversations"]) { const query = new URLSearchParams({ offset: String(offset), limit: String(limit), order: "updated", is_archived: String(archived) }); const endpoint = `${base}?${query}`; try { const response = await fetchWithRetry(endpoint, { credentials: "include", headers: authHeaders(token), cache: "no-store", }, 2); if (!response.ok) { errors.push(`${endpoint}: HTTP ${response.status}`); continue; } const data = await response.json(); const items = Array.isArray(data) ? data : data?.items; if (!Array.isArray(items)) { errors.push(`${endpoint}: 缺少 items`); continue; } page = { items, total: Number.isFinite(Number(data?.total)) ? Number(data.total) : null }; endpointUsed = base; break; } catch (error) { errors.push(`${endpoint}: ${error.message}`); } } if (!page) throw new Error(errors.join(";")); return page; }}); return { ...result, endpoint: endpointUsed }; } function normalizeInventoryItem(item, archived) { return { ...item, id: String(item?.id || item?.conversation_id || ""), title: item?.title || "未命名对话", create_time: item?.create_time || item?.inserted_at || null, update_time: item?.update_time || item?.updated_at || null, archived: Boolean(archived), }; } async function fetchCompleteInventory(token) { const normal = await fetchInventoryMode(token, false); let archived = { items: [], total: 0, supported: true }; let archivedBoundary = ""; try { const result = await fetchInventoryMode(token, true); archived = { ...result, supported: true, paginationComplete: true }; const normalIds = new Set(normal.items.map((item) => String(item?.id || item?.conversation_id || ""))); const archivedIds = result.items.map((item) => String(item?.id || item?.conversation_id || "")); if (archivedIds.length > 0 && archivedIds.length === normalIds.size && archivedIds.every((id) => normalIds.has(id))) { archived = { items: [], total: null, supported: false }; archivedBoundary = "归档参数返回了与普通目录完全相同的结果,无法确认接口支持归档筛选"; } } catch (error) { const paginationFailure = /分页不完整/.test(error.message || ""); archived = { items: [], total: null, supported: paginationFailure, paginationComplete: !paginationFailure }; archivedBoundary = paginationFailure ? `归档目录分页失败:${error.message}` : `归档目录不可访问:${error.message}`; } const map = new Map(); for (const item of normal.items.map((x) => normalizeInventoryItem(x, false))) if (item.id) map.set(item.id, item); for (const item of archived.items.map((x) => normalizeInventoryItem(x, true))) if (item.id) map.set(item.id, item); return { items: Array.from(map.values()), normal, archived, archivedBoundary }; } const platformAdapters = []; function registerPlatformAdapter(adapter) { const validation = validatePlatformAdapter(adapter); if (!validation.ok) throw new Error(`平台适配器缺少:${validation.missing.join(", ")}`); if (platformAdapters.some((item) => item.id === adapter.id)) throw new Error(`平台适配器重复:${adapter.id}`); platformAdapters.push(Object.freeze(adapter)); } function resolvePlatformAdapter(pageLocation = window.location) { const adapter = platformAdapters.find((item) => item.matches(pageLocation)); if (!adapter) throw new Error(`当前网站没有适配器:${pageLocation.hostname}`); return adapter; } registerPlatformAdapter({ id: "chatgpt", displayName: "ChatGPT", matches: (pageLocation) => ["chatgpt.com", "chat.openai.com"].includes(pageLocation.hostname), getConversationId: (pageLocation) => extractConversationId(pageLocation.pathname), getSession, fetchInventory: fetchCompleteInventory, fetchConversation, }); function openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(IDB_NAME, 1); request.onupgradeneeded = () => request.result.createObjectStore(IDB_STORE); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } async function idbGetStoredHandle(key) { const db = await openDatabase(); try { return await new Promise((resolve, reject) => { const request = db.transaction(IDB_STORE, "readonly").objectStore(IDB_STORE).get(key); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); } finally { db.close(); } } async function idbSetStoredHandle(key, handle) { const db = await openDatabase(); try { await new Promise((resolve, reject) => { const request = db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).put(handle, key); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } finally { db.close(); } } async function idbGetHandle() { return idbGetStoredHandle(IDB_HANDLE_KEY); } async function idbSetHandle(handle) { return idbSetStoredHandle(IDB_HANDLE_KEY, handle); } async function idbClear() { const db = await openDatabase(); try { await new Promise((resolve, reject) => { const request = db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } finally { db.close(); } } async function verifyPermission(handle, request = false) { if (!handle) return false; const options = { mode: "readwrite" }; if ((await handle.queryPermission(options)) === "granted") return true; return request && (await handle.requestPermission(options)) === "granted"; } async function chooseDirectory() { if (typeof window.showDirectoryPicker !== "function") throw new Error("当前浏览器不支持 File System Access API(showDirectoryPicker)"); const handle = await window.showDirectoryPicker({ mode: "readwrite", id: "chatgpt-local-export-root" }); if (!(await verifyPermission(handle, true))) throw new Error("未获得导出目录读写权限"); await idbSetHandle(handle); cachedDirectoryHandle = handle; directoryHandleLoaded = true; await ensureArchiveStructure(handle); updateDirectoryDisplay(); return handle; } async function getAuthorizedDirectory(interactive = false) { if (typeof window.showDirectoryPicker !== "function") return { handle: null, reason: "浏览器不支持 File System Access API" }; if (!directoryHandleLoaded) { if (interactive) { const selected = await chooseDirectory(); return { handle: selected, reason: "" }; } cachedDirectoryHandle = await idbGetHandle(); directoryHandleLoaded = true; } let handle = cachedDirectoryHandle; if (handle && await verifyPermission(handle, interactive)) return { handle, reason: "" }; if (interactive) { handle = await chooseDirectory(); return { handle, reason: "" }; } return { handle: null, reason: handle ? "导出目录权限需要重新授权" : "尚未选择导出目录" }; } async function getDirectory(root, relativePath, create = false) { let current = root; for (const segment of String(relativePath || "").split("/").filter(Boolean)) { current = await current.getDirectoryHandle(segment, { create }); } return current; } async function migrateLegacySystemFolder(root, legacyName, currentName) { let source; try { source = await getDirectory(root, legacyName, false); } catch (error) { if (error?.name === "NotFoundError") return { moved: 0, conflicts: [] }; throw error; } const files = []; for await (const entry of walkDirectory(source)) files.push(entry); let moved = 0; const conflicts = []; for (const entry of files) { if (!/\.(?:json|md)$/i.test(entry.path)) { conflicts.push(`${legacyName}/${entry.path}:不是 JSON/Markdown,已保留原文件`); continue; } const sourcePath = joinPath(legacyName, entry.path); const destinationPath = joinPath(currentName, entry.path); const sourceText = await (await entry.handle.getFile()).text(); const existingText = await readText(root, destinationPath); if (existingText !== null && existingText !== sourceText) { conflicts.push(`${sourcePath}:中文目录已有不同内容,未覆盖`); continue; } if (existingText === null) await writeText(root, destinationPath, sourceText); try { await removeFile(root, sourcePath); } catch (error) { if (error?.name !== "NotFoundError") throw error; } moved++; } if (!conflicts.length) { try { await root.removeEntry(legacyName, { recursive: true }); } catch (error) { if (error?.name !== "NotFoundError") conflicts.push(`${legacyName}:旧目录未能清理(${error.message || error})`); } } return { moved, conflicts }; } async function ensureArchiveStructure(root) { if (!root) return { moved: 0, conflicts: [] }; for (const path of [ `JSON/${SYSTEM_INBOX_FOLDER}`, `Markdown/${SYSTEM_INBOX_FOLDER}`, `${SYSTEM_ARCHIVE_FOLDER}/JSON`, `${SYSTEM_ARCHIVE_FOLDER}/Markdown`, `${SYSTEM_DELETED_FOLDER}/JSON`, `${SYSTEM_DELETED_FOLDER}/Markdown`, EXTRACTION_HISTORY_FOLDER, META_DIR, ]) await getDirectory(root, path, true); const archiveMigration = await migrateLegacySystemFolder(root, LEGACY_ARCHIVE_FOLDER, SYSTEM_ARCHIVE_FOLDER); const deletedMigration = await migrateLegacySystemFolder(root, LEGACY_DELETED_FOLDER, SYSTEM_DELETED_FOLDER); return { moved: archiveMigration.moved + deletedMigration.moved, conflicts: [...archiveMigration.conflicts, ...deletedMigration.conflicts], }; } function updateDirectoryDisplay() { if (!ui) return; const label = cachedDirectoryHandle?.name ? `当前资料库:${cachedDirectoryHandle.name}` : "尚未选择本地资料库"; for (const element of [ui.exportDirectoryName, ui.manageDirectoryName, ui.extractDirectoryName]) { if (element) element.textContent = label; } if (ui.extractHistoryLocation) updateExtractionOutputDisplay(); } async function getFileHandle(root, relativePath, create = false) { const parts = String(relativePath).split("/").filter(Boolean); const filename = parts.pop(); const directory = await getDirectory(root, parts.join("/"), create); return directory.getFileHandle(filename, { create }); } async function readText(root, path) { try { const handle = await getFileHandle(root, path, false); return await (await handle.getFile()).text(); } catch (error) { if (error?.name === "NotFoundError") return null; throw error; } } async function readJson(root, path) { const text = await readText(root, path); if (text === null) return null; return JSON.parse(text); } async function writeText(root, path, text) { const handle = await getFileHandle(root, path, true); const writable = await handle.createWritable(); try { await writable.write(text); await writable.close(); } catch (error) { try { await writable.abort(); } catch { /* noop */ } throw error; } } async function writeJson(root, path, value) { await writeText(root, path, `${JSON.stringify(value, null, 2)}\n`); } async function writeBinary(root, path, bytes) { const parts = String(path).split("/").filter(Boolean); const filename = parts.pop(); if (!filename) throw new Error("二进制输出路径缺少文件名"); const directory = await getDirectory(root, parts.join("/"), true); const fileHandle = await directory.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); try { await writable.write(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)); await writable.close(); } catch (error) { try { await writable.abort(); } catch { /* noop */ } throw error; } } async function removeFile(root, path) { const parts = String(path).split("/").filter(Boolean); const filename = parts.pop(); const directory = await getDirectory(root, parts.join("/"), false); await directory.removeEntry(filename); } async function removeEmptyAncestorDirectories(root, filePath) { const parts = String(filePath || "").split("/").filter(Boolean); parts.pop(); while (parts.length > 1) { const directoryPath = parts.join("/"); const directory = await getDirectory(root, directoryPath, false); let empty = true; for await (const _entry of directory.values()) { empty = false; break; } if (!empty) break; const name = parts.pop(); const parent = await getDirectory(root, parts.join("/"), false); await parent.removeEntry(name); } } function directoryIo(root) { return { readJson: (path) => readJson(root, path), readText: (path) => readText(root, path), writeJson: (path, value) => writeJson(root, path, value), writeText: (path, value) => writeText(root, path, value), exists: async (path) => (await readText(root, path)) !== null, remove: (path) => removeFile(root, path), }; } function emptyIndex() { return { schema_version: INDEX_SCHEMA_VERSION, exporter_version: VERSION, updated_at: null, conversations: {} }; } async function loadIndex(root) { const internal = await readJson(root, `${META_DIR}/index.json`); if (internal?.conversations && typeof internal.conversations === "object") return internal; return null; } function titleFromObservedPaths(observed = {}) { const path = observed.markdown?.[0] || observed.json?.[0] || ""; const filename = normalizeStatePath(path).split("/").pop() || ""; return filename.replace(/\.(?:md|json)$/i, "").replace(/__[A-Za-z0-9_-]{4,}$/i, "") || "未命名对话"; } async function ensureConversationState(root, index = null, observedFiles = []) { const rawState = await readJson(root, CONVERSATION_STATE_PATH); const rawDeleted = await readJson(root, DELETED_CONVERSATIONS_PATH); if (rawState !== null && (!rawState?.conversations || typeof rawState.conversations !== "object")) { throw new Error("conversation-state.json 已存在但格式无效;为避免覆盖,已停止状态初始化"); } if (rawDeleted !== null && (!rawDeleted?.deleted || typeof rawDeleted.deleted !== "object")) { throw new Error("deleted-conversations.json 已存在但格式无效;为避免覆盖,已停止状态初始化"); } const stateExisted = rawState !== null; const deletedExisted = rawDeleted !== null; let state = normalizeConversationState(rawState); let deleted = normalizeDeletedConversations(rawDeleted); let stateChanged = !stateExisted; const now = new Date().toISOString(); const deletedIds = new Set(Object.keys(deleted.deleted)); const entries = index?.conversations && typeof index.conversations === "object" ? index.conversations : {}; for (const [conversationId, entry] of Object.entries(entries)) { if (!conversationId || deletedIds.has(conversationId) || state.conversations[conversationId]) continue; state.conversations[conversationId] = stateEntryFromIndexEntry(entry, now); stateChanged = true; } if (!stateExisted) { const observedById = buildObservedConversationMap(observedFiles); for (const [conversationId, observed] of observedById.entries()) { if (deletedIds.has(conversationId) || state.conversations[conversationId]) continue; state.conversations[conversationId] = normalizeStateEntry({ title: titleFromObservedPaths(observed), state: "active", last_known: { json_path: observed.json[0] || "", markdown_path: observed.markdown[0] || "", }, classification: null, baseline_at: now, }); stateChanged = true; } } if (stateChanged) state = await persistConversationState(root, state); if (!deletedExisted) deleted = await persistDeletedConversations(root, deleted); return { state, deleted, created: !stateExisted, stateChanged }; } async function refreshConversationStateFromIndex(root, index, conversationIds = null) { const state = await loadConversationState(root); const deleted = await loadDeletedConversations(root); const deletedIds = new Set(Object.keys(deleted.deleted)); const ids = conversationIds ? new Set([...conversationIds].map(String)) : null; const now = new Date().toISOString(); let changed = false; for (const [conversationId, entry] of Object.entries(index?.conversations || {})) { if (ids && !ids.has(conversationId)) continue; if (deletedIds.has(conversationId)) continue; state.conversations[conversationId] = stateEntryFromIndexEntry(entry, now); changed = true; } if (changed) await persistConversationState(root, state); return { state, changed }; } async function* walkDirectory(directory, prefix = "") { for await (const [name, handle] of directory.entries()) { const path = joinPath(prefix, name); if (handle.kind === "directory") yield* walkDirectory(handle, path); else yield { path, handle }; } } async function rebuildIndex(root, progress = () => {}) { const index = emptyIndex(); const issues = []; let scanned = 0; for await (const entry of walkDirectory(root)) { if (cancelRequested) throw new Error("用户已取消操作"); const topFolder = String(entry.path || "").split("/")[0]; if ([SYSTEM_ARCHIVE_FOLDER, SYSTEM_DELETED_FOLDER, LEGACY_ARCHIVE_FOLDER, LEGACY_DELETED_FOLDER].includes(topFolder)) continue; if (!entry.path.toLowerCase().endsWith(".json") || entry.path.startsWith(`${META_DIR}/`) || ["conversation-index.json", "timeline.json"].includes(entry.path)) continue; scanned++; progress(`正在重建索引:已扫描 ${scanned} 个 JSON`); try { const data = JSON.parse(await (await entry.handle.getFile()).text()); const id = String(data?.conversation_id || ""); if (!id) { issues.push(`${entry.path}: 非本插件 JSON 或缺少 conversation_id`); continue; } const markdownPath = data.markdown_path || entry.path.replace(/^JSON\//, "Markdown/").replace(/\.json$/i, ".md"); index.conversations[id] = { conversation_id: id, title: data.title || "未命名对话", create_time: data.create_time || null, update_time: data.update_time || null, message_count: Array.isArray(data.messages) ? data.messages.length : 0, export_revision: data.export_revision || null, json_path: entry.path, markdown_path: markdownPath, classification: data.classification || { kind: "未归类", name: "" }, }; } catch (error) { issues.push(`${entry.path}: ${error.message}`); } } index.updated_at = new Date().toISOString(); await persistIndexes(root, index); return { index, issues, scanned, persisted: true }; } function publicIndex(index) { return { schema_version: index.schema_version, exporter_version: index.exporter_version, updated_at: index.updated_at, conversations: Object.values(index.conversations).sort((a, b) => toTimestampMs(b.update_time) - toTimestampMs(a.update_time)), }; } async function persistIndexes(root, index) { index.exporter_version = VERSION; index.updated_at = new Date().toISOString(); await writeJson(root, "conversation-index.json", publicIndex(index)); await writeJson(root, `${META_DIR}/index.json`, index); } function buildMarkdown(archive) { const yaml = (value) => String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const lines = [ "---", `title: "${yaml(archive.title)}"`, `conversation_id: "${archive.conversation_id}"`, `create_time: "${archive.create_time || ""}"`, `update_time: "${archive.update_time || ""}"`, `message_count: ${archive.messages.length}`, `export_revision: "${archive.export_revision || ""}"`, `json_path: "${yaml(archive.json_path || "")}"`, `markdown_path: "${yaml(archive.markdown_path || "")}"`, `exporter_version: "${VERSION}"`, `schema_version: "${SCHEMA_VERSION}"`, "---", "", ]; for (const message of archive.messages) { lines.push(`## ${message.role} · ${message.occurred_at || "时间缺失"}`, "", message.content || "(仅附件或引用)", ""); if (message.files?.length) lines.push(`附件:${message.files.map((file) => file.name || file.id || "未知附件").join("、")}`, ""); } if (archive.conversion_warnings?.length) { lines.push("## 转换警告", "", ...archive.conversion_warnings.map((warning) => `- ${warning}`), ""); } return `${lines.join("\n").trim()}\n`; } async function readIndexedArchives(root, index) { const archives = []; const issues = []; for (const entry of Object.values(index.conversations)) { try { const archive = await readJson(root, entry.json_path); if (archive?.conversation_id) archives.push({ ...archive, _json_path: entry.json_path }); else issues.push(`${entry.json_path}: 缺少 conversation_id`); } catch (error) { issues.push(`${entry.json_path}: ${error.message}`); } } return { archives, issues }; } function planSummary(plan) { return `本地已有 ${plan.localCount}|近期活跃 ${plan.active.length}|待新增 ${plan.add.length}|待更新 ${plan.update.length}|跳过 ${plan.skip.length}|时间未知 ${plan.unknown.length}`; } function buildReport(result) { const status = syncState({ written: result.written, failures: result.failed, stageFailures: result.stageFailures || [], cancelled: result.cancelled, recoveries: result.recoveries || [], }); const lines = [ `# ChatGPT 本地同步报告`, "", `状态:${status}`, `同步时间:${result.finishedAt}`, `目录统计:${result.summary}`, `新增:${result.added}`, `更新:${result.updated}`, `跳过:${result.skipped}`, `失败:${result.failed.length}`, `保留较完整历史版本:${result.preserved}`, ]; if (result.archivedBoundary) lines.push("", `能力边界:${result.archivedBoundary}`); if (result.warnings.length) lines.push("", "## 警告", "", ...result.warnings.map((x) => `- ${x}`)); if (result.recoveries?.length) lines.push("", "## 旁路恢复", "", ...result.recoveries.map((x) => `- ${x.conversation_id}: 原路径 ${x.original_path};恢复路径 ${x.recovery_path};错误 ${x.parse_error}`)); if (result.stageFailures?.length) lines.push("", "## 写入阶段失败", "", ...result.stageFailures.map((x) => `- ${x.stage}: ${x.reason}`)); if (result.failed.length) lines.push("", "## 失败", "", ...result.failed.map((x) => `- ${x.id}: ${x.reason}`)); return { status, markdown: `${lines.join("\n")}\n` }; } async function appendHistory(root, record) { let history = []; try { history = await readJson(root, `${META_DIR}/sync-history.json`) || []; } catch { history = []; } if (!Array.isArray(history)) history = []; history.push(record); await writeJson(root, `${META_DIR}/sync-history.json`, history.slice(-500)); } async function ensureIndex(root, force = false) { return loadOrRebuildIndex({ load: async () => force ? null : loadIndex(root), rebuild: () => rebuildIndex(root, (text) => setStatus(text)), }); } async function locateConversationCopies(root, conversationId) { const result = { json: [], markdown: [] }; const suffix = `__${shortId(conversationId)}`.toLowerCase(); for await (const entry of walkDirectory(root)) { const lower = entry.path.toLowerCase(); if (!lower.includes(suffix)) continue; try { if (lower.startsWith("json/") && lower.endsWith(".json")) { const data = JSON.parse(await (await entry.handle.getFile()).text()); if (String(data?.conversation_id || "") === String(conversationId)) result.json.push(entry.path); } else if (lower.startsWith("markdown/") && lower.endsWith(".md")) { const metadata = parseMarkdownMetadata(await (await entry.handle.getFile()).text()); if (String(metadata?.conversation_id || "") === String(conversationId)) result.markdown.push(entry.path); } } catch { // 损坏或非本插件文件不参与自动迁移,也不会被删除。 } } return result; } async function removeVerifiedConversationDuplicates(root, conversationId, keepPaths) { const copies = await locateConversationCopies(root, conversationId); const removed = []; for (const path of [...copies.json, ...copies.markdown]) { if (path === keepPaths.json || path === keepPaths.markdown) continue; await removeFile(root, path); removed.push(path); try { await removeEmptyAncestorDirectories(root, path); } catch { /* 非空目录保留 */ } } return removed; } async function migrateSelectedConversations(root, conversationIds, rule, issues = []) { const loaded = await ensureIndex(root, false); const index = loaded.index; const issueMap = new Map(issues.map((issue) => [issue.conversation_id, issue])); const migrated = []; const failed = []; const previousOverrides = classificationOverrides; const nextOverrides = { ...classificationOverrides }; for (const conversationId of conversationIds) { nextOverrides[conversationId] = { rule_id: rule.rule_id, source: "user-selection", updated_at: new Date().toISOString(), }; } classificationOverrides = nextOverrides; try { await writeSharedClassificationRules(root); } catch (error) { classificationOverrides = previousOverrides; throw error; } for (const [position, conversationId] of conversationIds.entries()) { if (cancelRequested) break; const issue = issueMap.get(conversationId); const entry = index.conversations[conversationId]; setStatus(`正在整理 ${position + 1}/${conversationIds.length}\n${entry?.title || issue?.title || conversationId}`, "normal", true); try { const copies = issue ? { json: issue.json_paths, markdown: issue.markdown_paths } : await locateConversationCopies(root, conversationId); if (copies.json.length !== 1) throw new Error(copies.json.length ? "存在多个 JSON 副本,需要单独处理" : "缺少可读取的 JSON,需先从网页同步补齐"); if (copies.markdown.length > 1) throw new Error("存在多个 Markdown 副本,需要单独处理"); const archive = await readJson(root, copies.json[0]); if (String(archive?.conversation_id || "") !== String(conversationId)) throw new Error("源 JSON conversation_id 校验失败"); const title = entry?.title || archive.title || issue?.title || "未命名对话"; const classification = classificationFromRule(rule, title, "user-selection"); if (!classification) throw new Error("目标规则不可用"); const desired = archivePaths(title, conversationId, classification); const oldEntry = { ...(entry || {}), conversation_id: conversationId, title, json_path: copies.json[0], markdown_path: copies.markdown[0] || entry?.markdown_path || desired.markdown, }; const staged = await stageConversationWrite({ io: directoryIo(root), freshArchive: { ...archive, title, exporter_version: VERSION, classification }, oldEntry, markdownText: buildMarkdown, }); if (staged.kind !== "staged") throw new Error(`源 JSON 无法安全读取,已写入恢复区:${staged.recovery_path}`); index.conversations[conversationId] = indexEntryFromArchive(staged.archive, staged.paths); await persistIndexes(root, index); staged.pending.stage = "index_written"; await writeJson(root, staged.pending_path, staged.pending); const finalized = await recoverPendingCommit({ io: directoryIo(root), pendingFile: staged.pending_path, record: staged.pending, index, renderMarkdown: buildMarkdown, persistIndex: (next) => persistIndexes(root, next), }); if (!finalized.complete) throw new Error(`提交校验失败:${finalized.errors.join(";")}`); await removeVerifiedConversationDuplicates(root, conversationId, staged.paths); migrated.push({ conversation_id: conversationId, title, ...staged.paths }); } catch (error) { failed.push({ conversation_id: conversationId, title: entry?.title || issue?.title || "", reason: error.message || String(error) }); } } if (migrated.length) { const archives = await readIndexedArchives(root, index); if (!archives.issues.length) await writeJson(root, "timeline.json", timelineFromArchives(archives.archives)); await refreshConversationStateFromIndex(root, index, migrated.map((item) => item.conversation_id)); } return { migrated, failed, cancelled: cancelRequested }; } async function migrateLocalConversationsForRule(root, rule) { const loaded = await ensureIndex(root, false); const index = loaded.index; const migrated = []; const failed = []; for (const originalEntry of Object.values(index.conversations)) { try { const copies = await locateConversationCopies(root, originalEntry.conversation_id); const copyFolders = [...copies.json, ...copies.markdown].map((path) => path.split("/").slice(1, -1).join("/")); const existingOverride = classificationOverrides[originalEntry.conversation_id]; const fromTitle = classifyTitle(originalEntry.title, classificationRules); const titleMatches = fromTitle?.rule_id === rule.rule_id; if (existingOverride?.rule_id && existingOverride.rule_id !== rule.rule_id) continue; const overrideMatches = existingOverride?.rule_id === rule.rule_id; if (!titleMatches && !overrideMatches) continue; const registeredFolders = new Set(classificationRules.map(ruleTargetFolder).filter((folder) => copyFolders.includes(folder))); if (!overrideMatches && registeredFolders.size > 1) { throw new Error(`JSON 与 Markdown 位于多个已登记目录:${[...registeredFolders].join("、")};请在整理列表中选择最终分类`); } const classification = overrideMatches ? classificationFromRule(rule, originalEntry.title, existingOverride.source || "user-selection") : fromTitle; const desired = archivePaths(originalEntry.title, originalEntry.conversation_id, classification); const actualJson = copies.json.includes(originalEntry.json_path) ? originalEntry.json_path : copies.json[0] || originalEntry.json_path; const actualMarkdown = copies.markdown.includes(originalEntry.markdown_path) ? originalEntry.markdown_path : copies.markdown[0] || originalEntry.markdown_path; const pathsAlreadyCorrect = actualJson === desired.json && actualMarkdown === desired.markdown; const classificationAlreadyCorrect = originalEntry.classification?.rule_id === rule.rule_id; if (pathsAlreadyCorrect && classificationAlreadyCorrect) continue; const archive = await readJson(root, actualJson); if (!archive?.conversation_id) throw new Error(`找不到可读取的 JSON:${actualJson}`); const oldEntry = { ...originalEntry, json_path: actualJson, markdown_path: actualMarkdown }; const staged = await stageConversationWrite({ io: directoryIo(root), freshArchive: { ...archive, exporter_version: VERSION, classification }, oldEntry, markdownText: buildMarkdown, }); if (staged.kind !== "staged") throw new Error(`原 JSON 无法安全读取,已写入恢复区:${staged.recovery_path}`); index.conversations[staged.archive.conversation_id] = indexEntryFromArchive(staged.archive, staged.paths); await persistIndexes(root, index); staged.pending.stage = "index_written"; await writeJson(root, staged.pending_path, staged.pending); const finalized = await recoverPendingCommit({ io: directoryIo(root), pendingFile: staged.pending_path, record: staged.pending, index, renderMarkdown: buildMarkdown, persistIndex: (next) => persistIndexes(root, next), }); if (!finalized.complete) throw new Error(`提交校验失败:${finalized.errors.join(";")}`); await removeVerifiedConversationDuplicates(root, staged.archive.conversation_id, staged.paths); migrated.push({ conversation_id: staged.archive.conversation_id, title: staged.archive.title, json_path: staged.paths.json, markdown_path: staged.paths.markdown, }); } catch (error) { failed.push(`${originalEntry.title}:${error.message || error}`); } } if (migrated.length) { const archives = await readIndexedArchives(root, index); if (!archives.issues.length) await writeJson(root, "timeline.json", timelineFromArchives(archives.archives)); await refreshConversationStateFromIndex(root, index, migrated.map((item) => item.conversation_id)); } return { migrated, failed }; } async function reconcileRegisteredFolderMoves() { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const migrated = []; const failed = []; for (const [index, rule] of classificationRules.entries()) { setStatus(`正在整理本地位置 ${index + 1}/${classificationRules.length}\n${ruleTargetFolder(rule)}`, "normal", true); const result = await migrateLocalConversationsForRule(directory.handle, rule); migrated.push(...result.migrated); failed.push(...result.failed); } await refreshFolderDiscoveries({ interactive: false, announce: false }); setStatus( `本地移动同步完成\n已成对迁移或补齐:${migrated.length} 个对话` + (failed.length ? `\n未能自动处理:${failed.length} 个\n${failed.slice(0, 8).join("\n")}` : "") + "\n没有规则的目录会作为可选建议显示,你可以登记,也可以忽略。", failed.length ? "warning" : "ok", ); } async function listPendingCommits(root) { try { const directory = await getDirectory(root, `${META_DIR}/pending`, false); const records = []; for await (const [name, handle] of directory.entries()) { if (handle.kind !== "file" || !name.endsWith(".json")) continue; try { records.push({ path: `${META_DIR}/pending/${name}`, record: JSON.parse(await (await handle.getFile()).text()) }); } catch (error) { records.push({ path: `${META_DIR}/pending/${name}`, error: error.message }); } } return records; } catch (error) { if (error?.name === "NotFoundError") return []; throw error; } } async function reconcilePendingCommits(root, index) { const pending = await listPendingCommits(root); const repaired = []; const unresolved = []; for (const item of pending) { if (item.error || !item.record?.conversation_id) { unresolved.push(`${item.path}: ${item.error || "pending 记录无效"}`); continue; } const record = item.record; const result = await recoverPendingCommit({ io: directoryIo(root), pendingFile: item.path, record, index, renderMarkdown: buildMarkdown, persistIndex: (next) => persistIndexes(root, next), }); if (result.complete) { repaired.push(record.conversation_id); } else { unresolved.push(`${record.conversation_id}: ${result.errors.join(";")}`); } } return { detected: pending.length, repaired, unresolved }; } async function inspectEnvironment({ interactiveDirectory = false, directoryHandle = null } = {}) { const adapter = resolvePlatformAdapter(); const session = await adapter.getSession(); if (!session.ok) throw new Error(`当前登录或会话接口不可用${session.status ? `(HTTP ${session.status})` : ""}`); const inventory = await adapter.fetchInventory(session.token); const directory = directoryHandle ? { handle: directoryHandle, reason: "" } : await getAuthorizedDirectory(interactiveDirectory); let index = emptyIndex(); let indexState = directory.reason; let indexIssues = []; let pendingState = { detected: 0, repaired: [], unresolved: [] }; let indexUsable = false; if (directory.handle) { try { const loaded = await ensureIndex(directory.handle); index = loaded.index; indexIssues = loaded.issues || []; indexState = loaded.rebuilt ? "索引不存在,已从本地 JSON 重建" : "索引可读"; pendingState = await reconcilePendingCommits(directory.handle, index); indexUsable = loaded.persisted !== false && pendingState.unresolved.length === 0; if (pendingState.repaired.length) indexState += `;已修复 ${pendingState.repaired.length} 个未完成提交`; if (pendingState.unresolved.length) indexState += `;仍有 ${pendingState.unresolved.length} 个未完成提交`; } catch (error) { indexState = `索引读取/重建失败:${error.message}`; indexUsable = false; } } let deletedState = emptyDeletedConversations(); if (directory.handle) { try { deletedState = await loadDeletedConversations(directory.handle); } catch { /* handled by sync safety elsewhere */ } } const deletedIds = new Set(Object.keys(deletedState.deleted || {})); const remoteItems = (inventory.items || []).filter((item) => !deletedIds.has(String(item.id || item.conversation_id || ""))); const plan = computeSyncPlan(remoteItems, index.conversations, Date.now(), classificationRules, classificationOverrides); const paginationComplete = (inventory.normal.total === null || inventory.normal.items.length === inventory.normal.total) && inventory.archived.paginationComplete !== false; const canSync = computeCanSync({ directoryPermission: Boolean(directory.handle), sessionOk: session.ok, interfaceOk: true, paginationComplete, indexUsable, rebuildOk: indexUsable, indexPersisted: indexUsable, }); return { adapter, session, inventory, directory, index, indexState, indexIssues, indexUsable, pendingState, paginationComplete, canSync, plan }; } async function runHealthCheck() { setStatus("正在检查登录、目录分页、授权和本地索引……"); const supported = typeof window.showDirectoryPicker === "function"; let environment; try { const authorized = await getAuthorizedDirectory(false); if (authorized.handle) await readSharedClassificationRules(authorized.handle); environment = await inspectEnvironment({ interactiveDirectory: false, directoryHandle: authorized.handle || null, }); } catch (error) { setStatus(`健康检查失败\n${error.message}\nFile System Access API:${supported ? "支持" : "不支持,无法执行本地同步"}`, "error"); return; } const currentId = environment.adapter.getConversationId(location); const lines = [ "健康检查", `平台适配器:${environment.adapter.displayName}(${environment.adapter.id})`, `登录/接口:正常`, `当前页面对话 ID:${currentId || "未处于具体对话页(不影响全量同步)"}`, `普通目录:${environment.inventory.normal.items.length}/${environment.inventory.normal.total ?? "total 未提供"}`, environment.inventory.archived.supported ? `归档目录:${environment.inventory.archived.items.length}/${environment.inventory.archived.total ?? "total 未提供"}` : `归档目录:不可用(${environment.inventory.archivedBoundary})`, `目录授权:${environment.directory.handle ? "有效" : environment.directory.reason}`, `本地索引:${environment.indexState}`, planSummary(environment.plan), ]; if (environment.directory.handle) { const audit = await auditExportFolders(environment.directory.handle); ui._folderAudit = audit; renderFolderDiscoveries(audit); lines.push(audit.hasDrift ? `目录与规则审计:发现 ${folderAuditLines(audit).length} 项待处理` : "目录与规则审计:一致"); if (audit.hasDrift) lines.push(...folderAuditLines(audit).slice(0, 12)); } if (!supported) lines.push("阻断:当前浏览器没有 showDirectoryPicker(),不能伪装为可同步。"); if (environment.indexIssues.length) lines.push(`索引重建问题:${environment.indexIssues.length} 个`); if (!environment.indexUsable) lines.push("阻断:本地索引不可用,禁止同步写入。"); if (!environment.paginationComplete) lines.push("阻断:对话目录分页不完整。"); setStatus(lines.join("\n"), environment.canSync ? (environment.inventory.archived.supported ? "ok" : "warning") : "error"); } async function runSync() { cancelRequested = false; const startedAt = new Date().toISOString(); setStatus("正在准备导出;首次使用会请求选择导出目录……"); // 目录选择必须紧邻用户点击,不能排在目录/网络扫描之后。 const authorized = await getAuthorizedDirectory(true); const root = authorized.handle; if (!root) throw new Error(authorized.reason || "未获得导出目录"); await ensureArchiveStructure(root); await readSharedClassificationRules(root); setStatus("正在检查本地文件有没有移动或删除……", "normal", true); let localAudit = await auditExportFolders(root); if (localAudit.stateChangeResult?.trusted === false && Object.keys(localAudit.conversationState?.conversations || {}).length) { ui._folderAudit = localAudit; renderFolderDiscoveries(localAudit); setStatus(`导出已暂停\n本地目录没有完整扫描,为避免误删或覆盖,暂不继续。`, "warning"); return; } const settledLocal = await settleDetectedLocalChanges(root, localAudit, { statusPrefix: "导出前先处理本地变化" }); localAudit = settledLocal.audit; ui._folderAudit = localAudit; renderFolderDiscoveries(localAudit); const unresolvedLocal = (localAudit.conversationChanges || []).filter((item) => ["conflict", "uncertain"].includes(item.type)); const blockingLocalCount = unresolvedLocal.length + (localAudit.missingFileIssues?.length || 0) + (localAudit.conversationIssues?.length || 0); const remainingFolderChanges = localAudit.folderChanges?.length || 0; if (blockingLocalCount || remainingFolderChanges || settledLocal.failures.length) { const detail = [ blockingLocalCount ? `${blockingLocalCount} 个对话需要确认` : "", remainingFolderChanges ? `${remainingFolderChanges} 个目录还没对齐` : "", settledLocal.failures.length ? `${settledLocal.failures.length} 项自动处理失败` : "", ].filter(Boolean).join(","); setStatus(`导出已暂停\n${detail}。到“管理”里处理后再导出。`, "warning"); if (typeof setMainView === "function") setMainView("manage"); return; } const environment = await inspectEnvironment({ directoryHandle: root }); if (!environment.canSync) { setStatus(`导出失败\n准备阶段没有通过:${environment.indexState}`, "error"); return; } const plan = environment.plan; setStatus(`导出前统计\n${planSummary(plan)}\n需要读取正文 ${plan.queue.length} 个对话`, "normal", true); await sleep(50); const index = environment.index; const warnings = [...environment.indexIssues]; let added = 0; let updated = 0; let preserved = 0; const changedConversations = []; const makeResult = (snapshot) => ({ startedAt, finishedAt: new Date().toISOString(), summary: planSummary(plan), added, updated, skipped: plan.skip.length + Math.max(0, plan.queue.length - snapshot.written - snapshot.failures.length - snapshot.recoveries.length), failed: snapshot.failures, warnings: Array.from(new Set(warnings)), preserved, written: snapshot.written, archivedBoundary: environment.inventory.archivedBoundary, cancelled: snapshot.cancelled, recoveries: snapshot.recoveries, stageFailures: snapshot.stageFailures, }); const outcome = await executeSyncWorkflow({ items: plan.queue, shouldCancel: () => cancelRequested, onProgress: (position, total, item) => setStatus(`导出中 ${position}/${total}\n${item.title}\n${planSummary(plan)}`, "normal", true), processItem: async (item) => { const remote = await environment.adapter.fetchConversation(item.id, environment.session.token); const fresh = buildArchive(remote.data, item, classificationRules, classificationOverrides); const oldEntry = index.conversations[item.id]; const staged = await stageConversationWrite({ io: directoryIo(root), freshArchive: fresh, oldEntry, markdownText: buildMarkdown, }); if (staged.kind === "recovery") { warnings.push(`${item.id} 原 JSON 损坏,已写入旁路恢复文件且未覆盖原文件`); return staged; } if (staged.preserved) { preserved++; warnings.push(`${item.id} 新数据节点较少,已合并并保留本地历史节点`); } if (oldEntry) staged.wasUpdate = true; else staged.wasAdd = true; const { archive } = staged; if (archive.conversion_warnings.length) warnings.push(...archive.conversion_warnings.map((x) => `${item.id}: ${x}`)); await sleep(120); return staged; }, applyIndex: (staged) => { index.conversations[staged.archive.conversation_id] = indexEntryFromArchive(staged.archive, staged.paths); changedConversations.push({ conversation_id: staged.archive.conversation_id, title: staged.archive.title, update_time: staged.archive.update_time, export_revision: staged.archive.export_revision, message_count: staged.archive.messages?.length || 0, markdown_path: staged.paths.markdown, json_path: staged.paths.json, classification: staged.archive.classification, relocated_from: staged.relocated_from, merge_mode: staged.archive.merge_mode || "new-archive", needs_ai_review: true, }); if (staged.wasUpdate) updated++; else added++; }, persistIndex: () => persistIndexes(root, index), finalizeCommit: async (staged) => { staged.pending.stage = "index_written"; await writeJson(root, staged.pending_path, staged.pending); const finalized = await recoverPendingCommit({ io: directoryIo(root), pendingFile: staged.pending_path, record: staged.pending, index, renderMarkdown: buildMarkdown, persistIndex: (next) => persistIndexes(root, next), }); if (!finalized.complete) throw new Error(`${staged.archive.conversation_id} 提交校验失败:${finalized.errors.join(";")}`); if (staged.relocated_from) { for (const oldPath of [staged.relocated_from.json, staged.relocated_from.markdown]) { if (!oldPath || oldPath === staged.paths.json || oldPath === staged.paths.markdown) continue; try { await removeFile(root, oldPath); await removeEmptyAncestorDirectories(root, oldPath); } catch (error) { warnings.push(`${staged.archive.conversation_id} 已写入新分类,但旧文件或空目录清理失败:${oldPath}(${error.message})`); } } } }, writeTimeline: async () => { const archiveResult = await readIndexedArchives(root, index); warnings.push(...archiveResult.issues); if (archiveResult.issues.length) { throw new Error(`有 ${archiveResult.issues.length} 个索引档案不可读,已保留旧 timeline`); } await writeJson(root, "timeline.json", timelineFromArchives(archiveResult.archives)); }, writeHistory: async (snapshot) => { const result = makeResult(snapshot); const report = buildReport(result); await appendHistory(root, { started_at: startedAt, finished_at: result.finishedAt, status: report.status, added, updated, skipped: result.skipped, failed: result.failed.length, preserved, }); }, writeReport: async (snapshot) => writeText(root, `${META_DIR}/last-report.md`, buildReport(makeResult(snapshot)).markdown), }); if (!outcome.stageFailures.some((item) => item.stage === "conversation-index")) { try { await refreshConversationStateFromIndex(root, index); } catch (error) { outcome.stageFailures.push({ stage: "conversation-state", reason: error.message || String(error) }); } } try { await writeJson(root, CHANGE_MANIFEST_PATH, { schema_version: "1.0", exporter_version: VERSION, generated_at: new Date().toISOString(), sync_started_at: startedAt, changes: changedConversations, }); } catch (error) { outcome.stageFailures.push({ stage: "conversation-changes", reason: error.message || String(error) }); } const result = makeResult(outcome); const details = outcome.failures.length ? `\n${outcome.failures.map((x) => `${x.id}: ${x.reason}`).join("\n")}` : ""; const stageDetails = outcome.stageFailures.length ? `\n${outcome.stageFailures.map((x) => `${x.stage}: ${x.reason}`).join("\n")}` : ""; ui._folderAudit = await auditExportFolders(root); renderFolderDiscoveries(ui._folderAudit); const outcomeLabel = outcome.state === "完整同步" ? "导出完成" : outcome.state === "部分同步" ? "导出完成,但有部分问题" : "导出失败"; setStatus(`${outcomeLabel}\n新增 ${added}|更新 ${updated}|未变化 ${result.skipped}|失败 ${outcome.failures.length}${details}${stageDetails}`, outcome.state === "完整同步" ? "ok" : outcome.state === "部分同步" ? "warning" : "error"); } function extractionOptionsFromUi() { const timeValue = ui?.extractTime?.value || "all"; return normalizeExtractionOptions({ days: timeValue === "7" ? 7 : timeValue === "30" ? 30 : 0, start: timeValue === "custom" ? ui.extractStart?.value : "", end: timeValue === "custom" ? ui.extractEnd?.value : "", field: ui?.extractField?.value || "", folder: ui?.extractFolder?.value || "", include_subfolders: ui?.extractIncludeSubfolders?.checked !== false, keyword: ui?.extractKeyword?.value || "", }); } function extractionTimeLabel() { const value = ui?.extractTime?.value || "all"; if (value === "7") return "最近7天"; if (value === "30") return "最近30天"; if (value === "custom") return `${ui.extractStart?.value || "起始"}_到_${ui.extractEnd?.value || "现在"}`; return "全部时间"; } function populateExtractionFilters(index) { if (!ui?.extractField || !ui?.extractFolder) return; const entries = Object.values(index?.conversations || {}); const currentField = ui.extractField.value; const currentFolder = ui.extractFolder.value; const fields = [...new Set(entries.map(extractionFieldFromEntry).filter(Boolean))].sort((a, b) => a.localeCompare(b)); const folders = [...new Set(entries.map(extractionFolderFromEntry).filter(Boolean))].sort((a, b) => a.localeCompare(b)); ui.extractField.innerHTML = '' + fields.map((field) => ``).join(""); ui.extractFolder.innerHTML = '' + folders.map((folder) => ``).join(""); if (fields.includes(currentField)) ui.extractField.value = currentField; if (folders.includes(currentFolder)) ui.extractFolder.value = currentFolder; } function renderExtractionCandidates(entries) { if (!ui?.extractResults || !ui?.extractList || !ui?.extractSummary) return; ui._extractCandidates = entries; ui.extractResults.hidden = false; ui.extractSummary.textContent = `找到 ${entries.length} 个对话`; if (!entries.length) { ui.extractList.innerHTML = '
没有找到符合条件的对话。
'; return; } const visible = entries.slice(0, 500); ui.extractList.innerHTML = visible.map((entry) => { const time = entry.update_time ? String(entry.update_time).slice(0, 10) : "时间未知"; const field = extractionFieldFromEntry(entry); const folder = extractionFolderFromEntry(entry) || "未归类"; return ``; }).join("") + (entries.length > visible.length ? `
列表只展示前 ${visible.length} 个;请缩小筛选范围后再打包。
` : ""); } async function prepareExtractionCatalog({ interactive = false } = {}) { const directory = await getAuthorizedDirectory(interactive); if (!directory.handle) return null; await ensureArchiveStructure(directory.handle); await readSharedClassificationRules(directory.handle); const index = await ensureIndex(directory.handle).then((result) => result.index); ui._extractIndex = index; populateExtractionFilters(index); return index; } async function previewExtraction() { const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); await readSharedClassificationRules(directory.handle); const index = await ensureIndex(directory.handle).then((result) => result.index); ui._extractIndex = index; populateExtractionFilters(index); const entries = filterExtractionEntries(index, extractionOptionsFromUi()); renderExtractionCandidates(entries); const formats = [ui.extractJson?.checked ? "JSON" : "", ui.extractMarkdown?.checked ? "Markdown" : ""].filter(Boolean).join(" + "); ui._extractDirty = false; setStatus(`找到 ${entries.length} 个对话${formats ? `|${formats}` : ""}`, entries.length ? "ok" : "warning"); return entries; } function packageBaseNameForExtraction(options, entries) { const scope = options.field || options.folder?.split("/").filter(Boolean).at(-1) || (options.keyword ? `搜索_${options.keyword}` : "对话"); return `${cleanSegment(scope, "对话")}_${cleanSegment(extractionTimeLabel(), "提取")}_${entries.length}个`; } function extractionPackageName(options, entries) { const manual = String(ui?.extractPackageName?.value || "").trim(); if (!manual) return packageBaseNameForExtraction(options, entries); const error = validatePathPart(manual, "本次提取名称"); if (error) throw new Error(error); return cleanSegment(manual, "本次提取"); } function localExtractionStamp(date = new Date()) { const pad = (value) => String(value).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}`; } function loadExtractionEntryState() { try { const parsed = JSON.parse(localStorage.getItem(EXTRACT_ENTRY_STATE_KEY) || "null"); const items = Array.isArray(parsed?.items) ? parsed.items .map((item) => ({ name: String(item?.name || "").trim(), kind: item?.kind === "directory" ? "directory" : "file" })) .filter((item) => item.name && !item.name.includes("/") && !item.name.includes("\\")) : []; return { items, updated_at: parsed?.updated_at || null, package_name: parsed?.package_name || null, kind: parsed?.kind || null }; } catch { return { items: [], updated_at: null, package_name: null, kind: null }; } } function saveExtractionEntryState(state) { const normalized = { items: Array.isArray(state?.items) ? state.items.map((item) => ({ name: String(item.name || ""), kind: item.kind === "directory" ? "directory" : "file" })).filter((item) => item.name) : [], updated_at: state?.updated_at || new Date().toISOString(), package_name: state?.package_name || null, kind: state?.kind || null, }; localStorage.setItem(EXTRACT_ENTRY_STATE_KEY, JSON.stringify(normalized)); return normalized; } async function extractionEntryTopLevel(handle) { const result = []; for await (const [name, entry] of handle.entries()) result.push({ name, kind: entry.kind }); return result.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")); } async function sameDirectoryHandle(a, b) { if (!a || !b) return false; if (typeof a.isSameEntry !== "function") return a === b; try { return await a.isSameEntry(b); } catch { return false; } } async function chooseExtractionEntryDirectory() { if (typeof window.showDirectoryPicker !== "function") throw new Error("当前浏览器不能选择本地文件夹"); const previous = ui?._extractEntryHandle || null; const options = { mode: "readwrite", id: "chatgpt-local-extract-entry" }; if (previous) options.startIn = previous; else options.startIn = "desktop"; let handle; try { handle = await window.showDirectoryPicker(options); } catch (error) { if (error?.name === "TypeError") handle = await window.showDirectoryPicker({ mode: "readwrite", id: "chatgpt-local-extract-entry" }); else throw error; } if (!(await verifyPermission(handle, true))) throw new Error("没有获得快捷入口的读写权限"); const same = await sameDirectoryHandle(previous, handle); if (!same) { const existing = await extractionEntryTopLevel(handle); if (existing.length) throw new Error("快捷入口需要是一个空文件夹。请新建一个空文件夹(例如桌面的“AI提取入口”)再选择,避免覆盖你自己的文件。"); saveExtractionEntryState({ items: [], package_name: null, kind: null }); } await idbSetStoredHandle(IDB_EXTRACT_ENTRY_KEY, handle); ui._extractEntryHandle = handle; updateExtractionOutputDisplay(); setStatus(`快捷入口已设置:${handle.name || "已选择的文件夹"}\n以后每次提取都会把最新结果放到这里。`, "ok"); return handle; } async function resolveExtractionEntry() { const handle = ui?._extractEntryHandle || await idbGetStoredHandle(IDB_EXTRACT_ENTRY_KEY); if (!handle || !(await verifyPermission(handle, false))) return null; ui._extractEntryHandle = handle; updateExtractionOutputDisplay(); return handle; } function updateExtractionOutputDisplay() { if (ui?.extractEntryLocation) { const handle = ui._extractEntryHandle; ui.extractEntryLocation.textContent = handle ? `快捷入口:${handle.name || "已选择的文件夹"}` : "快捷入口:还没设置(建议选桌面上的空文件夹)"; } if (ui?.extractHistoryLocation) { const libraryName = cachedDirectoryHandle?.name || "当前资料库"; ui.extractHistoryLocation.textContent = `历史记录:${libraryName} / ${EXTRACTION_HISTORY_FOLDER}`; } } async function validateExtractionEntryForUse(handle) { const existing = await extractionEntryTopLevel(handle); const state = loadExtractionEntryState(); const known = new Set(state.items.map((item) => item.name)); const extras = existing.filter((item) => !known.has(item.name)); if (extras.length) { throw new Error(`快捷入口里有不是上一次提取留下的内容:${extras.slice(0, 5).map((item) => item.name).join("、")}${extras.length > 5 ? "……" : ""}。为了避免误删,请移走这些内容,或换一个空文件夹。`); } return state; } async function clearPreviousExtractionEntry(handle) { const state = await validateExtractionEntryForUse(handle); for (const item of state.items) { try { await handle.removeEntry(item.name, { recursive: item.kind === "directory" }); } catch (error) { if (error?.name !== "NotFoundError") throw error; } } saveExtractionEntryState({ items: [], package_name: null, kind: null }); } async function fileExists(root, path) { try { await getFileHandle(root, path, false); return true; } catch (error) { if (error?.name === "NotFoundError") return false; throw error; } } async function folderExists(root, path) { try { await getDirectory(root, path, false); return true; } catch (error) { if (error?.name === "NotFoundError") return false; throw error; } } async function uniqueExtractionPath(root, basePath, baseName, kind) { for (let index = 1; index <= 999; index++) { const suffix = index === 1 ? "" : `_${index}`; const name = kind === "zip" ? `${baseName}${suffix}.zip` : `${baseName}${suffix}`; const path = joinPath(basePath, name); const exists = kind === "zip" ? await fileExists(root, path) : await folderExists(root, path); if (!exists) return path; } throw new Error("同名提取记录太多,请换一个名称"); } function uniqueFlatExtractionName(name, usedNames = new Set()) { const original = String(name || "file").replace(/\\/g, "/").split("/").filter(Boolean).pop() || "file"; if (!usedNames.has(original)) { usedNames.add(original); return original; } const match = original.match(/^(.*?)(\.[^.]*)?$/); const stem = match?.[1] || original; const ext = match?.[2] || ""; for (let index = 2; index <= 9999; index++) { const candidate = `${stem}__${index}${ext}`; if (!usedNames.has(candidate)) { usedNames.add(candidate); return candidate; } } throw new Error(`快捷入口里同名文件太多:${original}`); } async function collectDirectoryFilesFlat(sourceDirectory, prefix = "", output = []) { for await (const [name, handle] of sourceDirectory.entries()) { const relativePath = joinPath(prefix, name); if (handle.kind === "directory") await collectDirectoryFilesFlat(handle, relativePath, output); // 旧版本的提取历史可能带 package-manifest.json;快捷入口永远不复制这种工程文件。 else if (name !== "package-manifest.json") output.push({ relative_path: relativePath, source_name: name, handle }); } return output; } async function copyDirectoryContentsFlat(sourceDirectory, destinationRoot) { const files = await collectDirectoryFilesFlat(sourceDirectory); files.sort((a, b) => a.relative_path.localeCompare(b.relative_path, "zh-CN")); const usedNames = new Set(); const created = []; try { for (const item of files) { const destinationName = uniqueFlatExtractionName(item.source_name, usedNames); const bytes = new Uint8Array(await (await item.handle.getFile()).arrayBuffer()); await writeBinary(destinationRoot, destinationName, bytes); created.push({ name: destinationName, kind: "file", source_path: item.relative_path }); } return created; } catch (error) { error.extractionCreatedItems = created; throw error; } } async function syncHistoryToExtractionEntry({ historyRoot, historyPath, kind, baseName, entryHandle }) { await validateExtractionEntryForUse(entryHandle); await clearPreviousExtractionEntry(entryHandle); if (kind === "zip") { const entryName = `${baseName}.zip`; const sourceFile = await getFileHandle(historyRoot, historyPath, false); const bytes = new Uint8Array(await (await sourceFile.getFile()).arrayBuffer()); try { await writeBinary(entryHandle, entryName, bytes); saveExtractionEntryState({ items: [{ name: entryName, kind: "file" }], package_name: baseName, kind }); } catch (error) { // 即使写入中断,也把可能创建出的文件记下来,下一次可以安全清理。 saveExtractionEntryState({ items: [{ name: entryName, kind: "file" }], package_name: baseName, kind }); throw error; } return { items: [{ name: entryName, kind: "file" }], display: `${entryHandle.name || "快捷入口"} / ${entryName}` }; } const sourceDirectory = await getDirectory(historyRoot, historyPath, false); try { const flatItems = await copyDirectoryContentsFlat(sourceDirectory, entryHandle); const stateItems = flatItems.map((item) => ({ name: item.name, kind: "file" })); saveExtractionEntryState({ items: stateItems, package_name: baseName, kind }); return { items: stateItems, display: `${entryHandle.name || "快捷入口"}(文件已直接平铺)` }; } catch (error) { const partial = Array.isArray(error?.extractionCreatedItems) ? error.extractionCreatedItems : []; saveExtractionEntryState({ items: partial.map((item) => ({ name: item.name, kind: "file" })), package_name: baseName, kind }); throw error; } } function selectedExtractionEntries() { const selected = new Set([...(ui?.extractList?.querySelectorAll('[data-role="extract-select"]:checked') || [])].map((input) => input.value)); return (ui?._extractCandidates || []).filter((entry) => selected.has(String(entry.conversation_id))); } function invalidateExtractionPreview() { if (!ui) return; ui._extractCandidates = null; ui._extractDirty = true; if (ui.extractPreviewButton) ui.extractPreviewButton.textContent = "查看筛选结果"; } async function toggleExtractionPreview() { if (!ui?.extractResults || !ui?.extractPreviewButton) return []; const entries = await previewExtraction(); ui.extractResults.hidden = false; ui.extractPreviewButton.textContent = "重新查看结果"; ui._extractDirty = false; setExtractScene("results", 1); return entries; } function renderExtractionComplete(result) { if (!ui?.extractComplete || !ui?.extractCompleteText) return; ui.extractComplete.hidden = false; const kindLabel = result.kind === "folder" ? "普通文件" : "ZIP 压缩包"; const lines = [ `已准备好 ${result.conversationCount} 个对话(${kindLabel})`, result.entryError ? `快捷入口更新失败:${result.entryError}` : `快捷入口已更新:${result.entryDisplay}`, `历史已保存:${result.historyDisplay}`, ]; ui.extractCompleteText.textContent = lines.join("\n"); if (ui.extractDeleteHistoryButton) { ui.extractDeleteHistoryButton.disabled = !result.historyPath; ui.extractDeleteHistoryButton.textContent = result.historyPath ? "删除这次历史记录" : "历史记录已删除"; } setExtractScene("complete", 1); } async function generateExtractionPackage() { if (ui?._extractDirty || !ui?._extractCandidates) await previewExtraction(); const entries = selectedExtractionEntries(); if (!entries.length) throw new Error("请先筛选并选择至少一个对话"); if (entries.length > 500) throw new Error("一次最多提取 500 个对话,请缩小范围"); const includeJson = Boolean(ui.extractJson?.checked); const includeMarkdown = Boolean(ui.extractMarkdown?.checked); if (!includeJson && !includeMarkdown) throw new Error("请至少选择 JSON 或 Markdown 一种内容"); const archiveDirectory = await getAuthorizedDirectory(true); if (!archiveDirectory.handle) throw new Error(archiveDirectory.reason); await ensureArchiveStructure(archiveDirectory.handle); const entryHandle = await resolveExtractionEntry(); if (!entryHandle) throw new Error("请先点“选择 / 更换快捷入口”,选一个空文件夹。以后只需要设置一次。"); await validateExtractionEntryForUse(entryHandle); const options = extractionOptionsFromUi(); const kind = ui.extractOutputKind?.value === "folder" ? "folder" : "zip"; const baseName = extractionPackageName(options, entries); const historyName = `${localExtractionStamp()}_${baseName}`; const historyPath = await uniqueExtractionPath(archiveDirectory.handle, EXTRACTION_HISTORY_FOLDER, historyName, kind); const manifestItems = []; const zipEntries = []; for (const entry of entries) { if (includeJson) await getFileHandle(archiveDirectory.handle, entry.json_path, false) .catch((error) => { throw new Error(`${entry.title} 的 JSON 不存在或无法读取:${entry.json_path}(${error.message || error})`); }); if (includeMarkdown) await getFileHandle(archiveDirectory.handle, entry.markdown_path, false) .catch((error) => { throw new Error(`${entry.title} 的 Markdown 不存在或无法读取:${entry.markdown_path}(${error.message || error})`); }); } if (kind === "folder") await getDirectory(archiveDirectory.handle, historyPath, true); for (let index = 0; index < entries.length; index++) { const entry = entries[index]; setStatus(`正在提取 ${index + 1}/${entries.length}\n${entry.title}`, "normal", true); const item = { conversation_id: entry.conversation_id, title: entry.title, create_time: entry.create_time || null, update_time: entry.update_time || null, classification: entry.classification || null, json_path: includeJson ? entry.json_path : null, markdown_path: includeMarkdown ? entry.markdown_path : null, }; if (includeJson) { const content = await readText(archiveDirectory.handle, entry.json_path); if (content === null) throw new Error(`${entry.title} 的 JSON 不存在:${entry.json_path}`); if (kind === "zip") zipEntries.push({ name: normalizeStatePath(entry.json_path), data: utf8Bytes(content) }); else await writeText(archiveDirectory.handle, joinPath(historyPath, normalizeStatePath(entry.json_path)), content); } if (includeMarkdown) { const content = await readText(archiveDirectory.handle, entry.markdown_path); if (content === null) throw new Error(`${entry.title} 的 Markdown 不存在:${entry.markdown_path}`); if (kind === "zip") zipEntries.push({ name: normalizeStatePath(entry.markdown_path), data: utf8Bytes(content) }); else await writeText(archiveDirectory.handle, joinPath(historyPath, normalizeStatePath(entry.markdown_path)), content); } manifestItems.push(item); } const manifest = { schema_version: "1.1", tool_version: VERSION, generated_at: new Date().toISOString(), package_name: baseName, filter: { ...options, time_label: extractionTimeLabel() }, formats: { json: includeJson, markdown: includeMarkdown }, output: kind, delivery: "quick-entry-plus-history", conversation_count: manifestItems.length, conversations: manifestItems, }; const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; // 用户拿来拖拽/上传的提取结果只放真正选中的对话文件,不再夹带工程清单。 // 如果以后需要追溯这次提取的筛选条件,清单只留在插件自己的隐藏元数据目录。 if (kind === "zip") await writeBinary(archiveDirectory.handle, historyPath, createStoreZip(zipEntries)); const extractionMetaPath = joinPath(EXTRACTION_HISTORY_META_DIR, `${historyName}.json`); await writeText(archiveDirectory.handle, extractionMetaPath, manifestText); const historyDisplay = `${archiveDirectory.handle.name || "当前资料库"} / ${historyPath}`; const result = { historyRoot: archiveDirectory.handle, historyPath, metaPath: extractionMetaPath, kind, baseName, historyDisplay, entryDisplay: entryHandle.name || "快捷入口", entryError: null, conversationCount: entries.length, }; try { const entryResult = await syncHistoryToExtractionEntry({ historyRoot: archiveDirectory.handle, historyPath, kind, baseName, entryHandle, }); result.entryDisplay = entryResult.display; } catch (error) { result.entryError = error?.message || String(error); } ui._lastExtraction = result; renderExtractionComplete(result); setStatus( result.entryError ? `提取历史已经保存,但快捷入口没更新成功。\n${result.entryError}\n历史:${historyDisplay}` : `提取完成\n${entries.length} 个对话\n快捷入口已经换成这一次的内容。\n历史:${historyDisplay}`, result.entryError ? "warning" : "ok", ); return result; } async function deleteLastExtraction() { const last = ui?._lastExtraction; if (!last?.historyRoot || !last.historyPath) throw new Error("还没有可以删除的本次历史记录"); if (!(await verifyPermission(last.historyRoot, true))) throw new Error("资料库需要重新授权"); const parts = normalizeStatePath(last.historyPath).split("/").filter(Boolean); const name = parts.pop(); const parent = await getDirectory(last.historyRoot, parts.join("/"), false); if (!window.confirm(`删除这次提取历史?\n${last.historyDisplay}\n\n只删除资料库里的这条历史记录。快捷入口和原始对话都不会删除。`)) return; await parent.removeEntry(name, { recursive: last.kind === "folder" }); if (last.metaPath) { try { await removeFile(last.historyRoot, last.metaPath); } catch (error) { if (error?.name !== "NotFoundError") console.warn("提取历史元数据清理失败", error); } } ui._lastExtraction.historyPath = null; ui._lastExtraction.metaPath = null; ui._lastExtraction.historyDisplay = "这次历史记录已删除"; renderExtractionComplete(ui._lastExtraction); setStatus("这次提取历史已删除;快捷入口和原始对话都没有变化。", "ok"); } function resetExtractionComplete() { ui._lastExtraction = null; setExtractScene("filter", -1); setStatus("可以继续选下一批对话。", "normal"); } async function runRebuild() { cancelRequested = false; const directory = await getAuthorizedDirectory(true); if (!directory.handle) throw new Error(directory.reason); const result = await rebuildIndex(directory.handle, (text) => setStatus(text, "normal", true)); const archives = await readIndexedArchives(directory.handle, result.index); await writeJson(directory.handle, "timeline.json", timelineFromArchives(archives.archives)); await refreshConversationStateFromIndex(directory.handle, result.index); setStatus(`本地档案已重新读取\n识别对话 ${Object.keys(result.index.conversations).length} 个\n损坏、未知或非插件 JSON ${result.issues.length} 个${result.issues.length ? `\n${result.issues.slice(0, 12).join("\n")}` : ""}`, result.issues.length ? "warning" : "ok"); } async function runAction(action) { if (running && action !== "cancel") return; if (action === "cancel") { cancelRequested = true; setStatus("正在取消;当前文件操作结束后停止……", "warning", true); return; } running = true; updateBusy(); try { if (action === "view-export") setMainView("export"); else if (action === "view-manage") { setMainView("manage"); await refreshRuleFolderChoices({ interactive:false }); updateManageHomeSummary(ui?._folderAudit); } else if (action === "view-extract") { setMainView("extract"); updateExtractionOutputDisplay(); await prepareExtractionCatalog({ interactive:false }); } else if (action === "manage-home") setManageScene("home",-1); else if (action === "manage-rules") { renderClassificationRules(); setManageScene("rules",1); } else if (action === "manage-ignored") { if (cachedDirectoryHandle) await refreshFolderDiscoveries({ interactive:false,announce:false,autoApply:false }); renderIgnoredItems(); setManageScene("ignored",1); } else if (action === "manage-organizer") { renderConversationIssues(ui?._folderAudit); setManageScene("organizer",1); } else if (action === "rules-new") { resetRuleForm(); ui._ruleReturnScene = "rules"; if (ui.ruleBackButton) ui.ruleBackButton.textContent = "‹ 分类规则"; setManageScene("rule-editor",1,{focus:ui.ruleField}); } else if (action === "rules-back") cancelRuleEditor(); else if (action === "rule-delete-current") { if (!ui?._editingRuleId) cancelRuleEditor(); else { await deleteClassificationRule(ui._editingRuleId); renderClassificationRules(); setManageScene("rules",-1); } } else if (action === "health") await runHealthCheck(); else if (action === "sync") await runSync(); else if (action === "folder-scan") { const audit = await refreshFolderDiscoveries({ interactive:true,announce:true,autoApply:true }); if (audit) routeManageAfterAudit(audit); } else if (action === "state-apply") { await applyDetectedConversationChange(ui?._pendingConversationId); routeManageAfterAudit(ui?._folderAudit); } else if (action === "issue-delete") { await confirmMissingIssueDeleted(ui?._pendingConversationId); routeManageAfterAudit(ui?._folderAudit); } else if (action === "state-apply-all") { await applyAllDetectedConversationChanges(); routeManageAfterAudit(ui?._folderAudit); } else if (action === "remote-title-scan") { await scanRemoteTitleRules(); setManageScene("rules",1); } else if (action === "remote-rule-prefill") prefillRemoteRuleCandidate(ui?._pendingCandidateIndex); else if (action === "remote-rule-ignore") await ignoreRemoteRuleCandidate(ui?._pendingCandidateIndex); else if (action === "ignore-restore-folder") await restoreIgnoredFolder(ui?._pendingIgnoredFolder); else if (action === "ignore-restore-title") await restoreIgnoredRemoteTitle(ui?._pendingIgnoredTitleIndex); else if (action === "ignore-register-folder") await registerIgnoredFolder(ui?._pendingFolderPath); else if (action === "ignore-register-title") await registerIgnoredRemoteTitle(ui?._pendingIgnoredTitleIndex); else if (action === "ignore-delete-empty-folder") { await removeEmptyDiscoveredFolder(ui?._pendingFolderPath); renderIgnoredItems(); } else if (action === "extract-preview") await toggleExtractionPreview(); else if (action === "extract-back-filter") setExtractScene("filter",-1); else if (action === "extract-next-output") { if (!selectedExtractionEntries().length) throw new Error("至少选择一个对话,再进入下一步"); setExtractScene("output",1); } else if (action === "extract-back-results") setExtractScene("results",-1); else if (action === "extract-generate") await generateExtractionPackage(); else if (action === "extract-choose-entry") await chooseExtractionEntryDirectory(); else if (action === "extract-delete-last") await deleteLastExtraction(); else if (action === "extract-new") resetExtractionComplete(); else if (action === "extract-select-all") { const inputs = [...ui.extractList.querySelectorAll('[data-role="extract-select"]')]; const select = inputs.some((input) => !input.checked); for (const input of inputs) input.checked = select; } else if (action === "folder-reconcile") await reconcileRegisteredFolderMoves(); else if (action === "folder-register") prefillRuleFromFolder(ui?._pendingFolderPath); else if (action === "folder-organize") selectConversationIssuesForFolder(ui?._pendingFolderPath); else if (action === "folder-ignore") { await ignoreDiscoveredFolder(ui?._pendingFolderPath); routeManageAfterAudit(ui?._folderAudit); } else if (action === "folder-delete-empty") { await removeEmptyDiscoveredFolder(ui?._pendingFolderPath); routeManageAfterAudit(ui?._folderAudit); } else if (action === "folder-empty-delete") { await applyEmptyFolderDecision(ui?._pendingFolderPath,"delete"); routeManageAfterAudit(ui?._folderAudit); } else if (action === "folder-empty-mirror") { await applyEmptyFolderDecision(ui?._pendingFolderPath,"mirror"); routeManageAfterAudit(ui?._folderAudit); } else if (action === "folder-empty-ignore") { await ignoreEmptyFolderDecision(ui?._pendingFolderPath); routeManageAfterAudit(ui?._folderAudit); } else if (action === "issue-select-all") { const inputs = [...ui.issueList.querySelectorAll('[data-role="issue-select"]')]; const select = inputs.some((input) => !input.checked); for (const input of inputs) input.checked = select; } else if (action === "issue-preview") buildSelectedMigrationPreview(); else if (action === "issue-apply") { await applySelectedConversationRule(); routeManageAfterAudit(ui?._folderAudit); } else if (action === "rule-add") { const sourceFolder = String(ui?._discoveredFolderPath || ""); const returnScene = ui?._ruleReturnScene || (sourceFolder ? "discover" : "rules"); await addClassificationRule(); renderClassificationRules(); if (sourceFolder && returnScene === "discover") routeManageAfterAudit(ui?._folderAudit); else if (sourceFolder && returnScene === "ignored") { renderIgnoredItems(); setManageScene("ignored",-1); } else setManageScene("rules",-1); } else if (action === "rule-edit") editClassificationRule(ui?._pendingEditRuleId); else if (action === "rule-delete") { await deleteClassificationRule(ui?._pendingDeleteRuleId); renderClassificationRules(); } else if (action === "choose") { const handle = await chooseDirectory(); await refreshRuleFolderChoices({ interactive:false }); setStatus(`本地资料库已选择:${handle.name || "已授权目录"}`, "ok"); } else if (action === "rebuild") await runRebuild(); else if (action === "clear") { if (window.confirm("清除目录授权句柄和插件配置?本地导出文件不会被删除。")) { await idbClear(); localStorage.removeItem(RULES_STORAGE_KEY); localStorage.removeItem(EXTRACT_ENTRY_STATE_KEY); for (const key of LEGACY_RULES_STORAGE_KEYS) localStorage.removeItem(key); cachedDirectoryHandle = null; if (ui) ui._extractEntryHandle = null; directoryHandleLoaded = true; updateDirectoryDisplay(); updateExtractionOutputDisplay(); setStatus("插件配置已清除;本地文件未删除。","ok"); } } } catch (error) { const cancelled = cancelRequested || /取消|AbortError/.test(`${error?.name} ${error?.message}`); if (action === "sync") { setStatus(cancelled ? "导出已取消\n尚未确认安全落盘的内容不会覆盖本地文件。" : `导出失败\n${error?.message || error}`,"error"); } else { setStatus(cancelled ? "操作已取消;已经完整保存的文件会保留。" : `操作失败\n${error?.message || error}`,cancelled ? "warning" : "error"); } } finally { running = false; cancelRequested = false; updateBusy(); syncMainViewHeight(); } } function prefersReducedMotion() { try { return Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches); } catch { return false; } } function animateAttention(element, { subtle = false } = {}) { if (!element || prefersReducedMotion() || typeof element.animate !== "function") return; element.animate( subtle ? [{ transform:"translate3d(0,5px,0) scale(.994)" }, { transform:"translate3d(0,0,0) scale(1)" }] : [{ transform:"scale(.988)" }, { transform:"scale(1.006)" }, { transform:"scale(1)" }], { duration: subtle ? 180 : 320, easing:"cubic-bezier(.2,.8,.2,1)" }, ); } function setStatus(text, kind = "normal", cancellable = false) { createUi(); const wasHidden = ui.status.hidden; const previousKind = ui.status.dataset.kind || ""; ui.status.hidden = false; ui.status.textContent = text; ui.status.dataset.kind = kind; ui.cancel.hidden = !cancellable; if (wasHidden || previousKind !== kind) animateAttention(ui.status, { subtle:true }); } function updateBusy() { if (!ui) return; for (const button of ui.shadow.querySelectorAll("button[data-action]:not([data-action='cancel'])")) button.disabled = running; ui.trigger.setAttribute("aria-busy", running ? "true" : "false"); ui.panel?.toggleAttribute("data-busy", running); } function managePendingCounts(audit = ui?._folderAudit) { const changes = audit?.stateChangeResult?.changes || audit?.conversationChanges || []; const missing = audit?.missingFileIssues || []; const folders = (audit?.folderChanges || []).filter((item) => item.type === "decide-empty-folder"); const issues = (audit?.conversationIssues || []).filter((issue) => !isMissingOnlyIssue(issue)); const discoveries = audit?.unregisteredFolders || []; const stateCount = changes.length + missing.length + folders.length; return { state: stateCount, organizer: issues.length, discoveries: discoveries.length, blocking: stateCount + issues.length, total: stateCount + issues.length + discoveries.length, }; } function updateManageHomeSummary(audit = ui?._folderAudit) { if (!ui?.manageStatusTitle || !ui?.manageStatusCopy) return; if (!audit) { ui.manageStatusTitle.textContent = "还没有检查本地变化"; ui.manageStatusCopy.textContent = "在文件管理器里移动、删除或新建目录后,点一次“检查变化”。"; return; } const counts = managePendingCounts(audit); if (!counts.total) { ui.manageStatusTitle.textContent = "文件都整理好了"; ui.manageStatusCopy.textContent = "JSON 和 Markdown 没有需要你处理的变化。"; } else if (counts.blocking) { ui.manageStatusTitle.textContent = `还有 ${counts.blocking} 件事需要确认`; ui.manageStatusCopy.textContent = counts.discoveries ? `另外还有 ${counts.discoveries} 个新目录,可登记也可忽略。` : "一次只处理当前这一件。"; } else { ui.manageStatusTitle.textContent = `发现 ${counts.discoveries} 个新目录`; ui.manageStatusCopy.textContent = "这些只是整理建议,不影响导出;可以登记,也可以忽略。"; } } function sceneMap(kind) { if (!ui) return {}; const stage = kind === "manage" ? ui.manageStage : ui.extractStage; if (!stage) return {}; return Object.fromEntries([...stage.querySelectorAll(`[data-${kind}-scene]`)].map((node) => [node.dataset[`${kind}Scene`], node])); } function syncMainViewHeight() { if (!ui?.viewStage) return; const views = { export:ui.exportView, manage:ui.manageView, extract:ui.extractView }; const current = views[ui._mainView || "export"]; if (!current) return; requestAnimationFrame(() => { const height = Math.max(1, current.scrollHeight); ui.viewStage.style.height = `${height}px`; }); } function setScene(kind, name, direction = 1, { focus = null } = {}) { if (!ui) return; const stage = kind === "manage" ? ui.manageStage : ui.extractStage; const key = kind === "manage" ? "_manageScene" : "_extractScene"; const scenes = sceneMap(kind); const next = scenes[name]; if (!stage || !next) return; const previousName = ui[key] && scenes[ui[key]] ? ui[key] : name; const previous = scenes[previousName]; if (previous === next) { for (const [sceneName, element] of Object.entries(scenes)) { element.hidden = sceneName !== name; element.inert = sceneName !== name; } ui[key] = name; if (focus) requestAnimationFrame(() => focus.focus({ preventScroll:true })); syncMainViewHeight(); return; } const reduce = prefersReducedMotion() || typeof next.animate !== "function"; const oldHeight = previous?.offsetHeight || stage.offsetHeight || 1; next.hidden = false; next.inert = false; next.style.position = "absolute"; next.style.inset = "0 auto auto 0"; next.style.width = "100%"; next.style.visibility = "hidden"; const nextHeight = Math.max(1, next.scrollHeight); next.style.visibility = ""; stage.style.height = `${oldHeight}px`; // 先提交旧高度,再切到目标高度,确保 CSS height transition 真正发生,而不是结束时突然跳高。 void stage.offsetHeight; stage.style.height = `${nextHeight}px`; if (previous) previous.inert = true; if (!reduce && previous) { // 进入和退出从同一帧开始,不再故意空等 65ms;让下一场景立即接住视线。 previous.animate( [{ transform:"translate3d(0,0,0) scale(1)", opacity:1 }, { transform:`translate3d(${-14 * direction}px,0,0) scale(.994)`, opacity:.10 }], { duration:180, easing:"cubic-bezier(.4,0,.6,1)", fill:"both" }, ); next.animate( [{ transform:`translate3d(${20 * direction}px,0,0) scale(.992)`, opacity:.66 }, { transform:"translate3d(0,0,0) scale(1)", opacity:1 }], { duration:230, easing:"cubic-bezier(.2,.8,.2,1)", fill:"both" }, ); } ui[key] = name; window.setTimeout(() => { for (const [sceneName, element] of Object.entries(scenes)) { element.hidden = sceneName !== name; element.inert = sceneName !== name; element.style.position = ""; element.style.inset = ""; element.style.width = ""; element.getAnimations?.().forEach((animation) => animation.cancel()); } stage.style.height = ""; if (focus) focus.focus({ preventScroll:true }); syncMainViewHeight(); }, reduce ? 0 : 240); } function setManageScene(name, direction = 1, options = {}) { setScene("manage", name, direction, options); } function setExtractScene(name, direction = 1, options = {}) { setScene("extract", name, direction, options); } function routeManageAfterAudit(audit = ui?._folderAudit) { updateManageHomeSummary(audit); const counts = managePendingCounts(audit); if (counts.state) setManageScene("changes", 1); else if (counts.organizer) setManageScene("organizer", 1); else if (counts.discoveries) setManageScene("discover", 1); else setManageScene("home", -1); } function setMainView(view) { if (!ui) return; const selected = ["export","manage","extract"].includes(view) ? view : "export"; const order = ["export","manage","extract"]; const index = order.indexOf(selected); if (ui.tabs) ui.tabs.dataset.activeView = selected; if (ui.exportTab) ui.exportTab.dataset.active = selected === "export" ? "true" : "false"; if (ui.manageTab) ui.manageTab.dataset.active = selected === "manage" ? "true" : "false"; if (ui.extractTab) ui.extractTab.dataset.active = selected === "extract" ? "true" : "false"; ui._mainView = selected; if (ui.viewTrack) ui.viewTrack.style.transform = `translate3d(${-100 * index}%,0,0)`; for (const [name, element] of Object.entries({export:ui.exportView,manage:ui.manageView,extract:ui.extractView})) { if (!element) continue; element.inert = name !== selected; element.setAttribute("aria-hidden", name === selected ? "false" : "true"); } syncMainViewHeight(); } async function setPanelOpen(open) { if (!ui?.panel) return; if (ui._panelAnimation) { try { ui._panelAnimation.cancel(); } catch {} ui._panelAnimation = null; } if (open) { if (!ui.panel.hidden) return; ui.panel.hidden = false; ui.trigger.setAttribute("aria-expanded","true"); updateDirectoryDisplay(); const directory = cachedDirectoryHandle; if (directory) readSharedClassificationRules(directory).then(() => { renderIgnoredItems(); renderClassificationRules(); }).catch(() => {}); syncMainViewHeight(); if (!prefersReducedMotion() && typeof ui.panel.animate === "function") { ui._panelAnimation = ui.panel.animate( [{ transform:"translate3d(14px,-3px,0) scale(.975)", opacity:.42 }, { transform:"translate3d(0,0,0) scale(1)", opacity:1 }], { duration:230, easing:"cubic-bezier(.2,.8,.2,1)" }, ); try { await ui._panelAnimation.finished; } catch {} ui._panelAnimation = null; } return; } if (ui.panel.hidden) return; ui.trigger.setAttribute("aria-expanded","false"); if (!prefersReducedMotion() && typeof ui.panel.animate === "function") { ui._panelAnimation = ui.panel.animate( [{ transform:"translate3d(0,0,0) scale(1)", opacity:1 }, { transform:"translate3d(14px,-3px,0) scale(.975)", opacity:.18 }], { duration:175, easing:"cubic-bezier(.4,0,.8,.2)" }, ); try { await ui._panelAnimation.finished; } catch {} ui._panelAnimation = null; } ui.panel.hidden = true; } function createUi() { if (ui || document.getElementById(HOST_ID)) return; const host = document.createElement("div"); host.id = HOST_ID; host.style.cssText = "position:fixed;right:14px;top:92px;z-index:2147483647"; document.body.insertAdjacentElement("beforeend", host); const shadow = host.attachShadow({ mode:"open" }); shadow.innerHTML = ` `; ui = { host, shadow, trigger:shadow.querySelector(".trigger"), panel:shadow.querySelector(".panel"), status:shadow.querySelector(".status"), cancel:shadow.querySelector(".cancel"), tabs:shadow.querySelector('[data-role="tabs"]'), viewStage:shadow.querySelector('[data-role="view-stage"]'), viewTrack:shadow.querySelector('[data-role="view-track"]'), exportView:shadow.querySelector('[data-role="view-export"]'), manageView:shadow.querySelector('[data-role="view-manage"]'), extractView:shadow.querySelector('[data-role="view-extract"]'), exportTab:shadow.querySelector('[data-role="tab-export"]'), manageTab:shadow.querySelector('[data-role="tab-manage"]'), extractTab:shadow.querySelector('[data-role="tab-extract"]'), manageStage:shadow.querySelector('[data-role="manage-stage"]'), extractStage:shadow.querySelector('[data-role="extract-stage"]'), manageStatusTitle:shadow.querySelector('[data-role="manage-status-title"]'), manageStatusCopy:shadow.querySelector('[data-role="manage-status-copy"]'), ruleCount:shadow.querySelector('[data-role="rule-count"]'), ignoredCount:shadow.querySelector('[data-role="ignored-count"]'), manageIgnoredButton:shadow.querySelector('[data-role="manage-ignored-button"]'), stateChangesProgress:shadow.querySelector('[data-role="state-changes-progress"]'), folderDiscoveryProgress:shadow.querySelector('[data-role="folder-discovery-progress"]'), exportDirectoryName:shadow.querySelector('[data-role="export-directory-name"]'), manageDirectoryName:shadow.querySelector('[data-role="manage-directory-name"]'), extractDirectoryName:shadow.querySelector('[data-role="extract-directory-name"]'), stateChanges:shadow.querySelector('[data-role="state-changes"]'), stateChangesList:shadow.querySelector('[data-role="state-changes-list"]'), folderDiscovery:shadow.querySelector('[data-role="folder-discovery"]'), folderDiscoveryList:shadow.querySelector('[data-role="folder-discovery-list"]'), organizer:shadow.querySelector('[data-role="organizer"]'), issueList:shadow.querySelector('[data-role="issue-list"]'), issueTargetRule:shadow.querySelector('[data-role="issue-target-rule"]'), issuePreview:shadow.querySelector('[data-role="issue-preview"]'), ruleDetails:shadow.querySelector('[data-role="rule-details"]'), remoteRuleCandidates:shadow.querySelector('[data-role="remote-rule-candidates"]'), ruleField:shadow.querySelector('[data-role="rule-field"]'), ruleConnector:shadow.querySelector('[data-role="rule-connector"]'), ruleConnectorCustom:shadow.querySelector('[data-role="rule-connector-custom"]'), ruleRootFolder:shadow.querySelector('[data-role="rule-root-folder"]'), ruleRootFolderChoice:shadow.querySelector('[data-role="rule-root-folder-choice"]'), ruleFolderSeparator:shadow.querySelector('[data-role="rule-folder-separator"]'), ruleFolderSeparatorCustom:shadow.querySelector('[data-role="rule-folder-separator-custom"]'), ruleSubfolderField:shadow.querySelector('[data-role="rule-subfolder-field"]'), ruleSubfolderPath:shadow.querySelector('[data-role="rule-subfolder-path"]'), ruleSubfolderChoice:shadow.querySelector('[data-role="rule-subfolder-choice"]'), rulePreview:shadow.querySelector('[data-role="rule-preview"]'), keepOldAlias:shadow.querySelector('[data-role="keep-old-alias"]'), ruleSave:shadow.querySelector('[data-role="rule-save"]'), ruleDeleteCurrent:shadow.querySelector('[data-role="rule-delete-current"]'), ruleBackButton:shadow.querySelector('[data-role="rule-back-button"]'), rulesList:shadow.querySelector('[data-role="rules-list"]'), ignoredDetails:shadow.querySelector('[data-role="ignored-details"]'), ignoredList:shadow.querySelector('[data-role="ignored-list"]'), extractTime:shadow.querySelector('[data-role="extract-time"]'), extractField:shadow.querySelector('[data-role="extract-field"]'), extractStartField:shadow.querySelector('[data-role="extract-start-field"]'), extractEndField:shadow.querySelector('[data-role="extract-end-field"]'), extractStart:shadow.querySelector('[data-role="extract-start"]'), extractEnd:shadow.querySelector('[data-role="extract-end"]'), extractFolder:shadow.querySelector('[data-role="extract-folder"]'), extractIncludeSubfolders:shadow.querySelector('[data-role="extract-subfolders"]'), extractKeyword:shadow.querySelector('[data-role="extract-keyword"]'), extractJson:shadow.querySelector('[data-role="extract-json"]'), extractMarkdown:shadow.querySelector('[data-role="extract-markdown"]'), extractOutputKind:shadow.querySelector('[data-role="extract-output-kind"]'), extractPackageName:shadow.querySelector('[data-role="extract-package-name"]'), extractEntryLocation:shadow.querySelector('[data-role="extract-entry-location"]'), extractHistoryLocation:shadow.querySelector('[data-role="extract-history-location"]'), extractPreviewButton:shadow.querySelector('[data-role="extract-preview-button"]'), extractResults:shadow.querySelector('[data-role="extract-results"]'), extractSummary:shadow.querySelector('[data-role="extract-summary"]'), extractList:shadow.querySelector('[data-role="extract-list"]'), extractComplete:shadow.querySelector('[data-role="extract-complete"]'), extractCompleteText:shadow.querySelector('[data-role="extract-complete-text"]'), extractDeleteHistoryButton:shadow.querySelector('[data-role="extract-delete-history"]'), _pendingDeleteRuleId:"",_pendingEditRuleId:"",_pendingFolderPath:"",_pendingConversationId:"",_pendingCandidateIndex:"",_pendingIgnoredFolder:"",_pendingIgnoredTitleIndex:"", _editingRuleId:"",_discoveredFolderPath:"",_folderAudit:null,_remoteRuleCandidates:[],_ruleFolderPaths:[],_extractCandidates:null,_extractIndex:null,_extractDirty:true,_extractEntryHandle:null,_lastExtraction:null, _mainView:"export",_manageScene:"home",_extractScene:"filter",_ruleReturnScene:"rules",_panelAnimation:null,_viewResizeObserver:null, }; renderClassificationRules(); renderIgnoredItems(); updateRulePreview(); updateManageHomeSummary(); setMainView("export"); updateDirectoryDisplay(); updateExtractionOutputDisplay(); if (typeof ResizeObserver !== "undefined") { ui._viewResizeObserver = new ResizeObserver(() => syncMainViewHeight()); for (const view of [ui.exportView,ui.manageView,ui.extractView]) if (view) ui._viewResizeObserver.observe(view); } idbGetStoredHandle(IDB_EXTRACT_ENTRY_KEY).then((handle) => { if (!ui || !handle) return; ui._extractEntryHandle = handle; updateExtractionOutputDisplay(); }).catch(() => {}); ui.trigger.addEventListener("click", () => { void setPanelOpen(ui.panel.hidden); }); shadow.addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); if (!button) return; if (button.dataset.action === "rule-delete") ui._pendingDeleteRuleId = button.dataset.ruleId || ""; if (button.dataset.action === "rule-edit") ui._pendingEditRuleId = button.dataset.ruleId || ""; if ((button.dataset.action.startsWith("folder-") || button.dataset.action.startsWith("ignore-")) && button.dataset.folderPath) ui._pendingFolderPath = button.dataset.folderPath || ""; if (["state-apply","issue-delete"].includes(button.dataset.action)) ui._pendingConversationId = button.dataset.conversationId || ""; if (["remote-rule-prefill","remote-rule-ignore"].includes(button.dataset.action)) ui._pendingCandidateIndex = button.dataset.candidateIndex || ""; if (button.dataset.action === "ignore-restore-folder") ui._pendingIgnoredFolder = button.dataset.folderPath || ""; if (button.dataset.action === "ignore-restore-title" || button.dataset.action === "ignore-register-title") ui._pendingIgnoredTitleIndex = button.dataset.ignoreIndex || ""; runAction(button.dataset.action); }); shadow.addEventListener("input", (event) => { if (event.target === ui.ruleRootFolder) populateRuleSubfolderChoices(); if (event.target.matches("[data-role^='rule-']")) updateRulePreview(); if ([ui.extractStart,ui.extractEnd,ui.extractKeyword].includes(event.target)) invalidateExtractionPreview(); }); shadow.addEventListener("change", (event) => { if (event.target === ui.ruleConnector) { ui.ruleConnectorCustom.hidden = ui.ruleConnector.value !== "__custom__"; if (!ui.ruleConnectorCustom.hidden) ui.ruleConnectorCustom.focus(); } if (event.target === ui.ruleFolderSeparator) { ui.ruleFolderSeparatorCustom.hidden = ui.ruleFolderSeparator.value !== "__custom__"; if (!ui.ruleFolderSeparatorCustom.hidden) ui.ruleFolderSeparatorCustom.focus(); } if (event.target === ui.ruleRootFolderChoice && ui.ruleRootFolderChoice.value) { ui.ruleRootFolder.value = ui.ruleRootFolderChoice.value; populateRuleSubfolderChoices(); } if (event.target === ui.ruleSubfolderChoice && ui.ruleSubfolderChoice.value) { ui.ruleSubfolderPath.value = ui.ruleSubfolderChoice.value; if (["__unset__","__none__"].includes(ui.ruleFolderSeparator.value)) setDelimiterForm(ui.ruleFolderSeparator,ui.ruleFolderSeparatorCustom,"/"); ui.ruleSubfolderField.hidden = false; } if (event.target.matches("[data-role^='rule-'], [data-role='keep-old-alias']")) updateRulePreview(); if (event.target === ui.extractTime) { const custom = ui.extractTime.value === "custom"; ui.extractStartField.hidden = !custom; ui.extractEndField.hidden = !custom; } if ([ui.extractTime,ui.extractField,ui.extractFolder,ui.extractIncludeSubfolders,ui.extractJson,ui.extractMarkdown].includes(event.target)) invalidateExtractionPreview(); }); document.addEventListener("pointerdown", (event) => { if (!ui.panel.hidden && !event.composedPath().includes(host)) void setPanelOpen(false); }, true); } function initializeLocalSync() { createUi(); idbGetHandle().then((handle) => { if (!directoryHandleLoaded) { cachedDirectoryHandle = handle; directoryHandleLoaded = true; updateDirectoryDisplay(); if (handle) verifyPermission(handle, false).then((ok) => ok ? ensureArchiveStructure(handle) : null).catch(() => {}); } }).catch(() => { if (!directoryHandleLoaded) directoryHandleLoaded = true; updateDirectoryDisplay(); }); window.setInterval(function restoreInterfaceIfRemoved() { if (!document.getElementById(HOST_ID)) { ui = null; createUi(); } }, 3000); } const startWhenDocumentIsReady = () => initializeLocalSync(); if (document.readyState !== "loading") startWhenDocumentIsReady(); else window.addEventListener("DOMContentLoaded", startWhenDocumentIsReady, { once: true }); })();