diff --git a/src/zotero_kb/templates/index.html b/src/zotero_kb/templates/index.html index 6b0ede0..f3a95c6 100644 --- a/src/zotero_kb/templates/index.html +++ b/src/zotero_kb/templates/index.html @@ -236,6 +236,26 @@ grid-template-columns: 22rem minmax(0, 1fr); min-height: 0; } + .import-split { display: grid; grid-template-columns: 40% 60%; height: 100%; gap: 1px; background: var(--line); } + .import-left, .import-right { background: var(--surface); overflow-y: auto; padding: 1rem; } + .import-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; font-weight: 600; } + .import-list { display: flex; flex-direction: column; gap: 0.25rem; } + .import-item { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; border-radius: 6px; cursor: pointer; } + .import-item.pending { background: var(--panel); } + .import-item.done { background: transparent; opacity: 0.7; } + .import-item.selected { outline: 2px solid var(--accent, #4a90d9); } + .import-item input[type="checkbox"] { margin-top: 0.25rem; } + .import-item.done input[type="checkbox"] { display: none; } + .import-item .item-title { font-size: 0.875rem; font-weight: 500; } + .import-item .item-meta { font-size: 0.75rem; color: var(--ink-dim, #666); } + .import-list-footer { margin-top: 0.75rem; font-weight: 600; font-size: 0.875rem; } + .import-actions { padding: 0.75rem 1rem; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; } + #generate-cards-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .link-btn { background: none; border: none; color: var(--accent, #4a90d9); cursor: pointer; font-size: 0.8rem; } + #item-preview .preview-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; } + #item-preview .preview-meta { font-size: 0.8rem; color: var(--ink-dim); margin-bottom: 0.75rem; } + #item-preview .preview-abstract { font-size: 0.85rem; line-height: 1.5; } + .hint { color: var(--ink-dim); font-size: 0.875rem; } .window-pane { display: grid; gap: 0.6rem; @@ -359,14 +379,27 @@
-
- Collections -
-
+
+
+
+ Pending (0) + +
+
+ +
+ +
+
+
+

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