feat: wire modal import state
This commit is contained in:
parent
d52a52b574
commit
ec32d591df
@ -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);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user