feat: wire modal import state

This commit is contained in:
Saberlve 2026-04-15 12:34:26 +08:00
parent d52a52b574
commit ec32d591df
2 changed files with 322 additions and 9 deletions

View File

@ -380,12 +380,43 @@
<script>
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
function readImportSessionState() {
try {
const raw = window.sessionStorage.getItem(IMPORT_SESSION_STORAGE_KEY);
if (!raw) {
return {};
}
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const importSessionState = readImportSessionState();
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
collectionTree: [],
isImportModalOpen: false,
selectedCollectionKey: typeof importSessionState.selectedCollectionKey === "string"
? importSessionState.selectedCollectionKey
: null,
expandedCollectionKeys: new Set(
Array.isArray(importSessionState.expandedCollectionKeys)
? importSessionState.expandedCollectionKeys.filter((value) => typeof value === "string")
: []
),
selectedItemKeys: new Set(
Array.isArray(importSessionState.selectedItemKeys)
? importSessionState.selectedItemKeys.filter((value) => typeof value === "string")
: []
),
visibleCollectionItems: [],
};
const elements = {
@ -396,6 +427,15 @@
importModalOverlay: document.getElementById("import-modal-overlay"),
openImportModalButton: document.getElementById("open-import-modal-button"),
closeImportModalButton: document.getElementById("close-import-modal-button"),
importModalProjectLabel: document.getElementById("import-modal-project-label"),
collectionStatus: document.getElementById("collection-status"),
collectionTree: document.getElementById("modal-collection-tree"),
collectionItems: document.getElementById("modal-collection-items"),
selectedCount: document.getElementById("modal-selected-count"),
selectDescendantsButton: document.getElementById("modal-select-descendants-button"),
clearSelectionButton: document.getElementById("modal-clear-selection-button"),
importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"),
previewPopover: document.getElementById("item-preview-popover"),
currentProjectLabel: document.getElementById("current-project-label"),
cardList: document.getElementById("card-list"),
cardDetail: document.getElementById("card-detail"),
@ -413,6 +453,17 @@
planPanel: document.getElementById("plan-panel"),
};
function persistImportSessionState() {
window.sessionStorage.setItem(
IMPORT_SESSION_STORAGE_KEY,
JSON.stringify({
selectedCollectionKey: state.selectedCollectionKey,
expandedCollectionKeys: Array.from(state.expandedCollectionKeys),
selectedItemKeys: Array.from(state.selectedItemKeys),
})
);
}
function setStatus(target, message, isError = false) {
target.textContent = message || "";
target.classList.toggle("error", Boolean(isError));
@ -527,37 +578,285 @@
`).join("");
}
function syncImportModalVisibility() {
elements.importModal.hidden = !state.isImportModalOpen;
elements.importModal.setAttribute("aria-hidden", state.isImportModalOpen ? "false" : "true");
document.body.classList.toggle("modal-open", state.isImportModalOpen);
function renderSelectedCount() {
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
}
function openImportModal() {
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
elements.importModalProjectLabel.textContent = hasProject ? `当前项目:${state.currentProjectId}` : "未选择项目";
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.selectDescendantsButton.disabled = !state.visibleCollectionItems.length;
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size;
renderSelectedCount();
}
function collectCollectionParents(nodes, parentKey = null, lookup = new Map()) {
for (const node of nodes) {
lookup.set(node.collection_key, parentKey);
collectCollectionParents(node.children || [], node.collection_key, lookup);
}
return lookup;
}
function ensureSelectedCollectionExpanded() {
if (!state.selectedCollectionKey) {
return;
}
const parentLookup = collectCollectionParents(state.collectionTree);
let currentKey = parentLookup.get(state.selectedCollectionKey) || null;
while (currentKey) {
state.expandedCollectionKeys.add(currentKey);
currentKey = parentLookup.get(currentKey) || null;
}
}
function renderCollectionTree() {
if (!state.collectionTree.length) {
elements.collectionTree.innerHTML = '<div class="empty">暂无 collection。</div>';
return;
}
elements.collectionTree.innerHTML = "";
const fragment = document.createDocumentFragment();
function appendNode(node, depth) {
const wrapper = document.createElement("div");
wrapper.style.display = "grid";
wrapper.style.gap = "0.35rem";
wrapper.style.marginLeft = `${depth * 0.9}rem`;
const row = document.createElement("div");
row.className = "row";
row.style.alignItems = "stretch";
row.style.gap = "0.45rem";
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
if (hasChildren) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "secondary";
toggle.style.flex = "0 0 auto";
toggle.style.width = "auto";
toggle.style.padding = "0.55rem 0.75rem";
toggle.textContent = state.expandedCollectionKeys.has(node.collection_key) ? "" : "+";
toggle.addEventListener("click", () => {
if (state.expandedCollectionKeys.has(node.collection_key)) {
state.expandedCollectionKeys.delete(node.collection_key);
} else {
state.expandedCollectionKeys.add(node.collection_key);
}
persistImportSessionState();
renderCollectionTree();
});
row.appendChild(toggle);
}
const button = document.createElement("button");
button.type = "button";
button.className = "project-item" + (node.collection_key === state.selectedCollectionKey ? " active" : "");
button.style.textAlign = "left";
button.innerHTML = `
<strong>${node.name}</strong>
<div class="meta">${node.direct_item_count} 篇直接文献 · ${node.descendant_item_count} 篇含子目录</div>
`;
button.addEventListener("click", () => {
loadCollectionItems(node.collection_key).catch((error) => {
setStatus(elements.collectionStatus, error.message, true);
});
});
row.appendChild(button);
wrapper.appendChild(row);
fragment.appendChild(wrapper);
if (hasChildren && state.expandedCollectionKeys.has(node.collection_key)) {
for (const child of node.children) {
appendNode(child, depth + 1);
}
}
}
for (const node of state.collectionTree) {
appendNode(node, 0);
}
elements.collectionTree.appendChild(fragment);
}
function renderCollectionItems() {
if (!state.selectedCollectionKey) {
elements.collectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>';
updateImportActionState();
return;
}
if (!state.visibleCollectionItems.length) {
elements.collectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>';
updateImportActionState();
return;
}
elements.collectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) {
const row = document.createElement("label");
row.className = "project-item";
row.style.display = "grid";
row.style.gridTemplateColumns = "auto 1fr";
row.style.gap = "0.75rem";
row.style.alignItems = "start";
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.abstract || "暂无摘要"}</p>
</div>
`;
row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key));
elements.collectionItems.appendChild(row);
}
updateImportActionState();
}
async function loadCollectionTree() {
setStatus(elements.collectionStatus, "正在加载 collection...");
const payload = await api("/api/zotero/collections/tree");
state.collectionTree = Array.isArray(payload.collections) ? payload.collections : [];
if (!state.selectedCollectionKey && state.collectionTree.length) {
state.selectedCollectionKey = state.collectionTree[0].collection_key;
}
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
if (state.selectedCollectionKey) {
await loadCollectionItems(state.selectedCollectionKey);
return;
}
state.visibleCollectionItems = [];
renderCollectionItems();
setStatus(elements.collectionStatus, "");
}
async function loadCollectionItems(collectionKey) {
state.selectedCollectionKey = collectionKey;
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
setStatus(elements.collectionStatus, "正在加载文献...");
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
state.visibleCollectionItems = Array.isArray(payload.items) ? payload.items : [];
renderCollectionItems();
const visibleCount = state.visibleCollectionItems.length;
setStatus(
elements.collectionStatus,
visibleCount ? `当前目录及子目录共 ${visibleCount} 篇文献。` : "当前目录及子目录下没有可导入文献。"
);
}
function toggleItemSelection(itemKey) {
if (state.selectedItemKeys.has(itemKey)) {
state.selectedItemKeys.delete(itemKey);
} else {
state.selectedItemKeys.add(itemKey);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
function selectVisibleItems() {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
function clearSelectedItems() {
state.selectedItemKeys.clear();
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
async function importSelectedItems() {
if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState();
return;
}
setStatus(elements.collectionStatus, "正在导入...");
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
});
state.selectedItemKeys.clear();
persistImportSessionState();
renderCollectionItems();
renderCards(payload.project_view?.cards || []);
setStatus(elements.collectionStatus, "导入完成。");
updateImportActionState();
closeImportModal();
}
async function openImportModal() {
state.isImportModalOpen = true;
syncImportModalVisibility();
elements.importModal.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
updateImportActionState();
if (!state.collectionTree.length) {
try {
await loadCollectionTree();
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
} else if (state.selectedCollectionKey && !state.visibleCollectionItems.length) {
try {
await loadCollectionItems(state.selectedCollectionKey);
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
} else {
renderCollectionTree();
renderCollectionItems();
}
}
function closeImportModal() {
state.isImportModalOpen = false;
syncImportModalVisibility();
elements.importModal.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
}
async function loadProjects() {
const projects = await api("/api/projects");
state.projects = projects;
if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) {
state.currentProjectId = projects.length ? projects[0].id : null;
}
if (!state.currentProjectId && projects.length) {
state.currentProjectId = projects[0].id;
}
renderProjects();
updateImportActionState();
if (state.currentProjectId) {
await selectProject(state.currentProjectId, false);
return;
}
elements.currentProjectLabel.textContent = "当前未选择项目。";
renderCards([]);
}
async function selectProject(projectId, rerender = true) {
state.currentProjectId = projectId;
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
updateImportActionState();
if (rerender) {
renderProjects();
}
@ -600,9 +899,18 @@
elements.refreshProjectsButton.addEventListener("click", loadProjects);
elements.openImportModalButton.addEventListener("click", openImportModal);
elements.openImportModalButton.addEventListener("click", () => {
openImportModal();
});
elements.closeImportModalButton.addEventListener("click", closeImportModal);
elements.importModalOverlay.addEventListener("click", closeImportModal);
elements.selectDescendantsButton.addEventListener("click", selectVisibleItems);
elements.clearSelectionButton.addEventListener("click", clearSelectedItems);
elements.importSelectedItemsButton.addEventListener("click", () => {
importSelectedItems().catch((error) => {
setStatus(elements.collectionStatus, error.message, true);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
@ -660,7 +968,8 @@
elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
elements.planTab.addEventListener("click", () => activateTab("plan"));
syncImportModalVisibility();
closeImportModal();
updateImportActionState();
loadProjects().catch((error) => {
setStatus(elements.projectStatus, error.message, true);
});

View File

@ -41,3 +41,7 @@ def test_index_contains_import_modal_controls(tmp_path) -> None:
assert 'id="item-preview-popover"' in html
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
assert 'isImportModalOpen: false' in html
assert "function openImportModal()" in html
assert "function closeImportModal()" in html
assert "function updateImportActionState()" in html
assert 'document.body.classList.toggle("modal-open"' in html