zotero-kb/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md
Saberlve cf7e2feb19 feat: Implement project management features including rename and delete functionality
- Added project renaming capability in the workspace with appropriate API endpoints.
- Implemented project deletion functionality, ensuring project directories are removed.
- Updated UI to support project renaming and deletion, including inline editing and confirmation dialogs.
- Enhanced batch generation feature for project items with selection and progress tracking.
- Added tests for project renaming and deletion to ensure functionality and integrity.
2026-04-21 16:55:42 +08:00

14 KiB
Raw Blame History

Project Items And Inline Card Detail Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Show imported project items immediately after import, improve collection row readability, and render item detail inline under the active entry instead of in a fixed detail area.

Architecture: Normalize project view data around a single items list in ProjectService, with each item carrying card_status plus card fields when available. Update the center panel renderer in the inline template script to render both pending and done items, and expand one items detail inline at a time.

Tech Stack: Python, FastAPI template rendering, inline HTML/CSS/JavaScript, pytest


Task 1: Define Pending-Item Project View Contract

Files:

  • Modify: tests/test_projects.py

  • Modify: tests/test_api.py

  • Test: tests/test_projects.py

  • Test: tests/test_api.py

  • Step 1: Write the failing test

Add to tests/test_projects.py:

def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
    config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
    workspace = Workspace(config)
    workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")

    items_index_path = config.workspace_dir / "library" / "index" / "items.json"
    items_index_path.parent.mkdir(parents=True, exist_ok=True)
    items_index_path.write_text(
        json.dumps(
            {
                "PAPER0001": {
                    "item_key": "PAPER0001",
                    "title": "Pending Item",
                    "creators": ["Alice Smith"],
                    "year": "2024",
                    "item_type": "journalArticle",
                }
            },
            ensure_ascii=False,
        ),
        encoding="utf-8",
    )

    service = ProjectService(config.workspace_dir)
    payload = service.add_items("thesis-ch2", ["PAPER0001"])

    assert payload["selected_items"] == ["PAPER0001"]
    assert len(payload["items"]) == 1
    assert payload["items"][0]["item_key"] == "PAPER0001"
    assert payload["items"][0]["card_status"] == "pending"

Add to tests/test_api.py:

def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
    config = make_test_config(tmp_path)
    config.bridge_file.unlink()
    app = create_app(config, llm_client=FakeLlmClient())
    create_project = _route(app, "/api/projects", "POST")
    import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")

    create_project(
        CreateProjectRequest(
            project_id="thesis-ch2",
            name="Thesis Chapter 2",
            llm_provider="openai",
            llm_model="gpt-5-mini",
        )
    )

    payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})

    assert payload["project_view"]["selected_items"] == ["PAPER0001"]
    assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
    assert payload["project_view"]["items"][0]["card_status"] == "pending"
  • Step 2: Run test to verify it fails

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q Expected: FAIL because project view currently only returns cards, not normalized items.

  • Step 3: Write minimal implementation

Modify src/zotero_kb/projects.py so _rebuild_project_index() returns normalized items for both pending and done entries:

    def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
        cards_index = self._read_json(self.index_dir / "cards.json")
        items_index = self._read_json(self.index_dir / "items.json")
        collections_index = self._read_json(self.index_dir / "collections.json")

        project_items: list[dict[str, object]] = []
        project_cards: list[dict[str, object]] = []
        for item_key in selected_items:
            item_data = items_index.get(item_key, {})
            card_data = cards_index.get(item_key)
            item_payload = {
                "item_key": item_key,
                "title": item_data.get("title", "Untitled"),
                "creators": item_data.get("creators", []),
                "year": item_data.get("year"),
                "item_type": item_data.get("item_type", "unknown"),
                "card_status": "done" if card_data else "pending",
                "summary": card_data.get("summary") if card_data else None,
                "claims": card_data.get("claims", []) if card_data else [],
                "quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
            }
            project_items.append(item_payload)
            if card_data:
                project_cards.append(card_data)

        project_collections = [
            payload
            for payload in collections_index.values()
            if set(payload.get("item_keys", [])) & set(selected_items)
        ]
        payload = {
            "project_id": project_id,
            "selected_items": selected_items,
            "items": project_items,
            "cards": project_cards,
            "collections": project_collections,
        }
  • Step 4: Run test to verify it passes

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q Expected: PASS

  • Step 5: Commit
git add tests/test_projects.py tests/test_api.py src/zotero_kb/projects.py
git commit -m "feat: add pending project items to project view"

Task 2: Define UI Contract For Inline Project Item Detail

Files:

  • Modify: tests/test_ui.py

  • Test: tests/test_ui.py

  • Step 1: Write the failing test

Add to tests/test_ui.py:

def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
    html = _get_index_html(tmp_path)

    assert 'id="project-item-list"' in html
    assert "expandedProjectItemKey" in html
    assert "function renderProjectItems(items)" in html
    assert "function toggleProjectItemDetail(itemKey)" in html
    assert 'class="project-item-detail"' in html
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
    html = _get_index_html(tmp_path)

    assert ".project-item strong" in html
    assert ".project-item .meta" in html
    assert ".collection-row-toggle" in html
  • Step 2: Run test to verify it fails

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q Expected: FAIL because the page still renders only cards and uses the fixed detail region.

  • Step 3: Write minimal implementation

Update src/zotero_kb/templates/index.html:

  • replace the center list container with:
        <div id="project-item-list" class="card-list"></div>
  • replace the fixed detail surface with a passive note:
        <div class="section surface">
          <h3>项目状态</h3>
          <div class="empty">导入后,项目条目会立即出现在上方列表;点击条目可就地展开详情。</div>
        </div>
  • add state:
        expandedProjectItemKey: null,
  • add renderers:
      function toggleProjectItemDetail(itemKey) {
        state.expandedProjectItemKey = state.expandedProjectItemKey === itemKey ? null : itemKey;
        renderProjectItems(state.currentProjectItems);
      }
      function renderProjectItems(items) {
        state.currentProjectItems = items || [];
        if (!state.currentProjectItems.length) {
          elements.projectItemList.innerHTML = '<div class="empty">当前项目还没有条目。先导入 Zotero 文献。</div>';
          return;
        }

        elements.projectItemList.innerHTML = "";
        for (const item of state.currentProjectItems) {
          const card = document.createElement("div");
          card.className = "card-item";
          const statusLabel = item.card_status === "done" ? "已生成卡片" : "未生成卡片";
          card.innerHTML = `
            <strong>${escapeHtml(item.title)}</strong>
            <div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
            <p>${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}</p>
            <div class="row">
              <button type="button" class="secondary">查看详情</button>
              <button type="button" class="${item.card_status === "done" ? "secondary" : ""}">${statusLabel}</button>
              <button type="button" class="danger">移出项目</button>
            </div>
          `;
          const [detailButton, statusButton, removeButton] = card.querySelectorAll("button");
          detailButton.addEventListener("click", () => toggleProjectItemDetail(item.item_key));
          statusButton.disabled = true;
          removeButton.addEventListener("click", () => removeCard(item.item_key));

          if (state.expandedProjectItemKey === item.item_key) {
            const detail = document.createElement("div");
            detail.className = "project-item-detail";
            detail.innerHTML = item.card_status === "done"
              ? `
                <div class="meta">${item.item_key}</div>
                <p>${item.summary || "暂无摘要"}</p>
                <h4>Claims</h4>
                <ul>${(item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"}</ul>
                <h4>Quotable</h4>
                <ul>${(item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>"}</ul>
              `
              : `
                <div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
                <p>这篇文献已经导入到当前项目,但还没有生成卡片内容。</p>
              `;
            card.appendChild(detail);
          }

          elements.projectItemList.appendChild(card);
        }
      }
  • add CSS:
      .project-item strong,
      .project-item .meta {
        color: var(--ink);
      }
      .project-item.active strong,
      .project-item.active .meta {
        color: #f5f7ef;
      }
      .collection-row-toggle {
        color: var(--accent-strong);
        font-weight: 700;
      }
      .project-item-detail {
        margin-top: 0.85rem;
        padding-top: 0.85rem;
        border-top: 1px solid var(--line);
      }
  • Step 4: Run test to verify it passes

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q Expected: PASS

  • Step 5: Commit
git add tests/test_ui.py src/zotero_kb/templates/index.html
git commit -m "feat: render project items with inline detail"

Task 3: Wire Project Item Rendering Into Import And Project Selection Flows

Files:

  • Modify: src/zotero_kb/templates/index.html

  • Test: tests/test_api.py

  • Test: tests/test_ui.py

  • Step 1: Write the failing test

Reuse the UI/API failures from Tasks 1 and 2. No new production code first.

  • Step 2: Run tests to verify the current behavior fails

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q Expected: FAIL until the project list renderer uses items.

  • Step 3: Write minimal implementation

Update the inline script in src/zotero_kb/templates/index.html:

  • state:
        currentProjectItems: [],
  • elements:
        projectItemList: document.getElementById("project-item-list"),
  • remove showCardDetail() and stop using elements.cardDetail

  • change empty-state and load paths:

      async function selectProject(projectId, rerender = true) {
        state.currentProjectId = projectId;
        updateImportActionState();
        if (rerender) {
          renderProjects();
        }
        const payload = await api(`/api/projects/${projectId}/cards`);
        renderProjectItems(payload.items || []);
      }
        renderProjectItems(payload.project_view?.items || []);
        renderProjectItems([]);
  • Step 4: Run full targeted tests

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q Expected: PASS

  • Step 5: Commit
git add src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py
git commit -m "feat: show imported project items before card generation"

Task 4: Final Verification

Files:

  • Modify: none

  • Test: tests/test_projects.py

  • Test: tests/test_api.py

  • Test: tests/test_ui.py

  • Step 1: Run full test suite

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q Expected: PASS

  • Step 2: Run JS syntax guard

Run: UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q Expected: PASS

  • Step 3: Manual verification

Run:

UV_CACHE_DIR=/tmp/uv-cache uv run python main.py

Manual checks:

  • unselected collections remain readable in the import window

  • importing an item into a project makes it appear immediately in the center panel

  • pending item detail expands inline below the clicked item

  • generated item detail expands inline below the clicked item

  • Step 4: Commit

git add src/zotero_kb/projects.py src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md
git commit -m "feat: show project items inline before card generation"