From 854d5458e11976b405e7c7b4b95649ac495df9eb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 01:37:28 +0800 Subject: [PATCH 1/2] Bare 'autopapertoppt' (no args) now launches the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression: users running 'autopapertoppt' / 'autopapertoppt.exe' alone hit 'one of the arguments --query/-q --paper/-p --pdf is required' because the mutex group is required=True. The obvious "just open the app" gesture errored instead of launching the GUI, even though the GUI extra ships an entry point that does exactly that. Pre-parse in cli.main() now treats an empty argv the same as 'gui' — both dispatch to autopapertoppt.gui.app.main via _dispatch_gui. The existing 'gui' subcommand still works and still forwards trailing tokens (e.g. 'autopapertoppt gui --debug') to the GUI's own argv. When the [gui] extra isn't installed, _dispatch_gui prints the existing "pip install autopapertoppt[gui]" hint and exits 2 — same as before. Also add an epilog to the argparse parser pointing at the three GUI launch routes (bare, 'gui' subcommand, autopapertoppt-gui entry point) so users running --help see the option without having to read the README. Tests: test_cli_bare_invocation_dispatches_gui — empty argv → GUI dispatcher test_cli_gui_subcommand_dispatches_gui — 'gui' + tokens → forwards --- autopapertoppt/cli.py | 21 +++++++++++++++------ tests/test_cli.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/autopapertoppt/cli.py b/autopapertoppt/cli.py index 62fe997..f5d5732 100644 --- a/autopapertoppt/cli.py +++ b/autopapertoppt/cli.py @@ -56,6 +56,12 @@ def build_parser() -> argparse.ArgumentParser: "Search papers by keywords across multiple sources and export them " "to slides, summaries, and BibTeX." ), + epilog=( + "Launching the desktop GUI: run `autopapertoppt` with no arguments, " + "or `autopapertoppt gui`, or the standalone `autopapertoppt-gui` entry " + "point. The GUI extras must be installed: pip install " + "autopapertoppt[gui]." + ), ) parser.add_argument( "--version", action="version", version=f"autopapertoppt {__version__}" @@ -868,13 +874,16 @@ def _dispatch_gui(gui_argv: list[str]) -> int: def main(argv: list[str] | None = None) -> int: _configure_stdio_for_unicode() - # Pre-parse for the ``gui`` subcommand. argparse can't host it - # cleanly because the existing query/paper/pdf mode is a required - # mutually-exclusive group, and forcing one of those flags just to - # open the GUI would be silly. + # Pre-parse for the ``gui`` subcommand and the bare-invocation case. + # argparse can't host either cleanly because the existing + # query / paper / pdf mode is a required mutually-exclusive group; + # without this shim, ``autopapertoppt`` (no args, the obvious + # "just open the app" gesture) errors with the mutex requirement + # instead of launching the GUI. raw_argv = list(sys.argv[1:] if argv is None else argv) - if raw_argv and raw_argv[0] == "gui": - return _dispatch_gui(raw_argv[1:]) + if not raw_argv or raw_argv[0] == "gui": + gui_extra_argv = raw_argv[1:] if raw_argv else [] + return _dispatch_gui(gui_extra_argv) parser = build_parser() args = parser.parse_args(raw_argv) try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 64787e1..9edc858 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -207,6 +207,40 @@ def test_cli_rejects_both_query_and_paper(tmp_path): ) +def test_cli_bare_invocation_dispatches_gui(monkeypatch): + """`autopapertoppt` with no args MUST route to the GUI dispatcher. + + Regression: the bare command used to crash with `one of the arguments + --query/-q --paper/-p --pdf is required` because the mutex group is + `required=True`. Users expected a "just open the app" gesture, and + the GUI extras' own entry point already does that — so the bare + CLI now mirrors `autopapertoppt gui`. + """ + called: dict[str, list[str]] = {} + + def fake_dispatch_gui(argv: list[str]) -> int: + called["argv"] = argv + return 0 + + monkeypatch.setattr(cli_module, "_dispatch_gui", fake_dispatch_gui) + assert cli_module.main([]) == 0 + assert called == {"argv": []} + + +def test_cli_gui_subcommand_dispatches_gui(monkeypatch): + """`autopapertoppt gui` still routes to the GUI dispatcher, with any + trailing tokens forwarded to the GUI's own argv parser.""" + called: dict[str, list[str]] = {} + + def fake_dispatch_gui(argv: list[str]) -> int: + called["argv"] = argv + return 0 + + monkeypatch.setattr(cli_module, "_dispatch_gui", fake_dispatch_gui) + assert cli_module.main(["gui", "--debug"]) == 0 + assert called == {"argv": ["--debug"]} + + def test_cli_rejects_doi_identifier_until_resolver_lands(tmp_path): code = cli_module.main( ["--paper", "10.1234/example", "--out", str(tmp_path)] From 07aabe00b3a72334baf26f28dbd539aa489d752b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Wed, 20 May 2026 01:51:40 +0800 Subject: [PATCH 2/2] Implement Enrich + Deck GUI tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tabs used to be PlaceholderPage stubs; this lands the full implementation so the GUI shell stops advertising "comes in a follow-up" in the README and produces the same outputs the CLI does. autopapertoppt/gui/pages/enrich.py — EnrichPage: Walks every paper in the active PaperCollection through intelligence.fetch_and_extract + intelligence.summarise_paper. Settings surface the deck language + an optional Anthropic model override. Sequential (not parallel) because each Anthropic call already takes ~10 s and parallelism would just stress the user's rate limit. Papers without pdf_url are marked "skipped" but kept in the output collection; per-paper failures don't kill the whole run. ANTHROPIC_API_KEY check fires at click-time, not load-time, so the tab opens cleanly without a key configured. autopapertoppt/gui/pages/deck.py — DeckPage: Granular export controls that supplement the Search tab's one-button Export: output directory + filename stem + language picker, per-format checkboxes (pptx / xlsx / bib / md / json), max-slides spin (0 = no cap), include-abstract toggle. Shows a list of generated paths with an "Open folder" button that uses QDesktopServices for the OS file manager. Distinguishes "lightweight" vs "thesis-style (enriched)" in the ready status so the user knows what they're about to export. autopapertoppt/gui/pages/search.py: New Signal(object) collection_ready, emitted whenever a search completes. Lets the new tabs react without polling. autopapertoppt/gui/main_window.py: Replaces the two PlaceholderPage stubs with EnrichPage + DeckPage. Wires the inter-tab data flow: Search.collection_ready -> Enrich.set_collection Search.collection_ready -> Deck.set_collection (skip-enrich path) Enrich.collection_ready -> Deck.set_collection (enriched takes precedence) autopapertoppt/gui/i18n.py: Adds ~35 new keys (enrich.* + deck.*) across all 14 supported languages. The 14-language parity test in tests/test_i18n.py is the hard contract — every key has every language. tests/gui/test_enrich_page.py + test_deck_page.py: 12 new tests covering: initial-state disabled, set_collection enables the action button, no-API-key surfaces a Settings hint, happy-path enrich attaches summaries, paper-without-pdf-url skipped, signal emission, Deck export wiring (format checkboxes → ExportOptions), raw-vs-enriched status differentiation. All 510 tests pass; ruff + bandit clean. --- autopapertoppt/gui/i18n.py | 720 +++++++++++++++++++++++++++++ autopapertoppt/gui/main_window.py | 30 +- autopapertoppt/gui/pages/deck.py | 312 +++++++++++++ autopapertoppt/gui/pages/enrich.py | 304 ++++++++++++ autopapertoppt/gui/pages/search.py | 11 +- tests/gui/test_deck_page.py | 114 +++++ tests/gui/test_enrich_page.py | 150 ++++++ 7 files changed, 1633 insertions(+), 8 deletions(-) create mode 100644 autopapertoppt/gui/pages/deck.py create mode 100644 autopapertoppt/gui/pages/enrich.py create mode 100644 tests/gui/test_deck_page.py create mode 100644 tests/gui/test_enrich_page.py diff --git a/autopapertoppt/gui/i18n.py b/autopapertoppt/gui/i18n.py index 48949b0..dea5d1c 100644 --- a/autopapertoppt/gui/i18n.py +++ b/autopapertoppt/gui/i18n.py @@ -1008,6 +1008,726 @@ "hi": "Chrome प्रोफ़ाइल निर्देशिका चुनें", "id": "Pilih direktori profil Chrome", }, + # --- EnrichPage -------------------------------------------------- + "enrich.settings_group": { + "en": "Enrichment settings", + "zh-tw": "Enrich 設定", + "zh-cn": "Enrich 设置", + "ja": "Enrich 設定", + "es": "Ajustes de Enrich", + "fr": "Paramètres Enrich", + "de": "Enrich-Einstellungen", + "ko": "Enrich 설정", + "pt": "Configurações do Enrich", + "ru": "Настройки Enrich", + "it": "Impostazioni Enrich", + "vi": "Cài đặt Enrich", + "hi": "Enrich सेटिंग्स", + "id": "Pengaturan Enrich", + }, + "enrich.language_label": { + "en": "Summary language", + "zh-tw": "摘要語言", + "zh-cn": "摘要语言", + "ja": "要約の言語", + "es": "Idioma del resumen", + "fr": "Langue du résumé", + "de": "Sprache der Zusammenfassung", + "ko": "요약 언어", + "pt": "Idioma do resumo", + "ru": "Язык резюме", + "it": "Lingua del riassunto", + "vi": "Ngôn ngữ bản tóm tắt", + "hi": "सारांश की भाषा", + "id": "Bahasa ringkasan", + }, + "enrich.model_label": { + "en": "Anthropic model (override)", + "zh-tw": "Anthropic 模型(覆寫)", + "zh-cn": "Anthropic 模型(覆盖)", + "ja": "Anthropic モデル(上書き)", + "es": "Modelo Anthropic (anulación)", + "fr": "Modèle Anthropic (override)", + "de": "Anthropic-Modell (Override)", + "ko": "Anthropic 모델 (재정의)", + "pt": "Modelo Anthropic (substituição)", + "ru": "Модель Anthropic (переопределение)", + "it": "Modello Anthropic (override)", + "vi": "Mô hình Anthropic (ghi đè)", + "hi": "Anthropic मॉडल (ओवरराइड)", + "id": "Model Anthropic (override)", + }, + "enrich.enrich_button": { + "en": "Enrich", + "zh-tw": "Enrich", + "zh-cn": "Enrich", + "ja": "Enrich", + "es": "Enriquecer", + "fr": "Enrichir", + "de": "Anreichern", + "ko": "Enrich", + "pt": "Enriquecer", + "ru": "Обогатить", + "it": "Arricchisci", + "vi": "Enrich", + "hi": "Enrich", + "id": "Perkaya", + }, + "enrich.column_title": { + "en": "Title", + "zh-tw": "標題", + "zh-cn": "标题", + "ja": "タイトル", + "es": "Título", + "fr": "Titre", + "de": "Titel", + "ko": "제목", + "pt": "Título", + "ru": "Название", + "it": "Titolo", + "vi": "Tiêu đề", + "hi": "शीर्षक", + "id": "Judul", + }, + "enrich.column_pdf": { + "en": "PDF", + "zh-tw": "PDF", + "zh-cn": "PDF", + "ja": "PDF", + "es": "PDF", + "fr": "PDF", + "de": "PDF", + "ko": "PDF", + "pt": "PDF", + "ru": "PDF", + "it": "PDF", + "vi": "PDF", + "hi": "PDF", + "id": "PDF", + }, + "enrich.column_status": { + "en": "Status", + "zh-tw": "狀態", + "zh-cn": "状态", + "ja": "状態", + "es": "Estado", + "fr": "Statut", + "de": "Status", + "ko": "상태", + "pt": "Estado", + "ru": "Состояние", + "it": "Stato", + "vi": "Trạng thái", + "hi": "स्थिति", + "id": "Status", + }, + "enrich.row_pending": { + "en": "pending", + "zh-tw": "等待中", + "zh-cn": "等待中", + "ja": "待機中", + "es": "pendiente", + "fr": "en attente", + "de": "ausstehend", + "ko": "대기 중", + "pt": "pendente", + "ru": "ожидание", + "it": "in attesa", + "vi": "đang chờ", + "hi": "लंबित", + "id": "menunggu", + }, + "enrich.row_done": { + "en": "ok", + "zh-tw": "完成", + "zh-cn": "完成", + "ja": "完了", + "es": "ok", + "fr": "ok", + "de": "ok", + "ko": "완료", + "pt": "ok", + "ru": "ок", + "it": "ok", + "vi": "xong", + "hi": "हो गया", + "id": "selesai", + }, + "enrich.row_skipped": { + "en": "skipped (no PDF)", + "zh-tw": "已跳過(無 PDF)", + "zh-cn": "已跳过(无 PDF)", + "ja": "スキップ(PDF なし)", + "es": "omitido (sin PDF)", + "fr": "ignoré (pas de PDF)", + "de": "übersprungen (kein PDF)", + "ko": "건너뜀 (PDF 없음)", + "pt": "ignorado (sem PDF)", + "ru": "пропущено (нет PDF)", + "it": "saltato (nessun PDF)", + "vi": "đã bỏ qua (không có PDF)", + "hi": "छोड़ा गया (PDF नहीं)", + "id": "dilewati (tanpa PDF)", + }, + "enrich.row_failed": { + "en": "failed: {error}", + "zh-tw": "失敗: {error}", + "zh-cn": "失败: {error}", + "ja": "失敗: {error}", + "es": "falló: {error}", + "fr": "échec : {error}", + "de": "fehlgeschlagen: {error}", + "ko": "실패: {error}", + "pt": "falhou: {error}", + "ru": "ошибка: {error}", + "it": "fallito: {error}", + "vi": "thất bại: {error}", + "hi": "विफल: {error}", + "id": "gagal: {error}", + }, + "enrich.status_no_collection": { + "en": "Run a search first; results populate this tab.", + "zh-tw": "請先在 Search 分頁搜尋,結果會帶到這裡。", + "zh-cn": "请先在 Search 分页搜索,结果会带到这里。", + "ja": "まず Search タブで検索してください。結果がここに表示されます。", + "es": "Ejecute primero una búsqueda; los resultados aparecerán aquí.", + "fr": "Lancez d'abord une recherche ; les résultats apparaîtront ici.", + "de": "Erst eine Suche ausführen; die Ergebnisse erscheinen hier.", + "ko": "먼저 Search 탭에서 검색하세요. 결과가 여기에 표시됩니다.", + "pt": "Execute uma busca primeiro; os resultados aparecerão aqui.", + "ru": "Сначала выполните поиск; результаты появятся здесь.", + "it": "Esegui prima una ricerca; i risultati appariranno qui.", + "vi": "Hãy tìm kiếm trước; kết quả sẽ xuất hiện ở đây.", + "hi": "पहले एक search करें; परिणाम यहाँ दिखेंगे।", + "id": "Jalankan pencarian dulu; hasil muncul di sini.", + }, + "enrich.status_ready": { + "en": "Ready: {count} papers.", + "zh-tw": "就緒: {count} 篇論文。", + "zh-cn": "就绪: {count} 篇论文。", + "ja": "準備完了: {count} 件。", + "es": "Listo: {count} artículos.", + "fr": "Prêt : {count} articles.", + "de": "Bereit: {count} Arbeiten.", + "ko": "준비됨: {count}편.", + "pt": "Pronto: {count} artigos.", + "ru": "Готово: {count} статей.", + "it": "Pronto: {count} articoli.", + "vi": "Sẵn sàng: {count} bài.", + "hi": "तैयार: {count} पेपर।", + "id": "Siap: {count} makalah.", + }, + "enrich.status_running": { + "en": "Enriching… {done}/{total}", + "zh-tw": "Enrich 中… {done}/{total}", + "zh-cn": "Enrich 中… {done}/{total}", + "ja": "Enrich 中… {done}/{total}", + "es": "Enriqueciendo… {done}/{total}", + "fr": "Enrichissement… {done}/{total}", + "de": "Anreicherung läuft… {done}/{total}", + "ko": "Enrich 중… {done}/{total}", + "pt": "Enriquecendo… {done}/{total}", + "ru": "Обогащение… {done}/{total}", + "it": "Arricchimento… {done}/{total}", + "vi": "Đang enrich… {done}/{total}", + "hi": "Enrich हो रहा… {done}/{total}", + "id": "Memperkaya… {done}/{total}", + }, + "enrich.status_done": { + "en": "Done: {successes}/{total} enriched.", + "zh-tw": "完成: {successes}/{total} 已 enrich。", + "zh-cn": "完成: {successes}/{total} 已 enrich。", + "ja": "完了: {successes}/{total} を enrich。", + "es": "Hecho: {successes}/{total} enriquecidos.", + "fr": "Terminé : {successes}/{total} enrichis.", + "de": "Fertig: {successes}/{total} angereichert.", + "ko": "완료: {successes}/{total} enrich 됨.", + "pt": "Concluído: {successes}/{total} enriquecidos.", + "ru": "Готово: {successes}/{total} обогащено.", + "it": "Fatto: {successes}/{total} arricchiti.", + "vi": "Xong: {successes}/{total} đã enrich.", + "hi": "हो गया: {successes}/{total} enrich।", + "id": "Selesai: {successes}/{total} diperkaya.", + }, + "enrich.error_no_collection": { + "en": "No collection to enrich.", + "zh-tw": "沒有可 enrich 的結果集。", + "zh-cn": "没有可 enrich 的结果集。", + "ja": "Enrich する結果がありません。", + "es": "Sin colección que enriquecer.", + "fr": "Aucune collection à enrichir.", + "de": "Keine Sammlung zum Anreichern.", + "ko": "Enrich 할 결과가 없습니다.", + "pt": "Sem coleção para enriquecer.", + "ru": "Нет коллекции для обогащения.", + "it": "Nessuna collezione da arricchire.", + "vi": "Không có bộ kết quả để enrich.", + "hi": "Enrich करने के लिए कोई संग्रह नहीं।", + "id": "Tidak ada koleksi untuk diperkaya.", + }, + "enrich.error_no_key": { + "en": "ANTHROPIC_API_KEY is not set — open Settings to add it.", + "zh-tw": "未設 ANTHROPIC_API_KEY — 請到 Settings 分頁設定。", + "zh-cn": "未设 ANTHROPIC_API_KEY — 请到 Settings 分页设置。", + "ja": "ANTHROPIC_API_KEY 未設定 — Settings タブで追加してください。", + "es": "ANTHROPIC_API_KEY no está configurado: añádalo en Settings.", + "fr": "ANTHROPIC_API_KEY non défini — ajoutez-le dans Settings.", + "de": "ANTHROPIC_API_KEY ist nicht gesetzt — in Settings ergänzen.", + "ko": "ANTHROPIC_API_KEY 미설정 — Settings 탭에서 추가하세요.", + "pt": "ANTHROPIC_API_KEY não definido — adicione em Settings.", + "ru": "ANTHROPIC_API_KEY не задан — добавьте в Settings.", + "it": "ANTHROPIC_API_KEY non impostato — aggiungilo in Settings.", + "vi": "Chưa đặt ANTHROPIC_API_KEY — thêm vào tab Settings.", + "hi": "ANTHROPIC_API_KEY सेट नहीं — Settings में जोड़ें।", + "id": "ANTHROPIC_API_KEY belum diatur — tambah di Settings.", + }, + # --- DeckPage ---------------------------------------------------- + "deck.output_group": { + "en": "Output", + "zh-tw": "輸出", + "zh-cn": "输出", + "ja": "出力", + "es": "Salida", + "fr": "Sortie", + "de": "Ausgabe", + "ko": "출력", + "pt": "Saída", + "ru": "Вывод", + "it": "Output", + "vi": "Đầu ra", + "hi": "Output", + "id": "Keluaran", + }, + "deck.out_dir_label": { + "en": "Output directory", + "zh-tw": "輸出資料夾", + "zh-cn": "输出目录", + "ja": "出力ディレクトリ", + "es": "Carpeta de salida", + "fr": "Répertoire de sortie", + "de": "Ausgabeordner", + "ko": "출력 디렉터리", + "pt": "Diretório de saída", + "ru": "Каталог вывода", + "it": "Directory di output", + "vi": "Thư mục đầu ra", + "hi": "Output निर्देशिका", + "id": "Direktori keluaran", + }, + "deck.browse_button": { + "en": "Browse…", + "zh-tw": "瀏覽…", + "zh-cn": "浏览…", + "ja": "参照…", + "es": "Examinar…", + "fr": "Parcourir…", + "de": "Durchsuchen…", + "ko": "찾아보기…", + "pt": "Procurar…", + "ru": "Обзор…", + "it": "Sfoglia…", + "vi": "Duyệt…", + "hi": "ब्राउज़…", + "id": "Telusuri…", + }, + "deck.out_dir_dialog_title": { + "en": "Choose output directory", + "zh-tw": "選擇輸出資料夾", + "zh-cn": "选择输出目录", + "ja": "出力ディレクトリを選択", + "es": "Elija la carpeta de salida", + "fr": "Choisir le répertoire de sortie", + "de": "Ausgabeordner auswählen", + "ko": "출력 디렉터리 선택", + "pt": "Escolha o diretório de saída", + "ru": "Выберите каталог вывода", + "it": "Scegli la directory di output", + "vi": "Chọn thư mục đầu ra", + "hi": "Output निर्देशिका चुनें", + "id": "Pilih direktori keluaran", + }, + "deck.filename_stem_label": { + "en": "Filename stem (optional)", + "zh-tw": "檔名(選填)", + "zh-cn": "文件名(选填)", + "ja": "ファイル名(任意)", + "es": "Nombre de archivo (opcional)", + "fr": "Nom de fichier (optionnel)", + "de": "Dateiname (optional)", + "ko": "파일 이름 (선택)", + "pt": "Nome do arquivo (opcional)", + "ru": "Имя файла (опционально)", + "it": "Nome file (opzionale)", + "vi": "Tên tệp (tuỳ chọn)", + "hi": "फ़ाइल नाम (वैकल्पिक)", + "id": "Nama berkas (opsional)", + }, + "deck.filename_stem_placeholder": { + "en": "auto-generated from query + timestamp", + "zh-tw": "自動由查詢字串 + 時間戳產生", + "zh-cn": "自动由查询 + 时间戳生成", + "ja": "クエリ + タイムスタンプから自動生成", + "es": "auto desde la consulta + marca de tiempo", + "fr": "auto à partir de la requête + horodatage", + "de": "automatisch aus Anfrage + Zeitstempel", + "ko": "쿼리 + 타임스탬프에서 자동 생성", + "pt": "auto a partir da consulta + timestamp", + "ru": "авто из запроса + метки времени", + "it": "auto da query + timestamp", + "vi": "tự động từ truy vấn + thời gian", + "hi": "query + timestamp से auto", + "id": "otomatis dari query + timestamp", + }, + "deck.language_label": { + "en": "Deck language", + "zh-tw": "投影片語言", + "zh-cn": "幻灯片语言", + "ja": "スライドの言語", + "es": "Idioma de la presentación", + "fr": "Langue de la présentation", + "de": "Foliensprache", + "ko": "슬라이드 언어", + "pt": "Idioma do deck", + "ru": "Язык слайдов", + "it": "Lingua del deck", + "vi": "Ngôn ngữ slide", + "hi": "Deck की भाषा", + "id": "Bahasa deck", + }, + "deck.format_group": { + "en": "Formats", + "zh-tw": "輸出格式", + "zh-cn": "输出格式", + "ja": "出力フォーマット", + "es": "Formatos", + "fr": "Formats", + "de": "Formate", + "ko": "포맷", + "pt": "Formatos", + "ru": "Форматы", + "it": "Formati", + "vi": "Định dạng", + "hi": "Formats", + "id": "Format", + }, + "deck.format_pptx": { + "en": "PPTX", "zh-tw": "PPTX", "zh-cn": "PPTX", "ja": "PPTX", + "es": "PPTX", "fr": "PPTX", "de": "PPTX", "ko": "PPTX", + "pt": "PPTX", "ru": "PPTX", "it": "PPTX", "vi": "PPTX", + "hi": "PPTX", "id": "PPTX", + }, + "deck.format_xlsx": { + "en": "XLSX", "zh-tw": "XLSX", "zh-cn": "XLSX", "ja": "XLSX", + "es": "XLSX", "fr": "XLSX", "de": "XLSX", "ko": "XLSX", + "pt": "XLSX", "ru": "XLSX", "it": "XLSX", "vi": "XLSX", + "hi": "XLSX", "id": "XLSX", + }, + "deck.format_bib": { + "en": "BibTeX", "zh-tw": "BibTeX", "zh-cn": "BibTeX", "ja": "BibTeX", + "es": "BibTeX", "fr": "BibTeX", "de": "BibTeX", "ko": "BibTeX", + "pt": "BibTeX", "ru": "BibTeX", "it": "BibTeX", "vi": "BibTeX", + "hi": "BibTeX", "id": "BibTeX", + }, + "deck.format_md": { + "en": "Markdown", "zh-tw": "Markdown", "zh-cn": "Markdown", "ja": "Markdown", + "es": "Markdown", "fr": "Markdown", "de": "Markdown", "ko": "Markdown", + "pt": "Markdown", "ru": "Markdown", "it": "Markdown", "vi": "Markdown", + "hi": "Markdown", "id": "Markdown", + }, + "deck.format_json": { + "en": "JSON", "zh-tw": "JSON", "zh-cn": "JSON", "ja": "JSON", + "es": "JSON", "fr": "JSON", "de": "JSON", "ko": "JSON", + "pt": "JSON", "ru": "JSON", "it": "JSON", "vi": "JSON", + "hi": "JSON", "id": "JSON", + }, + "deck.options_group": { + "en": "Options", + "zh-tw": "選項", + "zh-cn": "选项", + "ja": "オプション", + "es": "Opciones", + "fr": "Options", + "de": "Optionen", + "ko": "옵션", + "pt": "Opções", + "ru": "Опции", + "it": "Opzioni", + "vi": "Tuỳ chọn", + "hi": "विकल्प", + "id": "Opsi", + }, + "deck.max_slides_label": { + "en": "Max slides per paper", + "zh-tw": "每篇最多投影片", + "zh-cn": "每篇最多幻灯片", + "ja": "論文あたりの最大スライド数", + "es": "Diapositivas máx. por artículo", + "fr": "Max diapos par article", + "de": "Max. Folien pro Arbeit", + "ko": "논문당 최대 슬라이드", + "pt": "Slides máx. por artigo", + "ru": "Макс. слайдов на статью", + "it": "Slide max per articolo", + "vi": "Slide tối đa mỗi bài", + "hi": "प्रति paper अधिकतम slides", + "id": "Slide maks per makalah", + }, + "deck.unlimited": { + "en": "unlimited", + "zh-tw": "無上限", + "zh-cn": "无上限", + "ja": "無制限", + "es": "ilimitado", + "fr": "illimité", + "de": "unbegrenzt", + "ko": "무제한", + "pt": "ilimitado", + "ru": "без предела", + "it": "illimitato", + "vi": "không giới hạn", + "hi": "असीमित", + "id": "tanpa batas", + }, + "deck.include_abstract_label": { + "en": "Include abstract slides", + "zh-tw": "包含摘要投影片", + "zh-cn": "包含摘要幻灯片", + "ja": "アブストラクトを含める", + "es": "Incluir diapositivas de resumen", + "fr": "Inclure les diapos de résumé", + "de": "Abstract-Folien einschließen", + "ko": "초록 슬라이드 포함", + "pt": "Incluir slides de resumo", + "ru": "Включать слайды с аннотацией", + "it": "Includi le slide del riassunto", + "vi": "Bao gồm slide abstract", + "hi": "Abstract slides शामिल करें", + "id": "Sertakan slide abstrak", + }, + "deck.export_button": { + "en": "Export", + "zh-tw": "輸出", + "zh-cn": "导出", + "ja": "エクスポート", + "es": "Exportar", + "fr": "Exporter", + "de": "Exportieren", + "ko": "내보내기", + "pt": "Exportar", + "ru": "Экспорт", + "it": "Esporta", + "vi": "Xuất", + "hi": "Export", + "id": "Ekspor", + }, + "deck.open_folder_button": { + "en": "Open folder", + "zh-tw": "開啟資料夾", + "zh-cn": "打开文件夹", + "ja": "フォルダを開く", + "es": "Abrir carpeta", + "fr": "Ouvrir le dossier", + "de": "Ordner öffnen", + "ko": "폴더 열기", + "pt": "Abrir pasta", + "ru": "Открыть папку", + "it": "Apri cartella", + "vi": "Mở thư mục", + "hi": "Folder खोलें", + "id": "Buka folder", + }, + "deck.column_format": { + "en": "Format", + "zh-tw": "格式", + "zh-cn": "格式", + "ja": "フォーマット", + "es": "Formato", + "fr": "Format", + "de": "Format", + "ko": "포맷", + "pt": "Formato", + "ru": "Формат", + "it": "Formato", + "vi": "Định dạng", + "hi": "Format", + "id": "Format", + }, + "deck.column_path": { + "en": "Path", + "zh-tw": "路徑", + "zh-cn": "路径", + "ja": "パス", + "es": "Ruta", + "fr": "Chemin", + "de": "Pfad", + "ko": "경로", + "pt": "Caminho", + "ru": "Путь", + "it": "Percorso", + "vi": "Đường dẫn", + "hi": "Path", + "id": "Path", + }, + "deck.status_no_collection": { + "en": "Run a search first; the result populates this tab.", + "zh-tw": "請先搜尋,結果會帶到這裡。", + "zh-cn": "请先搜索,结果会带到这里。", + "ja": "まず検索してください。結果がここに表示されます。", + "es": "Ejecute primero una búsqueda; el resultado aparecerá aquí.", + "fr": "Lancez d'abord une recherche ; le résultat apparaîtra ici.", + "de": "Erst eine Suche ausführen; das Ergebnis erscheint hier.", + "ko": "먼저 검색하세요. 결과가 여기에 표시됩니다.", + "pt": "Execute uma busca primeiro; o resultado aparecerá aqui.", + "ru": "Сначала выполните поиск; результат появится здесь.", + "it": "Esegui prima una ricerca; il risultato appare qui.", + "vi": "Hãy tìm trước; kết quả sẽ xuất hiện ở đây.", + "hi": "पहले search करें; परिणाम यहाँ दिखेगा।", + "id": "Jalankan pencarian dulu; hasil muncul di sini.", + }, + "deck.status_ready_raw": { + "en": "Ready: {count} papers (lightweight tier).", + "zh-tw": "就緒: {count} 篇(輕量版)。", + "zh-cn": "就绪: {count} 篇(轻量版)。", + "ja": "準備完了: {count} 件(ライトモード)。", + "es": "Listo: {count} artículos (modo ligero).", + "fr": "Prêt : {count} articles (mode léger).", + "de": "Bereit: {count} Arbeiten (Lightweight-Modus).", + "ko": "준비됨: {count}편 (lightweight).", + "pt": "Pronto: {count} artigos (modo leve).", + "ru": "Готово: {count} статей (лёгкий режим).", + "it": "Pronto: {count} articoli (modalità leggera).", + "vi": "Sẵn sàng: {count} bài (lightweight).", + "hi": "तैयार: {count} पेपर (lightweight)।", + "id": "Siap: {count} makalah (ringan).", + }, + "deck.status_ready_enriched": { + "en": "Ready: {count} papers (enriched / thesis-style).", + "zh-tw": "就緒: {count} 篇(已 enrich / 論文版)。", + "zh-cn": "就绪: {count} 篇(已 enrich / 论文版)。", + "ja": "準備完了: {count} 件(enrich 済み / thesis モード)。", + "es": "Listo: {count} artículos (enriquecidos / tesis).", + "fr": "Prêt : {count} articles (enrichis / mode thèse).", + "de": "Bereit: {count} Arbeiten (angereichert / Thesis-Modus).", + "ko": "준비됨: {count}편 (enrich, thesis).", + "pt": "Pronto: {count} artigos (enriquecidos / tese).", + "ru": "Готово: {count} статей (обогащено / thesis).", + "it": "Pronto: {count} articoli (arricchiti / tesi).", + "vi": "Sẵn sàng: {count} bài (đã enrich / thesis).", + "hi": "तैयार: {count} पेपर (enrich, thesis)।", + "id": "Siap: {count} makalah (diperkaya / thesis).", + }, + "deck.status_running": { + "en": "Exporting…", + "zh-tw": "輸出中…", + "zh-cn": "导出中…", + "ja": "エクスポート中…", + "es": "Exportando…", + "fr": "Export en cours…", + "de": "Export läuft…", + "ko": "내보내는 중…", + "pt": "Exportando…", + "ru": "Экспорт…", + "it": "Esportazione…", + "vi": "Đang xuất…", + "hi": "Export हो रहा…", + "id": "Mengekspor…", + }, + "deck.status_done": { + "en": "Wrote {count} file(s).", + "zh-tw": "完成,輸出 {count} 個檔案。", + "zh-cn": "完成,导出 {count} 个文件。", + "ja": "完了。{count} 件出力。", + "es": "Hecho: {count} archivo(s) escrito(s).", + "fr": "Terminé : {count} fichier(s) écrit(s).", + "de": "Fertig: {count} Datei(en) geschrieben.", + "ko": "완료: {count}개 파일 작성됨.", + "pt": "Concluído: {count} arquivo(s) gravado(s).", + "ru": "Готово: записано {count} файл(ов).", + "it": "Fatto: {count} file scritti.", + "vi": "Xong: ghi {count} tệp.", + "hi": "हो गया: {count} फ़ाइलें लिखीं।", + "id": "Selesai: menulis {count} berkas.", + }, + "deck.error_no_collection": { + "en": "No collection loaded.", + "zh-tw": "尚未載入結果集。", + "zh-cn": "尚未加载结果集。", + "ja": "結果セットがありません。", + "es": "Sin colección cargada.", + "fr": "Aucune collection chargée.", + "de": "Keine Sammlung geladen.", + "ko": "결과 컬렉션 없음.", + "pt": "Sem coleção carregada.", + "ru": "Коллекция не загружена.", + "it": "Nessuna collezione caricata.", + "vi": "Chưa nạp bộ kết quả.", + "hi": "कोई संग्रह नहीं।", + "id": "Belum ada koleksi.", + }, + "deck.error_no_formats": { + "en": "Select at least one format.", + "zh-tw": "至少要選一種格式。", + "zh-cn": "至少要选一种格式。", + "ja": "少なくとも 1 つのフォーマットを選択。", + "es": "Seleccione al menos un formato.", + "fr": "Sélectionnez au moins un format.", + "de": "Mindestens ein Format wählen.", + "ko": "포맷을 최소 하나 선택하세요.", + "pt": "Selecione pelo menos um formato.", + "ru": "Выберите хотя бы один формат.", + "it": "Seleziona almeno un formato.", + "vi": "Chọn ít nhất một định dạng.", + "hi": "कम से कम एक format चुनें।", + "id": "Pilih setidaknya satu format.", + }, + "deck.error_no_out_dir": { + "en": "Output directory cannot be empty.", + "zh-tw": "輸出資料夾不能空白。", + "zh-cn": "输出目录不能为空。", + "ja": "出力ディレクトリは必須です。", + "es": "La carpeta de salida no puede estar vacía.", + "fr": "Le répertoire de sortie est obligatoire.", + "de": "Ausgabeordner darf nicht leer sein.", + "ko": "출력 디렉터리는 필수입니다.", + "pt": "Diretório de saída obrigatório.", + "ru": "Каталог вывода обязателен.", + "it": "La directory di output è obbligatoria.", + "vi": "Thư mục đầu ra không được trống.", + "hi": "Output निर्देशिका आवश्यक।", + "id": "Direktori keluaran wajib.", + }, + "deck.error_no_output": { + "en": "Export produced no files.", + "zh-tw": "輸出沒有產生任何檔案。", + "zh-cn": "导出未生成任何文件。", + "ja": "ファイルは出力されませんでした。", + "es": "La exportación no generó archivos.", + "fr": "L'export n'a produit aucun fichier.", + "de": "Export hat keine Dateien erzeugt.", + "ko": "내보내기 결과 파일 없음.", + "pt": "A exportação não gerou arquivos.", + "ru": "Экспорт не создал файлов.", + "it": "L'esportazione non ha prodotto file.", + "vi": "Xuất không tạo ra tệp nào.", + "hi": "Export से कोई फ़ाइल नहीं बनी।", + "id": "Ekspor tidak menghasilkan berkas.", + }, + "deck.error_generic": { + "en": "Export failed: {error}", + "zh-tw": "輸出失敗: {error}", + "zh-cn": "导出失败: {error}", + "ja": "エクスポート失敗: {error}", + "es": "Falló la exportación: {error}", + "fr": "Échec de l'export : {error}", + "de": "Export fehlgeschlagen: {error}", + "ko": "내보내기 실패: {error}", + "pt": "Falha na exportação: {error}", + "ru": "Ошибка экспорта: {error}", + "it": "Esportazione fallita: {error}", + "vi": "Xuất thất bại: {error}", + "hi": "Export विफल: {error}", + "id": "Ekspor gagal: {error}", + }, } diff --git a/autopapertoppt/gui/main_window.py b/autopapertoppt/gui/main_window.py index ffb3cc8..b2d9327 100644 --- a/autopapertoppt/gui/main_window.py +++ b/autopapertoppt/gui/main_window.py @@ -16,7 +16,8 @@ ) from autopapertoppt.gui.i18n import t -from autopapertoppt.gui.pages.placeholder import PlaceholderPage +from autopapertoppt.gui.pages.deck import DeckPage +from autopapertoppt.gui.pages.enrich import EnrichPage from autopapertoppt.gui.pages.search import SearchPage from autopapertoppt.gui.pages.settings import SettingsPage @@ -47,15 +48,24 @@ def __init__(self, ui_language: str = "en", parent: QWidget | None = None) -> No t("nav.search", ui_language), ) - self._enrich_page = PlaceholderPage( - body_key="placeholder.enrich_body", ui_language=ui_language + self._enrich_page = EnrichPage(ui_language=ui_language) + tabs.addTab( + self._wrap_scrollable(self._enrich_page), + t("nav.enrich", ui_language), ) - tabs.addTab(self._enrich_page, t("nav.enrich", ui_language)) - self._deck_page = PlaceholderPage( - body_key="placeholder.deck_body", ui_language=ui_language + self._deck_page = DeckPage(ui_language=ui_language) + tabs.addTab( + self._wrap_scrollable(self._deck_page), + t("nav.deck", ui_language), ) - tabs.addTab(self._deck_page, t("nav.deck", ui_language)) + + # Wire the inter-tab data flow: Search → Enrich (raw collection) + # and Search → Deck (so the user can skip enrichment), plus + # Enrich → Deck (so the enriched collection takes precedence). + self._search_page.collection_ready.connect(self._enrich_page.set_collection) + self._search_page.collection_ready.connect(self._deck_page.set_collection) + self._enrich_page.collection_ready.connect(self._deck_page.set_collection) self._settings_page = SettingsPage(ui_language=ui_language) tabs.addTab( @@ -87,5 +97,11 @@ def search_page(self) -> SearchPage: def settings_page(self) -> SettingsPage: return self._settings_page + def enrich_page(self) -> EnrichPage: + return self._enrich_page + + def deck_page(self) -> DeckPage: + return self._deck_page + def tab_count(self) -> int: return self._tabs.count() diff --git a/autopapertoppt/gui/pages/deck.py b/autopapertoppt/gui/pages/deck.py new file mode 100644 index 0000000..fde71a9 --- /dev/null +++ b/autopapertoppt/gui/pages/deck.py @@ -0,0 +1,312 @@ +"""Deck tab. + +Granular export controls for a PaperCollection — supplements the Search +tab's one-button Export by exposing every ExportOptions knob: + +* Output directory + filename stem +* Per-format checkboxes (pptx / xlsx / bib / md / json) +* Deck language (independent of UI language) +* Max-slides cap (per-paper) +* Include-abstract toggle + +The page accepts either the raw collection from SearchPage or the +enriched one from EnrichPage. ``set_collection`` is the only entry +point — the page does not call the search pipeline itself. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt, QThreadPool, QUrl +from PySide6.QtGui import QDesktopServices, QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QTableView, + QVBoxLayout, + QWidget, +) + +from autopapertoppt.core.models import ExportOptions, PaperCollection +from autopapertoppt.exporters import export_collection +from autopapertoppt.exporters.i18n import SUPPORTED_LANGUAGES as DECK_LANGUAGES +from autopapertoppt.gui.i18n import LANGUAGE_DISPLAY_NAMES, t +from autopapertoppt.gui.workers import BlockingWorker + +# Match autopapertoppt.exporters.__init__.SUPPORTED_FORMATS ordering. +_FORMAT_CHOICES: tuple[tuple[str, str], ...] = ( + ("pptx", "deck.format_pptx"), + ("xlsx", "deck.format_xlsx"), + ("bib", "deck.format_bib"), + ("md", "deck.format_md"), + ("json", "deck.format_json"), +) +_DEFAULT_FORMATS: frozenset[str] = frozenset({"pptx", "xlsx", "bib"}) +_DEFAULT_MAX_SLIDES = 25 + + +class DeckPage(QWidget): + """Granular export controls for the active collection.""" + + def __init__(self, ui_language: str = "en", parent: QWidget | None = None) -> None: + super().__init__(parent) + self._ui_language = ui_language + self._collection: PaperCollection | None = None + self._collection_is_enriched: bool = False + self._last_written_dir: Path | None = None + self._files_model = QStandardItemModel(0, 2, self) + self._files_model.setHorizontalHeaderLabels([ + t("deck.column_format", ui_language), + t("deck.column_path", ui_language), + ]) + self._build_ui() + + def _build_ui(self) -> None: + outer = QVBoxLayout(self) + + # Output dir + filename row + output_box = QGroupBox(t("deck.output_group", self._ui_language), self) + output_form = QFormLayout(output_box) + + dir_row = QWidget(self) + dir_layout = QHBoxLayout(dir_row) + dir_layout.setContentsMargins(0, 0, 0, 0) + self._out_dir_input = QLineEdit(self) + self._out_dir_input.setText(str(Path.cwd() / "exports")) + browse = QPushButton(t("deck.browse_button", self._ui_language), self) + browse.clicked.connect(self._on_browse_out_dir) + dir_layout.addWidget(self._out_dir_input, stretch=1) + dir_layout.addWidget(browse) + output_form.addRow(t("deck.out_dir_label", self._ui_language), dir_row) + + self._filename_stem_input = QLineEdit(self) + self._filename_stem_input.setPlaceholderText( + t("deck.filename_stem_placeholder", self._ui_language), + ) + output_form.addRow( + t("deck.filename_stem_label", self._ui_language), + self._filename_stem_input, + ) + + self._language_combo = QComboBox(self) + for code in DECK_LANGUAGES: + display = LANGUAGE_DISPLAY_NAMES.get(code, code) + self._language_combo.addItem(display, code) + output_form.addRow( + t("deck.language_label", self._ui_language), self._language_combo, + ) + + outer.addWidget(output_box) + + # Format checkboxes + format_box = QGroupBox(t("deck.format_group", self._ui_language), self) + format_row = QHBoxLayout(format_box) + self._format_checks: dict[str, QCheckBox] = {} + for fmt, label_key in _FORMAT_CHOICES: + chk = QCheckBox(t(label_key, self._ui_language), self) + chk.setChecked(fmt in _DEFAULT_FORMATS) + format_row.addWidget(chk) + self._format_checks[fmt] = chk + format_row.addStretch(1) + outer.addWidget(format_box) + + # Options + options_box = QGroupBox(t("deck.options_group", self._ui_language), self) + options_form = QFormLayout(options_box) + self._max_slides_spin = QSpinBox(self) + self._max_slides_spin.setRange(0, 200) + self._max_slides_spin.setValue(_DEFAULT_MAX_SLIDES) + self._max_slides_spin.setSpecialValueText( + t("deck.unlimited", self._ui_language), + ) + options_form.addRow( + t("deck.max_slides_label", self._ui_language), self._max_slides_spin, + ) + self._include_abstract_check = QCheckBox( + t("deck.include_abstract_label", self._ui_language), self, + ) + self._include_abstract_check.setChecked(True) + options_form.addRow(self._include_abstract_check) + outer.addWidget(options_box) + + # Action row + button_row = QHBoxLayout() + self._export_button = QPushButton( + t("deck.export_button", self._ui_language), self, + ) + self._export_button.setEnabled(False) + self._export_button.clicked.connect(self._on_export_clicked) + self._open_folder_button = QPushButton( + t("deck.open_folder_button", self._ui_language), self, + ) + self._open_folder_button.setEnabled(False) + self._open_folder_button.clicked.connect(self._on_open_folder_clicked) + button_row.addWidget(self._export_button) + button_row.addWidget(self._open_folder_button) + button_row.addStretch(1) + outer.addLayout(button_row) + + # Output table + self._table = QTableView(self) + self._table.setModel(self._files_model) + self._table.setSelectionBehavior(QTableView.SelectRows) + self._table.setEditTriggers(QTableView.NoEditTriggers) + header = self._table.horizontalHeader() + header.setStretchLastSection(False) + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + outer.addWidget(self._table, stretch=1) + + self._status_label = QLabel( + t("deck.status_no_collection", self._ui_language), self, + ) + self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + outer.addWidget(self._status_label) + + # --- public API ----------------------------------------------------- + + def set_collection(self, collection: object) -> None: + if collection is None or not isinstance(collection, PaperCollection): + self._collection = None + self._collection_is_enriched = False + self._export_button.setEnabled(False) + self._status_label.setText( + t("deck.status_no_collection", self._ui_language), + ) + return + self._collection = collection + # Heuristic: when any paper has a populated summary, the + # collection has been through EnrichPage. Surface the distinction + # because the user wants to know whether the exported deck will + # be lightweight or thesis-style before clicking Export. + self._collection_is_enriched = any( + p.summary is not None for p in collection.papers + ) + self._export_button.setEnabled(bool(collection.papers)) + status_key = ( + "deck.status_ready_enriched" + if self._collection_is_enriched + else "deck.status_ready_raw" + ) + self._status_label.setText( + t(status_key, self._ui_language, count=len(collection.papers)), + ) + + def collection(self) -> PaperCollection | None: + return self._collection + + def status_text(self) -> str: + return self._status_label.text() + + def files_model(self) -> QStandardItemModel: + return self._files_model + + def format_checkbox(self, fmt: str) -> QCheckBox: + return self._format_checks[fmt] + + # --- internals ------------------------------------------------------ + + def _on_browse_out_dir(self) -> None: + start = self._out_dir_input.text() or str(Path.cwd()) + path = QFileDialog.getExistingDirectory( + self, t("deck.out_dir_dialog_title", self._ui_language), start, + ) + if path: + self._out_dir_input.setText(path) + + def _selected_formats(self) -> tuple[str, ...]: + return tuple( + fmt for fmt, _label in _FORMAT_CHOICES + if self._format_checks[fmt].isChecked() + ) + + def _on_export_clicked(self) -> None: + if self._collection is None or not self._collection.papers: + self._status_label.setText( + t("deck.error_no_collection", self._ui_language), + ) + return + formats = self._selected_formats() + if not formats: + self._status_label.setText( + t("deck.error_no_formats", self._ui_language), + ) + return + out_dir = self._out_dir_input.text().strip() + if not out_dir: + self._status_label.setText( + t("deck.error_no_out_dir", self._ui_language), + ) + return + stem = self._filename_stem_input.text().strip() or None + language = self._language_combo.currentData() or "en" + options = ExportOptions( + formats=formats, + out_dir=out_dir, + filename_stem=stem, + include_abstract=self._include_abstract_check.isChecked(), + language=language, + max_slides_per_paper=self._max_slides_spin.value(), + ) + collection = self._collection + self._export_button.setEnabled(False) + self._open_folder_button.setEnabled(False) + self._files_model.removeRows(0, self._files_model.rowCount()) + self._status_label.setText(t("deck.status_running", self._ui_language)) + + def call() -> dict[str, Path]: + return export_collection(collection, options) + + worker = BlockingWorker(call) + worker.signals.finished.connect(self._on_export_finished) + worker.signals.failed.connect(self._on_export_failed) + QThreadPool.globalInstance().start(worker) + + def _on_export_finished(self, written: object) -> None: + self._export_button.setEnabled(True) + if not isinstance(written, dict) or not written: + self._status_label.setText( + t("deck.error_no_output", self._ui_language), + ) + return + any_path: Path | None = None + for fmt, path in written.items(): + self._files_model.appendRow([ + QStandardItem(fmt), + QStandardItem(str(path)), + ]) + any_path = path + if any_path is not None: + self._last_written_dir = any_path.parent + self._open_folder_button.setEnabled(True) + self._status_label.setText( + t("deck.status_done", self._ui_language, count=len(written)), + ) + + def _on_export_failed(self, err: object) -> None: + self._export_button.setEnabled(True) + self._status_label.setText( + t("deck.error_generic", self._ui_language, error=str(err)), + ) + + def _on_open_folder_clicked(self) -> None: + if self._last_written_dir is None: + return + # QDesktopServices is the cross-platform "open in file manager" + # — Windows Explorer, macOS Finder, xdg-open on Linux. Subprocess + # would be more controllable but also leaks the host OS into the + # GUI layer; this is the right abstraction. + url = QUrl.fromLocalFile(str(self._last_written_dir)) + QDesktopServices.openUrl(url) + + diff --git a/autopapertoppt/gui/pages/enrich.py b/autopapertoppt/gui/pages/enrich.py new file mode 100644 index 0000000..676477d --- /dev/null +++ b/autopapertoppt/gui/pages/enrich.py @@ -0,0 +1,304 @@ +"""Enrich tab. + +Takes the PaperCollection produced by the Search tab and walks every +paper through ``intelligence.fetch_and_extract`` (download + PDF text +extraction) + ``intelligence.summarise_paper`` (Anthropic call). The +result is a NEW PaperCollection whose ``papers[*].summary`` carries +the rich-tier fields so the Deck tab can render thesis-style decks. + +Behaviour rules: + +* The Enrich button is disabled until SearchPage emits + ``collection_ready`` (or a collection is set programmatically by a + test). +* The Anthropic API key requirement is checked at button-click time, + not at page-construction time, so opening the tab without a key + doesn't crash. The error directs the user to the Settings tab. +* Enrichment runs one paper at a time on a worker thread; the UI + stays responsive and shows per-paper status (pending → ok / failed). + Sequential (not parallel) because each Anthropic call already takes + ~10 s and parallelism would just stress the user's rate limit. +* Failed papers don't kill the whole run — they keep the original + un-enriched ``Paper`` in the output collection so downstream Deck + rendering still works (falls back to lightweight tier per-paper). +""" + +from __future__ import annotations + +import os +from dataclasses import replace + +from PySide6.QtCore import Qt, QThreadPool, Signal +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableView, + QVBoxLayout, + QWidget, +) + +from autopapertoppt.core.models import Paper, PaperCollection +from autopapertoppt.exporters.i18n import SUPPORTED_LANGUAGES as DECK_LANGUAGES +from autopapertoppt.gui.i18n import LANGUAGE_DISPLAY_NAMES, t +from autopapertoppt.gui.workers import AsyncWorker + +# Anthropic env var name — mirrors intelligence.summarise._API_KEY_ENV +# but duplicated here so importing this page doesn't pull the optional +# anthropic SDK at module-load time. +_API_KEY_ENV = "ANTHROPIC_API_KEY" +_DEFAULT_MODEL = "claude-opus-4-7" + + +class EnrichPage(QWidget): + """Enrich the search collection with PDF-derived rich summaries.""" + + # Re-emitted to downstream tabs (Deck) whenever an enrichment run + # finishes — even partially — so they can render whatever survived. + collection_ready = Signal(object) + + def __init__(self, ui_language: str = "en", parent: QWidget | None = None) -> None: + super().__init__(parent) + self._ui_language = ui_language + self._collection: PaperCollection | None = None + self._enriched_papers: list[Paper] = [] + self._pending_index: int = 0 + self._table_model = QStandardItemModel(0, 3, self) + self._table_model.setHorizontalHeaderLabels([ + t("enrich.column_title", ui_language), + t("enrich.column_pdf", ui_language), + t("enrich.column_status", ui_language), + ]) + self._build_ui() + + def _build_ui(self) -> None: + outer = QVBoxLayout(self) + + settings_box = QGroupBox(t("enrich.settings_group", self._ui_language), self) + form = QFormLayout(settings_box) + + self._language_combo = QComboBox(self) + for code in DECK_LANGUAGES: + display = LANGUAGE_DISPLAY_NAMES.get(code, code) + self._language_combo.addItem(display, code) + form.addRow( + t("enrich.language_label", self._ui_language), self._language_combo, + ) + + self._model_input = QLineEdit(self) + self._model_input.setPlaceholderText(_DEFAULT_MODEL) + form.addRow(t("enrich.model_label", self._ui_language), self._model_input) + + outer.addWidget(settings_box) + + button_row = QHBoxLayout() + self._enrich_button = QPushButton( + t("enrich.enrich_button", self._ui_language), self, + ) + self._enrich_button.setEnabled(False) + self._enrich_button.clicked.connect(self._on_enrich_clicked) + button_row.addWidget(self._enrich_button) + button_row.addStretch(1) + outer.addLayout(button_row) + + self._table = QTableView(self) + self._table.setModel(self._table_model) + self._table.setSelectionBehavior(QTableView.SelectRows) + self._table.setEditTriggers(QTableView.NoEditTriggers) + header = self._table.horizontalHeader() + header.setStretchLastSection(False) + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + outer.addWidget(self._table, stretch=1) + + self._status_label = QLabel( + t("enrich.status_no_collection", self._ui_language), self, + ) + self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + outer.addWidget(self._status_label) + + # --- public API ----------------------------------------------------- + + def set_collection(self, collection: object) -> None: + """Receive the latest collection from SearchPage. + + Called from a Qt signal, so the parameter is typed ``object``. + ``None`` clears the page (used by tests to simulate the empty + initial state). + """ + if collection is None or not isinstance(collection, PaperCollection): + self._collection = None + self._enriched_papers = [] + self._table_model.removeRows(0, self._table_model.rowCount()) + self._enrich_button.setEnabled(False) + self._status_label.setText( + t("enrich.status_no_collection", self._ui_language), + ) + return + self._collection = collection + self._enriched_papers = [] + self._populate_table(collection.papers) + self._enrich_button.setEnabled(bool(collection.papers)) + self._status_label.setText( + t( + "enrich.status_ready", + self._ui_language, + count=len(collection.papers), + ), + ) + + def collection(self) -> PaperCollection | None: + """Most recent enriched collection — or the input collection + when no enrichment has run yet. Used by tests + the Deck tab.""" + if self._enriched_papers: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation # invariant + return replace(self._collection, papers=tuple(self._enriched_papers)) + return self._collection + + def status_text(self) -> str: + return self._status_label.text() + + def table_model(self) -> QStandardItemModel: + return self._table_model + + # --- internals ------------------------------------------------------ + + def _populate_table(self, papers: tuple[Paper, ...]) -> None: + self._table_model.removeRows(0, self._table_model.rowCount()) + for paper in papers: + title_item = QStandardItem(_truncate(paper.title, 80)) + pdf_item = QStandardItem("✓" if paper.pdf_url else "—") + status_item = QStandardItem( + t("enrich.row_pending", self._ui_language), + ) + self._table_model.appendRow([title_item, pdf_item, status_item]) + + def _set_row_status(self, row: int, message_key: str, **fmt: object) -> None: + item = self._table_model.item(row, 2) + if item is None: + return + item.setText(t(message_key, self._ui_language, **fmt)) + + def _on_enrich_clicked(self) -> None: + if self._collection is None or not self._collection.papers: + self._status_label.setText( + t("enrich.error_no_collection", self._ui_language), + ) + return + if not os.environ.get(_API_KEY_ENV): + self._status_label.setText( + t("enrich.error_no_key", self._ui_language), + ) + return + self._enriched_papers = [] + self._pending_index = 0 + self._enrich_button.setEnabled(False) + self._status_label.setText( + t( + "enrich.status_running", + self._ui_language, + done=0, + total=len(self._collection.papers), + ), + ) + self._enrich_next() + + def _enrich_next(self) -> None: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation + idx = self._pending_index + if idx >= len(self._collection.papers): + self._on_run_finished() + return + paper = self._collection.papers[idx] + if not paper.pdf_url: + # No PDF URL means we can't reach the body text — keep the + # original paper, mark the row, move on. + self._enriched_papers.append(paper) + self._set_row_status(idx, "enrich.row_skipped") + self._pending_index += 1 + self._update_running_status() + self._enrich_next() + return + language = self._language_combo.currentData() or "en" + model = self._model_input.text().strip() or _DEFAULT_MODEL + + async def coro() -> object: + # Lazy import — the intelligence extra is optional. + from autopapertoppt.intelligence.pdf import fetch_and_extract + from autopapertoppt.intelligence.summarise import summarise_paper + + extracted = await fetch_and_extract(paper.pdf_url) + return summarise_paper( + paper, extracted, language=language, model=model, + ) + + worker = AsyncWorker(coro) + worker.signals.finished.connect(self._on_paper_finished) + worker.signals.failed.connect(self._on_paper_failed) + QThreadPool.globalInstance().start(worker) + + def _on_paper_finished(self, summary: object) -> None: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation + idx = self._pending_index + paper = self._collection.papers[idx] + # Attach the new summary, keeping every other field. + enriched = replace(paper, summary=summary) # type: ignore[arg-type] + self._enriched_papers.append(enriched) + self._set_row_status(idx, "enrich.row_done") + self._pending_index += 1 + self._update_running_status() + self._enrich_next() + + def _on_paper_failed(self, err: object) -> None: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation + idx = self._pending_index + paper = self._collection.papers[idx] + # Failure → keep the original (un-enriched) paper so the + # downstream collection still has every entry. + self._enriched_papers.append(paper) + self._set_row_status( + idx, "enrich.row_failed", error=str(err)[:60], + ) + self._pending_index += 1 + self._update_running_status() + self._enrich_next() + + def _update_running_status(self) -> None: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation + total = len(self._collection.papers) + done = self._pending_index + self._status_label.setText( + t("enrich.status_running", self._ui_language, done=done, total=total), + ) + + def _on_run_finished(self) -> None: + assert self._collection is not None # noqa: S101 # nosec B101 # state-machine invariant, not runtime validation + total = len(self._collection.papers) + successes = sum( + 1 for p in self._enriched_papers if p.summary is not None + ) + self._enrich_button.setEnabled(True) + self._status_label.setText( + t( + "enrich.status_done", + self._ui_language, + successes=successes, + total=total, + ), + ) + result = self.collection() + if result is not None: + self.collection_ready.emit(result) + + +def _truncate(text: str, limit: int) -> str: + if not text: + return "" + return text if len(text) <= limit else text[: limit - 1] + "…" diff --git a/autopapertoppt/gui/pages/search.py b/autopapertoppt/gui/pages/search.py index 30b513c..d94d4f7 100644 --- a/autopapertoppt/gui/pages/search.py +++ b/autopapertoppt/gui/pages/search.py @@ -17,7 +17,7 @@ from pathlib import Path -from PySide6.QtCore import Qt, QThreadPool +from PySide6.QtCore import Qt, QThreadPool, Signal from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -53,6 +53,12 @@ class SearchPage(QWidget): """Search + export page.""" + # Emitted whenever a fresh search completes (or the cached collection + # is cleared). Receivers should use ``isinstance(obj, PaperCollection)`` + # because Signal(object) is the only typed-Python way to ship a + # frozen dataclass across threads. + collection_ready = Signal(object) + def __init__(self, ui_language: str = "en", parent: QWidget | None = None) -> None: super().__init__(parent) self._ui_language = ui_language @@ -196,6 +202,9 @@ def _on_search_finished(self, collection: object) -> None: self._papers_model.set_collection(collection) self._search_button.setEnabled(True) self._export_button.setEnabled(bool(collection.papers)) + # Tell downstream tabs (Enrich, Deck) about the fresh results + # so they enable their own actions without polling. + self.collection_ready.emit(collection) self._set_status( t( "search.status_done", diff --git a/tests/gui/test_deck_page.py b/tests/gui/test_deck_page.py new file mode 100644 index 0000000..25b7cde --- /dev/null +++ b/tests/gui/test_deck_page.py @@ -0,0 +1,114 @@ +"""Tests for the Deck page. + +``export_collection`` is monkey-patched so the test verifies the wiring +(format checkboxes → ExportOptions, out_dir / max_slides / etc.) without +actually rendering a .pptx via python-pptx. +""" + +from __future__ import annotations + +from pathlib import Path + +from autopapertoppt.core.models import Paper, PaperCollection, PaperSummary, Query +from autopapertoppt.gui.pages.deck import DeckPage + + +def _paper(idx: int, *, enriched: bool = False) -> Paper: + summary = ( + PaperSummary(language="en", core_observation="fake") + if enriched + else None + ) + return Paper( + source="arxiv", + source_id=f"2401.{idx:05d}", + title=f"Test paper {idx}", + authors=("Author A",), + year=2024, + venue=None, + abstract="…", + url="https://example.com/abs", + doi=None, + arxiv_id=f"2401.{idx:05d}", + pdf_url=None, + summary=summary, + ) + + +def _collection(*papers: Paper) -> PaperCollection: + return PaperCollection( + query=Query(keywords="deck-test", sources=("arxiv",)), + papers=papers, + ) + + +def test_initial_state_disabled(qtbot): + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + assert page._export_button.isEnabled() is False # noqa: SLF001 + assert "search" in page.status_text().lower() + + +def test_set_raw_collection_marks_lightweight(qtbot): + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1), _paper(2))) + assert page._export_button.isEnabled() is True # noqa: SLF001 + assert "lightweight" in page.status_text().lower() + + +def test_set_enriched_collection_marks_thesis(qtbot): + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1, enriched=True))) + text = page.status_text().lower() + # Either of the two phrases used in the en string is acceptable. + assert "enriched" in text or "thesis" in text + + +def test_export_calls_backend_with_selected_formats(qtbot, monkeypatch, tmp_path): + captured: dict[str, object] = {} + + def fake_export(collection, options): + captured["collection"] = collection + captured["options"] = options + return {"pptx": Path(options.out_dir) / "fake.pptx"} + + monkeypatch.setattr( + "autopapertoppt.gui.pages.deck.export_collection", fake_export, + ) + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + page._out_dir_input.setText(str(tmp_path)) # noqa: SLF001 + page.set_collection(_collection(_paper(1))) + # Toggle off bib (selected by default), pptx + xlsx remain on. + page.format_checkbox("bib").setChecked(False) + page.format_checkbox("md").setChecked(True) + page._on_export_clicked() # noqa: SLF001 + + qtbot.waitUntil(lambda: "Wrote" in page.status_text(), timeout=3000) + opts = captured["options"] + assert set(opts.formats) == {"pptx", "xlsx", "md"} + assert opts.out_dir == str(tmp_path) + assert opts.include_abstract is True + + +def test_export_blocks_when_no_formats(qtbot): + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1))) + # Uncheck everything. + for fmt in ("pptx", "xlsx", "bib", "md", "json"): + page.format_checkbox(fmt).setChecked(False) + page._on_export_clicked() # noqa: SLF001 + assert "format" in page.status_text().lower() + + +def test_open_folder_disabled_until_first_export(qtbot): + page = DeckPage(ui_language="en") + qtbot.addWidget(page) + assert page._open_folder_button.isEnabled() is False # noqa: SLF001 + page.set_collection(_collection(_paper(1))) + # Setting a collection enables Export but not Open folder. + assert page._export_button.isEnabled() is True # noqa: SLF001 + assert page._open_folder_button.isEnabled() is False # noqa: SLF001 diff --git a/tests/gui/test_enrich_page.py b/tests/gui/test_enrich_page.py new file mode 100644 index 0000000..90bd292 --- /dev/null +++ b/tests/gui/test_enrich_page.py @@ -0,0 +1,150 @@ +"""Tests for the Enrich page. + +The Anthropic call is mocked end-to-end so the test never hits the +network and never needs ``ANTHROPIC_API_KEY``-shaped credentials in +the environment except where we explicitly set one to drive a code +path. +""" + +from __future__ import annotations + +import os + +import pytest + +from autopapertoppt.core.models import ( + Paper, + PaperCollection, + PaperSummary, + Query, +) +from autopapertoppt.gui.pages.enrich import EnrichPage + + +def _paper(idx: int, *, with_pdf: bool = True) -> Paper: + return Paper( + source="arxiv", + source_id=f"2401.{idx:05d}", + title=f"Test paper {idx}", + authors=("Author A",), + year=2024, + venue=None, + abstract="…", + url="https://example.com/abs", + doi=None, + arxiv_id=f"2401.{idx:05d}", + pdf_url=f"https://example.com/pdf/{idx}" if with_pdf else None, + ) + + +def _collection(*papers: Paper) -> PaperCollection: + return PaperCollection( + query=Query(keywords="enrich-test", sources=("arxiv",)), + papers=papers, + ) + + +def test_initial_state_no_collection(qtbot): + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + # Enrich button disabled, status mentions running a search first. + assert page._enrich_button.isEnabled() is False # noqa: SLF001 + assert "search" in page.status_text().lower() + + +def test_set_collection_enables_button_and_populates_table(qtbot): + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1), _paper(2))) + assert page._enrich_button.isEnabled() is True # noqa: SLF001 + assert page.table_model().rowCount() == 2 + assert "2" in page.status_text() + + +def test_enrich_without_api_key_surfaces_settings_hint(qtbot, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1))) + page._on_enrich_clicked() # noqa: SLF001 + assert "ANTHROPIC_API_KEY" in page.status_text() + + +def test_enrich_happy_path_attaches_summaries(qtbot, monkeypatch): + """Mock fetch_and_extract + summarise_paper so the worker thread + drives the UI without any real network or LLM call.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + + class FakeExtracted: + text = "abstract … methods … results …" + chars = 50_000 + + async def fake_fetch(_url, source="intelligence"): + return FakeExtracted() + + def fake_summarise(paper, _pdf, *, language, model, api_key=None): + return PaperSummary( + language=language, + model=model, + raw_text_chars=50_000, + core_observation=f"fake summary for {paper.source_id}", + ) + + monkeypatch.setattr( + "autopapertoppt.intelligence.pdf.fetch_and_extract", fake_fetch, + ) + monkeypatch.setattr( + "autopapertoppt.intelligence.summarise.summarise_paper", + fake_summarise, + ) + + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1), _paper(2))) + page._on_enrich_clicked() # noqa: SLF001 + + qtbot.waitUntil( + lambda: "Done" in page.status_text() or "Done:" in page.status_text(), + timeout=5000, + ) + + enriched = page.collection() + assert enriched is not None + assert len(enriched.papers) == 2 + assert all(p.summary is not None for p in enriched.papers) + assert "1" in page.status_text() or "2" in page.status_text() + + +def test_paper_without_pdf_url_is_skipped(qtbot, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + page.set_collection(_collection(_paper(1, with_pdf=False))) + page._on_enrich_clicked() # noqa: SLF001 + + qtbot.waitUntil( + lambda: "Done" in page.status_text(), timeout=3000, + ) + enriched = page.collection() + assert enriched is not None + # Original paper preserved, summary stays None. + assert enriched.papers[0].summary is None + + +def test_collection_ready_emitted_after_enrich(qtbot, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + page = EnrichPage(ui_language="en") + qtbot.addWidget(page) + received: list[object] = [] + page.collection_ready.connect(lambda c: received.append(c)) + page.set_collection(_collection(_paper(1, with_pdf=False))) + page._on_enrich_clicked() # noqa: SLF001 + qtbot.waitUntil(lambda: len(received) >= 1, timeout=3000) + assert isinstance(received[0], PaperCollection) + + +@pytest.fixture(autouse=True) +def _clear_anthropic_key_after_test(): + """Defensive: never leak a fake key into other tests.""" + yield + os.environ.pop("ANTHROPIC_API_KEY", None)