feat: wire modal import state
This commit is contained in:
parent
d52a52b574
commit
ec32d591df
@ -380,12 +380,43 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
|
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 = {
|
const state = {
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentCards: [],
|
currentCards: [],
|
||||||
|
collectionTree: [],
|
||||||
isImportModalOpen: false,
|
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 = {
|
const elements = {
|
||||||
@ -396,6 +427,15 @@
|
|||||||
importModalOverlay: document.getElementById("import-modal-overlay"),
|
importModalOverlay: document.getElementById("import-modal-overlay"),
|
||||||
openImportModalButton: document.getElementById("open-import-modal-button"),
|
openImportModalButton: document.getElementById("open-import-modal-button"),
|
||||||
closeImportModalButton: document.getElementById("close-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"),
|
currentProjectLabel: document.getElementById("current-project-label"),
|
||||||
cardList: document.getElementById("card-list"),
|
cardList: document.getElementById("card-list"),
|
||||||
cardDetail: document.getElementById("card-detail"),
|
cardDetail: document.getElementById("card-detail"),
|
||||||
@ -413,6 +453,17 @@
|
|||||||
planPanel: document.getElementById("plan-panel"),
|
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) {
|
function setStatus(target, message, isError = false) {
|
||||||
target.textContent = message || "";
|
target.textContent = message || "";
|
||||||
target.classList.toggle("error", Boolean(isError));
|
target.classList.toggle("error", Boolean(isError));
|
||||||
@ -527,37 +578,285 @@
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncImportModalVisibility() {
|
function renderSelectedCount() {
|
||||||
elements.importModal.hidden = !state.isImportModalOpen;
|
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
|
||||||
elements.importModal.setAttribute("aria-hidden", state.isImportModalOpen ? "false" : "true");
|
|
||||||
document.body.classList.toggle("modal-open", state.isImportModalOpen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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() {
|
function closeImportModal() {
|
||||||
state.isImportModalOpen = false;
|
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() {
|
async function loadProjects() {
|
||||||
const projects = await api("/api/projects");
|
const projects = await api("/api/projects");
|
||||||
state.projects = 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) {
|
if (!state.currentProjectId && projects.length) {
|
||||||
state.currentProjectId = projects[0].id;
|
state.currentProjectId = projects[0].id;
|
||||||
}
|
}
|
||||||
renderProjects();
|
renderProjects();
|
||||||
|
updateImportActionState();
|
||||||
if (state.currentProjectId) {
|
if (state.currentProjectId) {
|
||||||
await selectProject(state.currentProjectId, false);
|
await selectProject(state.currentProjectId, false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
elements.currentProjectLabel.textContent = "当前未选择项目。";
|
||||||
|
renderCards([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectProject(projectId, rerender = true) {
|
async function selectProject(projectId, rerender = true) {
|
||||||
state.currentProjectId = projectId;
|
state.currentProjectId = projectId;
|
||||||
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
|
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
|
||||||
|
updateImportActionState();
|
||||||
if (rerender) {
|
if (rerender) {
|
||||||
renderProjects();
|
renderProjects();
|
||||||
}
|
}
|
||||||
@ -600,9 +899,18 @@
|
|||||||
|
|
||||||
elements.refreshProjectsButton.addEventListener("click", loadProjects);
|
elements.refreshProjectsButton.addEventListener("click", loadProjects);
|
||||||
|
|
||||||
elements.openImportModalButton.addEventListener("click", openImportModal);
|
elements.openImportModalButton.addEventListener("click", () => {
|
||||||
|
openImportModal();
|
||||||
|
});
|
||||||
elements.closeImportModalButton.addEventListener("click", closeImportModal);
|
elements.closeImportModalButton.addEventListener("click", closeImportModal);
|
||||||
elements.importModalOverlay.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) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape" && state.isImportModalOpen) {
|
if (event.key === "Escape" && state.isImportModalOpen) {
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
@ -660,7 +968,8 @@
|
|||||||
elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
|
elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
|
||||||
elements.planTab.addEventListener("click", () => activateTab("plan"));
|
elements.planTab.addEventListener("click", () => activateTab("plan"));
|
||||||
|
|
||||||
syncImportModalVisibility();
|
closeImportModal();
|
||||||
|
updateImportActionState();
|
||||||
loadProjects().catch((error) => {
|
loadProjects().catch((error) => {
|
||||||
setStatus(elements.projectStatus, error.message, true);
|
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 'id="item-preview-popover"' in html
|
||||||
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
|
assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html
|
||||||
assert 'isImportModalOpen: false' 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