-
Collections
-
-
+
+
+
+
+
Click an item to preview
+
+
-
-
Collection Items
-
+
+
@@ -495,6 +528,10 @@
hoveredItemKey: null,
hoverTimerId: null,
previewItemKey: null,
+ importItems: [],
+ selectedPendingKeys: new Set(),
+ isGenerating: false,
+ generatingProgress: { current: 0, total: 0 },
};
const elements = {
@@ -690,6 +727,13 @@
target.classList.toggle("error", Boolean(isError));
}
+ function escapeHtml(str) {
+ if (str === null || str === undefined) return "";
+ const div = document.createElement("div");
+ div.textContent = String(str);
+ return div.innerHTML;
+ }
+
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
@@ -1095,6 +1139,98 @@
closeImportWindow();
}
+ async function loadImportState() {
+ const resp = await fetch(`/api/projects/${state.currentProjectId}/import-state`);
+ const data = await resp.json();
+ state.importItems = data.items;
+ renderImportItemList();
+ updateImportCounts(data.pending_count, data.done_count);
+ }
+
+ function renderImportItemList() {
+ const list = document.getElementById('import-items-list');
+ list.innerHTML = '';
+ state.importItems.forEach(item => {
+ const isDone = item.card_status === 'done';
+ const div = document.createElement('div');
+ div.className = `import-item ${item.card_status}`;
+ div.dataset.key = item.item_key;
+ div.innerHTML = `
+
+
+
${escapeHtml(item.title)}
+
${item.creators.join(', ')} · ${item.year || 'n.d.'}
+
+ `;
+ if (!isDone) {
+ div.querySelector('input').addEventListener('change', (e) => {
+ if (e.target.checked) state.selectedPendingKeys.add(item.item_key);
+ else state.selectedPendingKeys.delete(item.item_key);
+ updateGenerateButton();
+ div.classList.toggle('selected', e.target.checked);
+ });
+ }
+ div.addEventListener('click', (e) => { if (e.target.tagName !== 'INPUT') showItemPreview(item); });
+ list.appendChild(div);
+ });
+ }
+
+ function showItemPreview(item) {
+ document.getElementById('item-preview').innerHTML = `
+
${escapeHtml(item.title)}
+
${item.creators.join(', ')} · ${item.year || 'n.d.'} · ${item.item_type}
+
${escapeHtml(item.abstract || 'No abstract available.')}
+ `;
+ }
+
+ function updateGenerateButton() {
+ const btn = document.getElementById('generate-cards-btn');
+ const count = state.selectedPendingKeys.size;
+ btn.disabled = count === 0 || state.isGenerating;
+ btn.textContent = state.isGenerating
+ ? `生成中 ${state.generatingProgress.current}/${state.generatingProgress.total}`
+ : `生成选中卡片 (${count})`;
+ }
+
+ async function generateSelectedCards() {
+ const keys = Array.from(state.selectedPendingKeys);
+ if (!keys.length) return;
+ state.isGenerating = true;
+ state.generatingProgress = { current: 0, total: keys.length };
+ updateGenerateButton();
+
+ try {
+ const resp = await fetch(`/api/projects/${state.currentProjectId}/cards/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ item_keys: keys }),
+ });
+ const data = await resp.json();
+
+ data.items.forEach(updated => {
+ const item = state.importItems.find(i => i.item_key === updated.item_key);
+ if (item) item.card_status = updated.card_status;
+ });
+
+ state.selectedPendingKeys.clear();
+ renderImportItemList();
+
+ const counts = {
+ pending: state.importItems.filter(i => i.card_status === 'pending').length,
+ done: state.importItems.filter(i => i.card_status === 'done').length,
+ };
+ updateImportCounts(counts.pending, counts.done);
+ } finally {
+ state.isGenerating = false;
+ updateGenerateButton();
+ }
+ }
+
+ function updateImportCounts(pending, done) {
+ document.getElementById('pending-count').textContent = pending;
+ document.getElementById('done-count').textContent = done;
+ }
+
async function openImportWindow() {
state.isImportWindowOpen = true;
elements.importWindowShell.hidden = false;
@@ -1103,25 +1239,7 @@
cancelAbstractPreview();
updateImportActionState();
initWindowFromSession();
- if (!state.collectionTree.length) {
- try {
- await loadCollectionTree();
- } catch (error) {
- setStatus(elements.windowCollectionStatus, error.message, true);
- }
- } else if (
- state.selectedCollectionKey &&
- (!state.collectionItemsLoaded && state.loadingCollectionKey !== state.selectedCollectionKey)
- ) {
- try {
- await loadCollectionItems(state.selectedCollectionKey);
- } catch (error) {
- setStatus(elements.windowCollectionStatus, error.message, true);
- }
- } else {
- renderCollectionTree();
- renderCollectionItems();
- }
+ await loadImportState();
}
function closeImportWindow() {
@@ -1130,6 +1248,8 @@
elements.importWindowShell.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
cancelAbstractPreview();
+ state.importItems = [];
+ state.selectedPendingKeys.clear();
}
async function loadProjects() {
@@ -1215,6 +1335,15 @@
setStatus(elements.windowCollectionStatus, error.message, true);
});
});
+ document.getElementById('select-all-pending').addEventListener('click', () => {
+ document.querySelectorAll('.import-item.pending input[type="checkbox"]').forEach(cb => {
+ cb.checked = true;
+ state.selectedPendingKeys.add(cb.dataset.key);
+ cb.closest('.import-item').classList.add('selected');
+ });
+ updateGenerateButton();
+ });
+ document.getElementById('generate-cards-btn').addEventListener('click', generateSelectedCards);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow();
diff --git a/tests/test_ui.py b/tests/test_ui.py
index ff85c23..77b5a22 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -37,12 +37,13 @@ def test_index_contains_import_window_controls(tmp_path) -> None:
assert 'id="window-restore-button"' in html
assert 'id="import-window-minimized-bar"' in html
assert 'id="import-window-project-label"' in html
- assert 'id="window-collection-tree"' in html
- assert 'id="window-collection-items"' in html
- assert 'id="window-selected-count"' in html
- assert 'id="window-clear-selection-button"' in html
- assert 'id="window-import-selected-items-button"' in html
- assert 'id="item-preview-popover"' in html
+ # New two-column import layout
+ assert 'id="import-items-list"' in html
+ assert 'id="item-preview"' in html
+ assert 'id="pending-count"' in html
+ assert 'id="done-count"' in html
+ assert 'id="select-all-pending"' in html
+ assert 'id="generate-cards-btn"' in html
assert 'isImportWindowOpen: false' in html
assert "function openImportWindow()" in html
assert "function closeImportWindow()" in html
@@ -65,3 +66,56 @@ def test_index_window_frame_css(tmp_path) -> None:
assert "restoreWindow" in html
assert "initWindowFromSession" in html
assert "persistWindowSessionState" in html
+
+
+def test_index_import_window_has_two_column_layout(tmp_path) -> None:
+ html = _get_index_html(tmp_path)
+
+ # Two-column split layout structure
+ assert 'class="import-split"' in html
+ assert 'class="import-left"' in html
+ assert 'class="import-right"' in html
+
+ # Left column: list header, list, footer
+ assert 'class="import-list-header"' in html
+ assert 'id="import-items-list"' in html
+ assert 'class="import-list-footer"' in html
+ assert 'id="pending-count"' in html
+ assert 'id="done-count"' in html
+ assert 'id="select-all-pending"' in html
+
+ # Right column: preview
+ assert 'id="item-preview"' in html
+
+ # Action bar
+ assert 'class="import-actions"' in html
+ assert 'id="generate-cards-btn"' in html
+
+ # CSS: import-split grid layout
+ assert ".import-split" in html
+ # CSS: import-item classes
+ assert ".import-item" in html
+ assert ".import-item.pending" in html
+ assert ".import-item.done" in html
+
+
+def test_index_has_import_state_and_cards_generate_api_calls(tmp_path) -> None:
+ html = _get_index_html(tmp_path)
+
+ # loadImportState function fetches import-state
+ assert "function loadImportState()" in html
+ assert "/api/projects/" in html
+ assert "import-state" in html
+
+ # generateSelectedCards function calls cards/generate
+ assert "function generateSelectedCards()" in html
+ assert "/cards/generate" in html
+
+ # openImportWindow calls loadImportState
+ assert "openImportWindow()" in html
+ # Check loadImportState is called inside openImportWindow (await statement appears)
+ assert "await loadImportState()" in html
+
+ # generate-cards-btn has click listener for generateSelectedCards
+ assert 'generate-cards-btn' in html
+ assert 'generateSelectedCards' in html