# my-publisher 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:** Build a PyQt6 desktop GUI for Linux that centralizes the danix.xyz blog publishing workflow — articles, translations, git, hugo server, media, and taxonomy management. **Architecture:** Single Python process, PyQt6 for UI, QThread/QProcess for all async operations so the UI never blocks. Core layer (data models, config, file scanning) is UI-agnostic. Workers wrap subprocess operations and emit Qt signals. UI components consume workers via signals/slots. **Tech Stack:** Python 3.11+, PyQt6, tomlkit, mistune. Blog repo at `/home/danix/Programming/GIT/danix.xyz-hacker-theme`, translation script at `/home/danix/bin/transart.py`. --- ## File Map ``` my-publisher/ ├── main.py # QApplication entry point, loads config, shows main window ├── requirements.txt ├── core/ │ ├── __init__.py │ ├── config.py # load/save ~/.config/my-publisher/config.toml │ ├── models.py # Article, TaxonomyModel dataclasses + ARTICLE_TYPES │ ├── article_scanner.py # scan content/it/ + content/en/, build list[Article] │ ├── frontmatter.py # parse/write TOML frontmatter with tomlkit │ └── taxonomy.py # load/save tags-it.txt + tags-en.txt, TaxonomyModel ├── workers/ │ ├── __init__.py │ ├── git_worker.py # QThread: pull, push master, push production, rm, restore │ ├── hugo_worker.py # QProcess wrapper: hugo server start/stop + log streaming │ └── translation_worker.py # QProcess wrapper: transart.py + stdout streaming ├── ui/ │ ├── __init__.py │ ├── main_window.py # QMainWindow: sidebar + QStackedWidget content area │ ├── setup_dialog.py # first-run config dialog │ ├── articles_view.py # tab IT/EN list + "Senza Traduzione" mode │ ├── article_detail.py # header + frontmatter column + markdown preview column │ ├── frontmatter_editor.py # modal dialog: dynamic TOML form │ ├── translation_view.py # QProcess log streaming + post-translation preview │ ├── git_view.py # repo status + pull/push buttons + deleted articles │ ├── taxonomy_view.py # tab Tags/Categorie, IT|EN columns, edit + translate │ ├── media_view.py # drag-drop upload to static/uppies/YYYY/MM/ │ └── hugo_panel.py # hugo server start/stop + log + URL link └── tests/ ├── test_config.py ├── test_models.py ├── test_article_scanner.py ├── test_frontmatter.py └── test_taxonomy.py ``` --- ## Task 1: Project scaffold + dependencies **Files:** - Create: `requirements.txt` - Create: `main.py` - Create: `core/__init__.py`, `workers/__init__.py`, `ui/__init__.py` - [ ] **Step 1: Create requirements.txt** ``` PyQt6>=6.6.0 tomlkit>=0.12.0 mistune>=3.0.0 ``` - [ ] **Step 2: Install dependencies** ```bash cd /home/danix/Programming/GIT/my-publisher pip install -r requirements.txt ``` Expected: all packages install without errors. - [ ] **Step 3: Create package init files** ```bash mkdir -p core workers ui tests touch core/__init__.py workers/__init__.py ui/__init__.py tests/__init__.py ``` - [ ] **Step 4: Create minimal main.py** ```python import sys from PyQt6.QtWidgets import QApplication def main(): app = QApplication(sys.argv) app.setApplicationName("my-publisher") app.setOrganizationName("danix") sys.exit(app.exec()) if __name__ == "__main__": main() ``` - [ ] **Step 5: Verify app launches and exits** ```bash python main.py & sleep 1 kill %1 ``` Expected: no import errors. - [ ] **Step 6: Commit** ```bash git init git add requirements.txt main.py core/__init__.py workers/__init__.py ui/__init__.py tests/__init__.py git commit -m "chore: initial project scaffold" ``` --- ## Task 2: Config module **Files:** - Create: `core/config.py` - Create: `tests/test_config.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_config.py import pytest from pathlib import Path import tempfile, os from core.config import Config, DEFAULT_CONFIG_PATH def test_config_loads_defaults_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml") cfg = Config.load() assert cfg.blog_repo == "" assert cfg.transart_script == "/home/danix/bin/transart.py" assert cfg.typora_bin == "typora" def test_config_round_trips(tmp_path, monkeypatch): path = tmp_path / "config.toml" monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", path) cfg = Config(blog_repo="/some/path", transart_script="/bin/x", typora_bin="typora") cfg.save() loaded = Config.load() assert loaded.blog_repo == "/some/path" def test_config_is_complete_false_when_blog_repo_empty(tmp_path, monkeypatch): monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml") cfg = Config.load() assert cfg.is_complete() is False def test_config_is_complete_true_when_all_set(tmp_path, monkeypatch): monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml") cfg = Config(blog_repo="/some/repo", transart_script="/bin/x", typora_bin="typora") assert cfg.is_complete() is True ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_config.py -v ``` Expected: ImportError or AttributeError — `core.config` not defined. - [ ] **Step 3: Implement core/config.py** ```python from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path import tomlkit DEFAULT_CONFIG_PATH = Path.home() / ".config" / "my-publisher" / "config.toml" @dataclass class Config: blog_repo: str = "" transart_script: str = "/home/danix/bin/transart.py" typora_bin: str = "typora" def is_complete(self) -> bool: return bool(self.blog_repo) def save(self, path: Path = DEFAULT_CONFIG_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) doc = tomlkit.document() doc.add("blog_repo", self.blog_repo) doc.add("transart_script", self.transart_script) doc.add("typora_bin", self.typora_bin) path.write_text(tomlkit.dumps(doc)) @classmethod def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> "Config": if not path.exists(): return cls() data = tomlkit.loads(path.read_text()) return cls( blog_repo=str(data.get("blog_repo", "")), transart_script=str(data.get("transart_script", "/home/danix/bin/transart.py")), typora_bin=str(data.get("typora_bin", "typora")), ) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/test_config.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add core/config.py tests/test_config.py git commit -m "feat: config load/save with tomlkit" ``` --- ## Task 3: Data models **Files:** - Create: `core/models.py` - Create: `tests/test_models.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_models.py from pathlib import Path from core.models import Article, ARTICLE_TYPES def test_article_types_contains_five(): assert len(ARTICLE_TYPES) == 5 assert "Tech" in ARTICLE_TYPES assert "Life" in ARTICLE_TYPES def test_article_fields(): a = Article( slug="test-post", lang="it", path=Path("/blog/content/it/articles/test-post/index.md"), frontmatter={"title": "Test", "type": "Tech"}, has_translation=False, translation_path=None, ) assert a.slug == "test-post" assert a.lang == "it" assert a.has_translation is False assert a.translation_path is None def test_article_with_translation(): a = Article( slug="test-post", lang="it", path=Path("/blog/content/it/articles/test-post/index.md"), frontmatter={}, has_translation=True, translation_path=Path("/blog/content/en/articles/test-post/index.md"), ) assert a.has_translation is True assert a.translation_path is not None ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_models.py -v ``` Expected: ImportError. - [ ] **Step 3: Implement core/models.py** ```python from __future__ import annotations from dataclasses import dataclass from pathlib import Path ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"] @dataclass class Article: slug: str lang: str # "it" | "en" path: Path frontmatter: dict has_translation: bool translation_path: Path | None ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/test_models.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash git add core/models.py tests/test_models.py git commit -m "feat: Article dataclass and ARTICLE_TYPES" ``` --- ## Task 4: Frontmatter parser **Files:** - Create: `core/frontmatter.py` - Create: `tests/test_frontmatter.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_frontmatter.py import pytest from pathlib import Path import tempfile from core.frontmatter import parse_frontmatter, write_frontmatter SAMPLE_MD = """\ +++ title = "Test Article" type = "Tech" draft = false tags = ["linux", "python"] +++ # Body Some content here. """ def test_parse_returns_frontmatter_dict(tmp_path): f = tmp_path / "index.md" f.write_text(SAMPLE_MD) fm, body = parse_frontmatter(f) assert fm["title"] == "Test Article" assert fm["type"] == "Tech" assert fm["draft"] is False assert "linux" in fm["tags"] def test_parse_returns_body(tmp_path): f = tmp_path / "index.md" f.write_text(SAMPLE_MD) fm, body = parse_frontmatter(f) assert "# Body" in body assert "Some content here." in body def test_write_preserves_format(tmp_path): f = tmp_path / "index.md" f.write_text(SAMPLE_MD) fm, body = parse_frontmatter(f) fm["title"] = "Updated Title" write_frontmatter(f, fm, body) fm2, _ = parse_frontmatter(f) assert fm2["title"] == "Updated Title" assert fm2["type"] == "Tech" def test_parse_raises_on_missing_delimiters(tmp_path): f = tmp_path / "index.md" f.write_text("# No frontmatter\n\nJust body.") with pytest.raises(ValueError, match="frontmatter"): parse_frontmatter(f) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_frontmatter.py -v ``` Expected: ImportError. - [ ] **Step 3: Implement core/frontmatter.py** ```python from __future__ import annotations from pathlib import Path import tomlkit def parse_frontmatter(path: Path) -> tuple[dict, str]: """Parse TOML frontmatter delimited by +++. Returns (frontmatter_dict, body_str).""" text = path.read_text(encoding="utf-8") if not text.startswith("+++"): raise ValueError(f"No TOML frontmatter found in {path}") parts = text.split("+++", 2) if len(parts) < 3: raise ValueError(f"Malformed frontmatter in {path}") _, toml_block, body = parts fm = dict(tomlkit.loads(toml_block)) return fm, body.lstrip("\n") def write_frontmatter(path: Path, frontmatter: dict, body: str) -> None: """Write frontmatter + body back to file, preserving TOML format.""" text = path.read_text(encoding="utf-8") parts = text.split("+++", 2) if len(parts) < 3: raise ValueError(f"Malformed frontmatter in {path}") _, original_toml, _ = parts doc = tomlkit.loads(original_toml) for k, v in frontmatter.items(): doc[k] = v new_text = f"+++\n{tomlkit.dumps(doc).strip()}\n+++\n\n{body}" path.write_text(new_text, encoding="utf-8") ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/test_frontmatter.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add core/frontmatter.py tests/test_frontmatter.py git commit -m "feat: TOML frontmatter parse/write with tomlkit" ``` --- ## Task 5: Taxonomy module **Files:** - Create: `core/taxonomy.py` - Create: `tests/test_taxonomy.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_taxonomy.py import pytest from pathlib import Path from core.taxonomy import TaxonomyModel, load_taxonomy, save_taxonomy def _write_pair(tmp_path, it_lines, en_lines): it_file = tmp_path / "tags-it.txt" en_file = tmp_path / "tags-en.txt" it_file.write_text("\n".join(it_lines) + "\n") en_file.write_text("\n".join(en_lines) + "\n") return it_file, en_file def test_load_taxonomy_basic(tmp_path): it_f, en_f = _write_pair(tmp_path, ["linux", "vita"], ["linux", "life"]) model = load_taxonomy(it_f, en_f) assert model.it_to_en["linux"] == "linux" assert model.it_to_en["vita"] == "life" def test_load_taxonomy_orphan_it(tmp_path): it_f, en_f = _write_pair(tmp_path, ["linux", "vita", "extra"], ["linux", "life"]) model = load_taxonomy(it_f, en_f) assert "extra" in model.orphans_it def test_load_taxonomy_orphan_en(tmp_path): it_f, en_f = _write_pair(tmp_path, ["linux"], ["linux", "extra-en"]) model = load_taxonomy(it_f, en_f) assert "extra-en" in model.orphans_en def test_save_taxonomy_round_trips(tmp_path): it_f, en_f = _write_pair(tmp_path, ["linux"], ["linux"]) model = load_taxonomy(it_f, en_f) model.it_to_en["vita"] = "life" save_taxonomy(model, it_f, en_f) model2 = load_taxonomy(it_f, en_f) assert model2.it_to_en["vita"] == "life" def test_save_taxonomy_sorted(tmp_path): it_f, en_f = _write_pair(tmp_path, [], []) from core.taxonomy import TaxonomyModel model = TaxonomyModel(it_to_en={"zzz": "zzz", "aaa": "aaa"}, orphans_it=[], orphans_en=[]) save_taxonomy(model, it_f, en_f) lines = it_f.read_text().splitlines() assert lines == sorted(lines) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_taxonomy.py -v ``` Expected: ImportError. - [ ] **Step 3: Implement core/taxonomy.py** ```python from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path @dataclass class TaxonomyModel: it_to_en: dict[str, str] orphans_it: list[str] # IT terms with no EN pair orphans_en: list[str] # EN terms with no IT pair def load_taxonomy(it_path: Path, en_path: Path) -> TaxonomyModel: it_terms = [l.strip() for l in it_path.read_text().splitlines() if l.strip()] if it_path.exists() else [] en_terms = [l.strip() for l in en_path.read_text().splitlines() if l.strip()] if en_path.exists() else [] min_len = min(len(it_terms), len(en_terms)) it_to_en = {it_terms[i]: en_terms[i] for i in range(min_len)} orphans_it = it_terms[min_len:] orphans_en = en_terms[min_len:] return TaxonomyModel(it_to_en=it_to_en, orphans_it=orphans_it, orphans_en=orphans_en) def save_taxonomy(model: TaxonomyModel, it_path: Path, en_path: Path) -> None: pairs = sorted(model.it_to_en.items(), key=lambda x: x[0]) it_path.write_text("\n".join(k for k, _ in pairs) + "\n") en_path.write_text("\n".join(v for _, v in pairs) + "\n") ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/test_taxonomy.py -v ``` Expected: 5 passed. - [ ] **Step 5: Commit** ```bash git add core/taxonomy.py tests/test_taxonomy.py git commit -m "feat: taxonomy load/save with IT/EN pair alignment" ``` --- ## Task 6: Article scanner **Files:** - Create: `core/article_scanner.py` - Create: `tests/test_article_scanner.py` - [ ] **Step 1: Write failing tests** ```python # tests/test_article_scanner.py from pathlib import Path from core.article_scanner import scan_articles def _make_article(base: Path, lang: str, slug: str, fm_extra: str = "") -> Path: p = base / "content" / lang / "articles" / slug p.mkdir(parents=True) (p / "index.md").write_text(f"+++\ntitle = \"{slug}\"\ntype = \"Tech\"\n{fm_extra}+++\n\nBody.\n") return p / "index.md" def test_scan_finds_articles(tmp_path): _make_article(tmp_path, "it", "primo-post") _make_article(tmp_path, "en", "first-post") articles = scan_articles(tmp_path) slugs = [a.slug for a in articles] assert "primo-post" in slugs assert "first-post" in slugs def test_scan_detects_translation_pair(tmp_path): _make_article(tmp_path, "it", "shared-slug") _make_article(tmp_path, "en", "shared-slug") articles = scan_articles(tmp_path) it_art = next(a for a in articles if a.lang == "it" and a.slug == "shared-slug") en_art = next(a for a in articles if a.lang == "en" and a.slug == "shared-slug") assert it_art.has_translation is True assert it_art.translation_path == en_art.path assert en_art.has_translation is True def test_scan_detects_missing_translation(tmp_path): _make_article(tmp_path, "it", "solo-italiano") articles = scan_articles(tmp_path) art = next(a for a in articles if a.slug == "solo-italiano") assert art.has_translation is False assert art.translation_path is None def test_scan_parses_frontmatter(tmp_path): _make_article(tmp_path, "it", "con-fm", 'tags = ["linux"]\n') articles = scan_articles(tmp_path) art = next(a for a in articles if a.slug == "con-fm") assert art.frontmatter["title"] == "con-fm" assert "linux" in art.frontmatter["tags"] ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/test_article_scanner.py -v ``` Expected: ImportError. - [ ] **Step 3: Implement core/article_scanner.py** ```python from __future__ import annotations from pathlib import Path from core.models import Article from core.frontmatter import parse_frontmatter def scan_articles(blog_root: Path) -> list[Article]: articles: list[Article] = [] by_slug: dict[tuple[str, str], Path] = {} for lang in ("it", "en"): content_dir = blog_root / "content" / lang / "articles" if not content_dir.exists(): continue for index_md in sorted(content_dir.glob("*/index.md")): slug = index_md.parent.name by_slug[(lang, slug)] = index_md for (lang, slug), path in by_slug.items(): other_lang = "en" if lang == "it" else "it" translation_path = by_slug.get((other_lang, slug)) try: fm, _ = parse_frontmatter(path) except (ValueError, Exception): fm = {} articles.append(Article( slug=slug, lang=lang, path=path, frontmatter=fm, has_translation=translation_path is not None, translation_path=translation_path, )) return articles ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/test_article_scanner.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add core/article_scanner.py tests/test_article_scanner.py git commit -m "feat: article scanner with translation pair detection" ``` --- ## Task 7: Git worker **Files:** - Create: `workers/git_worker.py` - [ ] **Step 1: Implement workers/git_worker.py** ```python from __future__ import annotations from PyQt6.QtCore import QThread, pyqtSignal import subprocess from pathlib import Path class GitWorker(QThread): output = pyqtSignal(str) error = pyqtSignal(str) finished = pyqtSignal(bool) # True = success def __init__(self, repo_path: Path, command: list[str], parent=None): super().__init__(parent) self._repo_path = repo_path self._command = command def run(self): try: result = subprocess.run( self._command, cwd=self._repo_path, capture_output=True, text=True, ) if result.stdout: self.output.emit(result.stdout) if result.returncode != 0: self.error.emit(result.stderr or f"Exit code {result.returncode}") self.finished.emit(False) else: self.finished.emit(True) except Exception as e: self.error.emit(str(e)) self.finished.emit(False) @staticmethod def pull(repo_path: Path, parent=None) -> "GitWorker": return GitWorker(repo_path, ["git", "pull"], parent) @staticmethod def push_master(repo_path: Path, parent=None) -> "GitWorker": return GitWorker(repo_path, ["git", "push", "origin", "master"], parent) @staticmethod def push_production(repo_path: Path, parent=None) -> "GitWorker": return GitWorker(repo_path, ["git", "push", "origin", "production"], parent) @staticmethod def remove_article(repo_path: Path, rel_path: str, slug: str, lang: str, parent=None) -> "GitWorker": # Two-step: git rm then git commit class _RmWorker(QThread): output = pyqtSignal(str) error = pyqtSignal(str) finished = pyqtSignal(bool) def run(self_inner): for cmd in [ ["git", "rm", "-r", rel_path], ["git", "commit", "-m", f"remove: {slug} ({lang})"], ]: result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True) if result.stdout: self_inner.output.emit(result.stdout) if result.returncode != 0: self_inner.error.emit(result.stderr or f"Exit code {result.returncode}") self_inner.finished.emit(False) return self_inner.finished.emit(True) return _RmWorker(parent) @staticmethod def restore_article(repo_path: Path, commit_hash: str, rel_path: str, parent=None) -> "GitWorker": return GitWorker(repo_path, ["git", "checkout", commit_hash, "--", rel_path], parent) ``` - [ ] **Step 2: Verify import works** ```bash python -c "from workers.git_worker import GitWorker; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Commit** ```bash git add workers/git_worker.py git commit -m "feat: GitWorker QThread for pull/push/rm/restore" ``` --- ## Task 8: Hugo worker **Files:** - Create: `workers/hugo_worker.py` - [ ] **Step 1: Implement workers/hugo_worker.py** ```python from __future__ import annotations from PyQt6.QtCore import QObject, QProcess, pyqtSignal from pathlib import Path class HugoWorker(QObject): log_line = pyqtSignal(str) started = pyqtSignal() stopped = pyqtSignal() error = pyqtSignal(str) def __init__(self, repo_path: Path, parent=None): super().__init__(parent) self._repo_path = repo_path self._process = QProcess(self) self._process.readyReadStandardOutput.connect(self._on_stdout) self._process.readyReadStandardError.connect(self._on_stderr) self._process.finished.connect(self._on_finished) @property def is_running(self) -> bool: return self._process.state() == QProcess.ProcessState.Running def start(self): if self.is_running: return self._process.setWorkingDirectory(str(self._repo_path)) self._process.start("hugo", ["server", "-D"]) self.started.emit() def stop(self): if self.is_running: self._process.terminate() self._process.waitForFinished(3000) self.stopped.emit() def _on_stdout(self): data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace") for line in data.splitlines(): self.log_line.emit(line) def _on_stderr(self): data = self._process.readAllStandardError().data().decode("utf-8", errors="replace") for line in data.splitlines(): self.log_line.emit(line) def _on_finished(self, exit_code: int, _): if exit_code != 0: self.error.emit(f"hugo server exited with code {exit_code}") self.stopped.emit() ``` - [ ] **Step 2: Verify import** ```bash python -c "from workers.hugo_worker import HugoWorker; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Commit** ```bash git add workers/hugo_worker.py git commit -m "feat: HugoWorker QProcess with log streaming" ``` --- ## Task 9: Translation worker **Files:** - Create: `workers/translation_worker.py` - [ ] **Step 1: Implement workers/translation_worker.py** ```python from __future__ import annotations from PyQt6.QtCore import QObject, QProcess, pyqtSignal from pathlib import Path class TranslationWorker(QObject): log_line = pyqtSignal(str) finished = pyqtSignal(bool, str) # success, output_path error = pyqtSignal(str) def __init__(self, transart_script: Path, article_path: Path, direction: str, parent=None): super().__init__(parent) self._script = transart_script self._article_path = article_path self._direction = direction # e.g. "it-en" or "en-it" self._process = QProcess(self) self._process.readyReadStandardOutput.connect(self._on_stdout) self._process.readyReadStandardError.connect(self._on_stderr) self._process.finished.connect(self._on_finished) def start(self): self._process.start("python3", [ str(self._script), "--direction", self._direction, str(self._article_path), ]) def _on_stdout(self): data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace") for line in data.splitlines(): self.log_line.emit(line) def _on_stderr(self): data = self._process.readAllStandardError().data().decode("utf-8", errors="replace") for line in data.splitlines(): self.log_line.emit(f"[stderr] {line}") def _on_finished(self, exit_code: int, _): success = exit_code == 0 # Derive expected output path: content/it/... → content/en/... path_str = str(self._article_path) if "/it/" in path_str: out = path_str.replace("/it/", "/en/", 1) elif "/en/" in path_str: out = path_str.replace("/en/", "/it/", 1) else: out = "" if not success: self.error.emit(f"transart.py exited with code {exit_code}") self.finished.emit(success, out) ``` - [ ] **Step 2: Verify import** ```bash python -c "from workers.translation_worker import TranslationWorker; print('ok')" ``` Expected: `ok` - [ ] **Step 3: Commit** ```bash git add workers/translation_worker.py git commit -m "feat: TranslationWorker QProcess for transart.py streaming" ``` --- ## Task 10: Main window + setup dialog **Files:** - Create: `ui/setup_dialog.py` - Create: `ui/main_window.py` - Modify: `main.py` - [ ] **Step 1: Implement ui/setup_dialog.py** ```python from __future__ import annotations from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QFormLayout, ) from core.config import Config class SetupDialog(QDialog): def __init__(self, config: Config, parent=None): super().__init__(parent) self.setWindowTitle("my-publisher — Setup") self.setMinimumWidth(500) self._config = config self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) layout.addWidget(QLabel("Configura my-publisher prima di iniziare.")) form = QFormLayout() self._blog_repo = QLineEdit(self._config.blog_repo) browse_btn = QPushButton("Sfoglia...") browse_btn.clicked.connect(self._browse_repo) row = QHBoxLayout() row.addWidget(self._blog_repo) row.addWidget(browse_btn) form.addRow("Blog repo:", row) self._transart = QLineEdit(self._config.transart_script) form.addRow("transart.py:", self._transart) self._typora = QLineEdit(self._config.typora_bin) form.addRow("Typora bin:", self._typora) layout.addLayout(form) btns = QHBoxLayout() save_btn = QPushButton("Salva") save_btn.clicked.connect(self._save) btns.addStretch() btns.addWidget(save_btn) layout.addLayout(btns) def _browse_repo(self): path = QFileDialog.getExistingDirectory(self, "Seleziona blog repo") if path: self._blog_repo.setText(path) def _save(self): self._config.blog_repo = self._blog_repo.text().strip() self._config.transart_script = self._transart.text().strip() self._config.typora_bin = self._typora.text().strip() self._config.save() self.accept() ``` - [ ] **Step 2: Implement ui/main_window.py** ```python from __future__ import annotations from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QStackedWidget, QFrame, QSizePolicy, ) from PyQt6.QtCore import Qt, QFileSystemWatcher from PyQt6.QtGui import QFont from pathlib import Path from core.config import Config from core.article_scanner import scan_articles from core.models import Article class SidebarButton(QPushButton): def __init__(self, text: str, parent=None): super().__init__(text, parent) self.setFlat(True) self.setCheckable(True) self.setStyleSheet(""" QPushButton { text-align: left; padding: 5px 10px; border-radius: 4px; color: #888; } QPushButton:checked { background: #2a2a4e; color: #a855f7; } QPushButton:hover:!checked { background: #1a1a2e; color: #ccc; } """) class MainWindow(QMainWindow): def __init__(self, config: Config, parent=None): super().__init__(parent) self.config = config self._articles: list[Article] = [] self._watcher = QFileSystemWatcher(self) self._watcher.directoryChanged.connect(self._on_fs_change) self.setWindowTitle("my-publisher") self.setMinimumSize(1100, 700) self._build_ui() self._refresh_articles() self._setup_watcher() def _build_ui(self): central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # Sidebar sidebar = self._build_sidebar() root.addWidget(sidebar) # Divider line = QFrame() line.setFrameShape(QFrame.Shape.VLine) line.setStyleSheet("color: #2a2a4e;") root.addWidget(line) # Content stack self._stack = QStackedWidget() root.addWidget(self._stack, stretch=1) # Placeholder pages (replaced in later tasks) for name in ["articles", "no_translation", "new_article", "taxonomy", "media", "translations", "git", "hugo"]: w = QLabel(f"[{name}]") w.setAlignment(Qt.AlignmentFlag.AlignCenter) self._stack.addWidget(w) setattr(self, f"_page_{name}", self._stack.count() - 1) def _build_sidebar(self) -> QWidget: w = QWidget() w.setFixedWidth(210) w.setStyleSheet("background: #1a1a2e;") layout = QVBoxLayout(w) layout.setContentsMargins(8, 12, 8, 12) layout.setSpacing(2) header = QHBoxLayout() title = QLabel("📰 my-publisher") title.setStyleSheet("color: #fff; font-weight: bold; font-size: 13px; padding: 4px 8px;") header.addWidget(title) self._refresh_btn = QPushButton("🔄") self._refresh_btn.setFlat(True) self._refresh_btn.setFixedSize(28, 28) self._refresh_btn.setStyleSheet("color: #555; border: none;") self._refresh_btn.clicked.connect(self._refresh_articles) header.addWidget(self._refresh_btn) layout.addLayout(header) def section(text): lbl = QLabel(text) lbl.setStyleSheet("color: #a855f7; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; padding: 8px 8px 2px;") layout.addWidget(lbl) self._btn_group: list[SidebarButton] = [] def btn(icon_text: str, page_attr: str) -> SidebarButton: b = SidebarButton(icon_text) b.clicked.connect(lambda: self._show_page(page_attr, b)) layout.addWidget(b) self._btn_group.append(b) return b section("CONTENUTO") btn("📋 Articoli", "_page_articles") self._btn_no_trans = btn("⚠️ Senza Traduzione", "_page_no_translation") self._badge_no_trans = QLabel("0") self._badge_no_trans.setStyleSheet("background:#3a1a1a;color:#ff6b6b;border-radius:8px;padding:1px 6px;font-size:10px;") btn("➕ Nuovo articolo", "_page_new_article") btn("🏷 Tassonomia", "_page_taxonomy") btn("🖼 Media", "_page_media") section("WORKFLOW") btn("🌍 Traduzioni", "_page_translations") btn("🔧 Git ops", "_page_git") btn("🚀 Hugo server", "_page_hugo") section("DEPLOY") btn("🧪 Test (master)", "_page_git") btn("🚢 Production", "_page_git") layout.addStretch() return w def _show_page(self, page_attr: str, active_btn: SidebarButton): for b in self._btn_group: b.setChecked(False) active_btn.setChecked(True) self._stack.setCurrentIndex(getattr(self, page_attr)) def _refresh_articles(self): if not self.config.blog_repo: return self._articles = scan_articles(Path(self.config.blog_repo)) missing = [a for a in self._articles if not a.has_translation] self._badge_no_trans.setText(str(len(missing))) def _setup_watcher(self): if not self.config.blog_repo: return root = Path(self.config.blog_repo) for lang in ("it", "en"): d = root / "content" / lang / "articles" if d.exists(): self._watcher.addPath(str(d)) def _on_fs_change(self, _path: str): self._refresh_articles() ``` - [ ] **Step 3: Update main.py to show window** ```python import sys from PyQt6.QtWidgets import QApplication from core.config import Config from ui.main_window import MainWindow from ui.setup_dialog import SetupDialog def main(): app = QApplication(sys.argv) app.setApplicationName("my-publisher") app.setOrganizationName("danix") config = Config.load() if not config.is_complete(): dlg = SetupDialog(config) if dlg.exec() == 0: sys.exit(0) config = Config.load() window = MainWindow(config) window.show() sys.exit(app.exec()) if __name__ == "__main__": main() ``` - [ ] **Step 4: Launch app and verify sidebar renders** ```bash python main.py ``` Expected: window opens with sidebar, all section buttons visible, no errors in terminal. - [ ] **Step 5: Commit** ```bash git add ui/setup_dialog.py ui/main_window.py main.py git commit -m "feat: main window with sidebar navigation and file watcher" ``` --- ## Task 11: Articles view **Files:** - Create: `ui/articles_view.py` - Modify: `ui/main_window.py` (replace placeholder page) - [ ] **Step 1: Implement ui/articles_view.py** ```python from __future__ import annotations from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QListWidget, QListWidgetItem, QLabel, QPushButton, ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor from core.models import Article class ArticleItem(QListWidgetItem): def __init__(self, article: Article): super().__init__() self.article = article lang_other = "EN" if article.lang == "it" else "IT" if article.has_translation: badge = f"🇬🇧 ✓" if article.lang == "it" else "🇮🇹 ✓" status = f"{article.slug} [{badge}]" else: badge = f"🇬🇧 ✗" if article.lang == "it" else "🇮🇹 ✗" status = f"{article.slug} [{badge}]" self.setText(status) if not article.has_translation: self.setForeground(QColor("#ff6b6b")) class ArticlesView(QWidget): article_selected = pyqtSignal(object) # Article translate_requested = pyqtSignal(object) # Article def __init__(self, parent=None): super().__init__(parent) self._articles: list[Article] = [] self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self._tabs = QTabWidget() self._tabs.setStyleSheet("QTabBar::tab { padding: 6px 16px; }") self._list_it = QListWidget() self._list_en = QListWidget() self._tabs.addTab(self._list_it, "🇮🇹 Italiano") self._tabs.addTab(self._list_en, "🇬🇧 English") layout.addWidget(self._tabs) self._list_it.itemClicked.connect(lambda item: self.article_selected.emit(item.article)) self._list_en.itemClicked.connect(lambda item: self.article_selected.emit(item.article)) def set_articles(self, articles: list[Article]): self._articles = articles self._list_it.clear() self._list_en.clear() for a in articles: item = ArticleItem(a) if a.lang == "it": self._list_it.addItem(item) else: self._list_en.addItem(item) class MissingTranslationView(QWidget): article_selected = pyqtSignal(object) # Article translate_requested = pyqtSignal(object) # Article def __init__(self, parent=None): super().__init__(parent) self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) header = QLabel("⚠️ Articoli senza traduzione") header.setStyleSheet("color: #ff6b6b; font-weight: bold; font-size: 13px; padding: 10px;") layout.addWidget(header) self._list = QListWidget() layout.addWidget(self._list) self._list.itemClicked.connect(lambda item: self.article_selected.emit(item.article)) def set_articles(self, articles: list[Article]): self._list.clear() missing = [a for a in articles if not a.has_translation] for a in missing: lang_other = "🇬🇧" if a.lang == "it" else "🇮🇹" item = ArticleItem(a) item.setText(f"{a.slug} [manca {lang_other}] ({a.lang.upper()})") self._list.addItem(item) ``` - [ ] **Step 2: Wire into main_window.py** In `MainWindow._build_ui`, after creating `self._stack`, replace placeholder article pages: ```python from ui.articles_view import ArticlesView, MissingTranslationView # replace placeholder creation for "articles" and "no_translation": self._articles_view = ArticlesView() self._articles_view.article_selected.connect(self._on_article_selected) self._stack.addWidget(self._articles_view) self._page_articles = self._stack.count() - 1 self._missing_view = MissingTranslationView() self._missing_view.article_selected.connect(self._on_article_selected) self._stack.addWidget(self._missing_view) self._page_no_translation = self._stack.count() - 1 ``` Add `_refresh_articles` update and `_on_article_selected` stub to `MainWindow`: ```python def _refresh_articles(self): if not self.config.blog_repo: return self._articles = scan_articles(Path(self.config.blog_repo)) self._articles_view.set_articles(self._articles) self._missing_view.set_articles(self._articles) missing = [a for a in self._articles if not a.has_translation] self._badge_no_trans.setText(str(len(missing))) def _on_article_selected(self, article: Article): # placeholder — wired in Task 12 pass ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "Articoli" → tab IT/EN with real articles from blog repo. Untranslated articles shown in red. - [ ] **Step 4: Commit** ```bash git add ui/articles_view.py ui/main_window.py git commit -m "feat: articles view with IT/EN tabs and missing translation highlight" ``` --- ## Task 12: Article detail panel **Files:** - Create: `ui/article_detail.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/article_detail.py** ```python from __future__ import annotations import subprocess import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextBrowser, QScrollArea, QSizePolicy, QFrame, ) from PyQt6.QtCore import pyqtSignal, Qt from core.models import Article from core.frontmatter import parse_frontmatter def _strip_shortcodes(text: str) -> str: return re.sub(r'\{\{[^}]+\}\}', '[shortcode]', text) class ArticleDetailView(QWidget): open_typora = pyqtSignal(object) # Article open_frontmatter = pyqtSignal(object) # Article translate = pyqtSignal(object) # Article push_master = pyqtSignal(object) # Article publish = pyqtSignal(object) # Article delete_article = pyqtSignal(object) # Article def __init__(self, parent=None): super().__init__(parent) self._article: Article | None = None self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Header header = QWidget() header.setStyleSheet("background: #0f0f1a; border-bottom: 1px solid #2a2a4e;") h_layout = QHBoxLayout(header) h_layout.setContentsMargins(12, 8, 12, 8) self._title_label = QLabel("") self._title_label.setStyleSheet("color: #fff; font-weight: bold; font-size: 13px;") self._path_label = QLabel("") self._path_label.setStyleSheet("color: #555; font-size: 10px;") info = QVBoxLayout() info.addWidget(self._title_label) info.addWidget(self._path_label) h_layout.addLayout(info, stretch=1) for (icon, signal_name, style) in [ ("✏️ Typora", "open_typora", "color:#a855f7;border:1px solid #a855f7;"), ("🔧 Frontmatter", "open_frontmatter", "color:#888;border:1px solid #444;"), ("🌍 Traduci", "translate", "color:#f59e0b;border:1px solid #f59e0b;"), ("🧪 Push master", "push_master", "color:#60a5fa;border:1px solid #60a5fa;"), ("🚢 Pubblica", "publish", "color:#00ff88;border:1px solid #00ff88;"), ("🗑 Elimina", "delete_article", "color:#ff6b6b;border:1px solid #ff6b6b;"), ]: btn = QPushButton(icon) btn.setStyleSheet(f"background:#1a1a2e;{style}padding:4px 10px;border-radius:4px;font-size:10px;") btn.clicked.connect(lambda _, s=signal_name: getattr(self, s).emit(self._article)) h_layout.addWidget(btn) layout.addWidget(header) # Body: split columns body = QHBoxLayout() body.setContentsMargins(0, 0, 0, 0) body.setSpacing(0) # Frontmatter column self._fm_widget = QWidget() self._fm_widget.setFixedWidth(230) self._fm_widget.setStyleSheet("background:#0a0a12;") self._fm_layout = QVBoxLayout(self._fm_widget) self._fm_layout.setContentsMargins(10, 10, 10, 10) fm_title = QLabel("Frontmatter") fm_title.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;") self._fm_layout.addWidget(fm_title) self._fm_content = QVBoxLayout() self._fm_layout.addLayout(self._fm_content) self._fm_layout.addStretch() body.addWidget(self._fm_widget) divider = QFrame() divider.setFrameShape(QFrame.Shape.VLine) divider.setStyleSheet("color:#1a1a2e;") body.addWidget(divider) # Preview column preview_container = QWidget() preview_layout = QVBoxLayout(preview_container) preview_layout.setContentsMargins(12, 10, 12, 10) preview_title = QLabel("Preview markdown") preview_title.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;") preview_layout.addWidget(preview_title) self._preview = QTextBrowser() self._preview.setStyleSheet("background:#0d0d1a;color:#ccc;border:none;") self._preview.setOpenExternalLinks(True) preview_layout.addWidget(self._preview) body.addWidget(preview_container, stretch=1) layout.addLayout(body, stretch=1) def set_article(self, article: Article): self._article = article self._title_label.setText(article.slug) rel = str(article.path).replace(str(article.path.parent.parent.parent.parent), "") self._path_label.setText(rel) # Populate frontmatter while self._fm_content.count(): item = self._fm_content.takeAt(0) if item.widget(): item.widget().deleteLater() for key, val in article.frontmatter.items(): row = QVBoxLayout() key_lbl = QLabel(key) key_lbl.setStyleSheet("color:#555;font-size:9px;") val_lbl = QLabel(str(val) if not isinstance(val, list) else ", ".join(str(v) for v in val)) val_lbl.setStyleSheet("color:#e2e8f0;font-size:11px;") val_lbl.setWordWrap(True) row.addWidget(key_lbl) row.addWidget(val_lbl) container = QWidget() container.setLayout(row) self._fm_content.addWidget(container) # Load and render markdown preview try: _, body = parse_frontmatter(article.path) clean = _strip_shortcodes(body) self._preview.setMarkdown(clean) except Exception as e: self._preview.setPlainText(f"[Errore caricamento: {e}]") ``` - [ ] **Step 2: Wire into main_window.py** Add to `MainWindow._build_ui` after the missing_view block: ```python from ui.article_detail import ArticleDetailView self._detail_view = ArticleDetailView() self._detail_view.open_typora.connect(self._do_open_typora) self._detail_view.open_frontmatter.connect(self._do_open_frontmatter) self._detail_view.translate.connect(self._do_translate) self._detail_view.push_master.connect(lambda a: self._do_git_push("master")) self._detail_view.publish.connect(lambda a: self._do_git_push("production")) self._detail_view.delete_article.connect(self._do_delete_article) self._stack.addWidget(self._detail_view) self._page_detail = self._stack.count() - 1 ``` Add action stubs to `MainWindow`: ```python def _on_article_selected(self, article: Article): self._detail_view.set_article(article) self._stack.setCurrentIndex(self._page_detail) def _do_open_typora(self, article: Article): subprocess.Popen([self.config.typora_bin, str(article.path)]) def _do_open_frontmatter(self, article: Article): pass # implemented in Task 13 def _do_translate(self, article: Article): pass # implemented in Task 15 def _do_git_push(self, branch: str): pass # implemented in Task 16 def _do_delete_article(self, article: Article): pass # implemented in Task 16 ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click article → detail panel shows frontmatter fields + markdown preview. "✏️ Typora" opens file in Typora. - [ ] **Step 4: Commit** ```bash git add ui/article_detail.py ui/main_window.py git commit -m "feat: article detail panel with frontmatter display and markdown preview" ``` --- ## Task 13: Frontmatter editor dialog **Files:** - Create: `ui/frontmatter_editor.py` - Modify: `ui/main_window.py` (wire `_do_open_frontmatter`) - [ ] **Step 1: Implement ui/frontmatter_editor.py** ```python from __future__ import annotations from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QComboBox, QPushButton, QCompleter, QWidget, QScrollArea, ) from PyQt6.QtCore import Qt from pathlib import Path from core.models import Article, ARTICLE_TYPES from core.frontmatter import parse_frontmatter, write_frontmatter from core.taxonomy import load_taxonomy class FrontmatterEditor(QDialog): def __init__(self, article: Article, blog_root: Path, parent=None): super().__init__(parent) self.setWindowTitle(f"Frontmatter — {article.slug}") self.setMinimumWidth(520) self._article = article self._blog_root = blog_root self._fields: dict[str, QWidget] = {} self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(True) inner = QWidget() form = QFormLayout(inner) scroll.setWidget(inner) layout.addWidget(scroll) lang = self._article.lang tags_it = self._blog_root / "docs" / "tags-it.txt" tags_en = self._blog_root / "docs" / "tags-en.txt" taxonomy = load_taxonomy(tags_it, tags_en) known_tags = list(taxonomy.it_to_en.keys()) if lang == "it" else list(taxonomy.it_to_en.values()) for key, val in self._article.frontmatter.items(): if key == "type": widget = QComboBox() widget.addItems(ARTICLE_TYPES) if str(val) in ARTICLE_TYPES: widget.setCurrentText(str(val)) self._fields[key] = widget elif key == "tags": widget = QLineEdit(", ".join(str(v) for v in val) if isinstance(val, list) else str(val)) completer = QCompleter(known_tags) completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) widget.setCompleter(completer) self._fields[key] = widget else: widget = QLineEdit(str(val)) self._fields[key] = widget form.addRow(QLabel(key), widget) btns = QHBoxLayout() save_btn = QPushButton("Salva") save_btn.clicked.connect(self._save) cancel_btn = QPushButton("Annulla") cancel_btn.clicked.connect(self.reject) btns.addStretch() btns.addWidget(cancel_btn) btns.addWidget(save_btn) layout.addLayout(btns) def _save(self): updated = dict(self._article.frontmatter) for key, widget in self._fields.items(): if isinstance(widget, QComboBox): updated[key] = widget.currentText() elif isinstance(widget, QLineEdit): val = widget.text().strip() if key == "tags": updated[key] = [t.strip() for t in val.split(",") if t.strip()] else: updated[key] = val try: _, body = parse_frontmatter(self._article.path) write_frontmatter(self._article.path, updated, body) self._article.frontmatter.update(updated) self.accept() except Exception as e: from PyQt6.QtWidgets import QMessageBox QMessageBox.critical(self, "Errore", str(e)) ``` - [ ] **Step 2: Wire into main_window.py** Replace `_do_open_frontmatter` stub: ```python def _do_open_frontmatter(self, article: Article): from ui.frontmatter_editor import FrontmatterEditor dlg = FrontmatterEditor(article, Path(self.config.blog_repo), self) if dlg.exec(): self._detail_view.set_article(article) ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🔧 Frontmatter" → dialog with all fields. `type` field is dropdown with 5 options. Save writes to disk. Reopen article → updated values visible. - [ ] **Step 4: Commit** ```bash git add ui/frontmatter_editor.py ui/main_window.py git commit -m "feat: frontmatter editor dialog with type dropdown and tag autocomplete" ``` --- ## Task 14: New article stub creation **Files:** - Create: `ui/new_article_dialog.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/new_article_dialog.py** ```python from __future__ import annotations from datetime import date from pathlib import Path from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QFormLayout, QHBoxLayout, QLineEdit, QComboBox, QLabel, QPushButton, QMessageBox, ) from core.models import ARTICLE_TYPES STUB_TEMPLATE = """\ +++ title = "{title}" date = {date} type = "{type}" draft = true excerpt = "" tags = [] categories = [] +++ TODO: scrivi il contenuto qui. """ class NewArticleDialog(QDialog): def __init__(self, blog_root: Path, parent=None): super().__init__(parent) self.setWindowTitle("Nuovo articolo") self.setMinimumWidth(400) self._blog_root = blog_root self._created_path: Path | None = None self._build_ui() @property def created_path(self) -> Path | None: return self._created_path def _build_ui(self): layout = QVBoxLayout(self) form = QFormLayout() self._slug = QLineEdit() self._slug.setPlaceholderText("mio-articolo-2026") form.addRow("Slug:", self._slug) self._title = QLineEdit() self._title.setPlaceholderText("Il mio articolo") form.addRow("Titolo:", self._title) self._lang = QComboBox() self._lang.addItems(["it", "en"]) form.addRow("Lingua:", self._lang) self._type = QComboBox() self._type.addItems(ARTICLE_TYPES) form.addRow("Tipo:", self._type) layout.addLayout(form) btns = QHBoxLayout() create_btn = QPushButton("Crea") create_btn.clicked.connect(self._create) cancel_btn = QPushButton("Annulla") cancel_btn.clicked.connect(self.reject) btns.addStretch() btns.addWidget(cancel_btn) btns.addWidget(create_btn) layout.addLayout(btns) def _create(self): slug = self._slug.text().strip() title = self._title.text().strip() lang = self._lang.currentText() article_type = self._type.currentText() if not slug or not title: QMessageBox.warning(self, "Errore", "Slug e titolo sono obbligatori.") return target = self._blog_root / "content" / lang / "articles" / slug if target.exists(): QMessageBox.warning(self, "Errore", f"Esiste già: {target}") return target.mkdir(parents=True) index_md = target / "index.md" index_md.write_text(STUB_TEMPLATE.format( title=title, date=date.today().isoformat(), type=article_type, ), encoding="utf-8") self._created_path = index_md self.accept() ``` - [ ] **Step 2: Wire into main_window.py** Replace the placeholder for `_page_new_article`. In `_build_ui`, instead of adding a label placeholder, connect the sidebar button to open the dialog: ```python # In _build_sidebar, change the "Nuovo articolo" button click handler: # Find the btn("➕ Nuovo articolo", ...) call and replace with: b_new = SidebarButton("➕ Nuovo articolo") b_new.clicked.connect(self._do_new_article) layout.addWidget(b_new) self._btn_group.append(b_new) ``` Add to `MainWindow`: ```python def _do_new_article(self): from ui.new_article_dialog import NewArticleDialog import subprocess dlg = NewArticleDialog(Path(self.config.blog_repo), self) if dlg.exec() and dlg.created_path: self._refresh_articles() subprocess.Popen([self.config.typora_bin, str(dlg.created_path)]) ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "➕ Nuovo articolo" → dialog → fill slug/title/type → create → file appears in blog repo → Typora opens it. - [ ] **Step 4: Commit** ```bash git add ui/new_article_dialog.py ui/main_window.py git commit -m "feat: new article stub creation with Typora open" ``` --- ## Task 15: Translation view **Files:** - Create: `ui/translation_view.py` - Modify: `ui/main_window.py` (wire `_do_translate`) - [ ] **Step 1: Implement ui/translation_view.py** ```python from __future__ import annotations import subprocess from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, QTextBrowser, ) from PyQt6.QtCore import pyqtSignal from core.models import Article from core.frontmatter import parse_frontmatter from workers.translation_worker import TranslationWorker import re def _strip_shortcodes(text: str) -> str: return re.sub(r'\{\{[^}]+\}\}', '[shortcode]', text) class TranslationView(QWidget): push_master = pyqtSignal() publish = pyqtSignal() def __init__(self, transart_script: str, typora_bin: str, parent=None): super().__init__(parent) self._transart_script = transart_script self._typora_bin = typora_bin self._article: Article | None = None self._worker: TranslationWorker | None = None self._output_path: str = "" self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) # Header self._header = QLabel("") self._header.setStyleSheet("color:#fff;font-weight:bold;font-size:13px;padding:10px;") layout.addWidget(self._header) # Log log_label = QLabel("Output traduzione:") log_label.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:0 10px;") layout.addWidget(log_label) self._log = QPlainTextEdit() self._log.setReadOnly(True) self._log.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;") self._log.setMaximumHeight(180) layout.addWidget(self._log) # Preview preview_label = QLabel("Preview traduzione:") preview_label.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:0 10px;") layout.addWidget(preview_label) self._preview = QTextBrowser() self._preview.setStyleSheet("background:#0d0d1a;color:#ccc;border:none;") layout.addWidget(self._preview, stretch=1) # Action buttons (hidden until translation done) self._action_bar = QWidget() btns = QHBoxLayout(self._action_bar) btns.setContentsMargins(10, 6, 10, 6) self._btn_typora = QPushButton("✏️ Apri in Typora") self._btn_typora.clicked.connect(self._open_typora) self._btn_retry = QPushButton("🔄 Rigenera") self._btn_retry.clicked.connect(self._retry) self._btn_master = QPushButton("🧪 Push master") self._btn_master.clicked.connect(self.push_master.emit) self._btn_publish = QPushButton("🚢 Pubblica") self._btn_publish.clicked.connect(self.publish.emit) for btn in (self._btn_typora, self._btn_retry, self._btn_master, self._btn_publish): btns.addWidget(btn) btns.addStretch() self._action_bar.setVisible(False) layout.addWidget(self._action_bar) def start_translation(self, article: Article): self._article = article self._log.clear() self._preview.clear() self._action_bar.setVisible(False) direction = "it-en" if article.lang == "it" else "en-it" self._header.setText(f"Traduzione: {article.slug} ({article.lang.upper()} → {('EN' if article.lang == 'it' else 'IT')})") self._worker = TranslationWorker( Path(self._transart_script), article.path, direction, parent=self, ) self._worker.log_line.connect(self._log.appendPlainText) self._worker.finished.connect(self._on_finished) self._worker.error.connect(lambda e: self._log.appendPlainText(f"[ERRORE] {e}")) self._worker.start() def _on_finished(self, success: bool, output_path: str): self._output_path = output_path if success and Path(output_path).exists(): try: _, body = parse_frontmatter(Path(output_path)) self._preview.setMarkdown(_strip_shortcodes(body)) except Exception as e: self._preview.setPlainText(f"[Errore preview: {e}]") self._action_bar.setVisible(True) def _open_typora(self): if self._output_path: subprocess.Popen([self._typora_bin, self._output_path]) def _retry(self): if self._article: self.start_translation(self._article) ``` - [ ] **Step 2: Wire into main_window.py** Add to `MainWindow._build_ui`: ```python from ui.translation_view import TranslationView self._translation_view = TranslationView( self.config.transart_script, self.config.typora_bin, parent=self, ) self._translation_view.push_master.connect(lambda: self._do_git_push("master")) self._translation_view.publish.connect(lambda: self._do_git_push("production")) self._stack.addWidget(self._translation_view) self._page_translation = self._stack.count() - 1 ``` Replace `_do_translate` stub: ```python def _do_translate(self, article: Article): self._translation_view.start_translation(article) self._stack.setCurrentIndex(self._page_translation) ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🌍 Traduci" on an article → translation view appears with streaming log. When done: preview shows translated markdown + action buttons appear. - [ ] **Step 4: Commit** ```bash git add ui/translation_view.py ui/main_window.py git commit -m "feat: translation view with QProcess streaming and post-translation preview" ``` --- ## Task 16: Git view **Files:** - Create: `ui/git_view.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/git_view.py** ```python from __future__ import annotations from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, QTabWidget, QListWidget, QListWidgetItem, ) from PyQt6.QtCore import pyqtSignal from workers.git_worker import GitWorker import subprocess class GitView(QWidget): refresh_needed = pyqtSignal() def __init__(self, repo_path: Path, parent=None): super().__init__(parent) self._repo_path = repo_path self._worker: GitWorker | None = None self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) # Status bar status_row = QHBoxLayout() self._branch_label = QLabel("Branch: —") self._branch_label.setStyleSheet("color:#a855f7;font-weight:bold;") status_row.addWidget(self._branch_label) status_row.addStretch() refresh_btn = QPushButton("🔄 Refresh") refresh_btn.clicked.connect(self.load_status) status_row.addWidget(refresh_btn) layout.addLayout(status_row) # Tabs tabs = QTabWidget() # Status tab status_tab = QWidget() st_layout = QVBoxLayout(status_tab) self._status_log = QPlainTextEdit() self._status_log.setReadOnly(True) self._status_log.setStyleSheet("background:#0a0a12;color:#ccc;font-family:monospace;font-size:11px;") st_layout.addWidget(self._status_log) btns = QHBoxLayout() for label, slot in [ ("⬇ Pull", self._do_pull), ("🧪 Push master", self._do_push_master), ("🚢 Push production", self._do_push_production), ]: btn = QPushButton(label) btn.clicked.connect(slot) btns.addWidget(btn) btns.addStretch() st_layout.addLayout(btns) tabs.addTab(status_tab, "Stato repo") # Deleted articles tab deleted_tab = QWidget() del_layout = QVBoxLayout(deleted_tab) del_layout.addWidget(QLabel("Articoli eliminati (da git log):")) self._deleted_list = QListWidget() del_layout.addWidget(self._deleted_list) restore_btn = QPushButton("♻️ Ripristina selezionato") restore_btn.clicked.connect(self._do_restore) del_layout.addWidget(restore_btn) tabs.addTab(deleted_tab, "Articoli eliminati") layout.addWidget(tabs) # Output log self._output = QPlainTextEdit() self._output.setReadOnly(True) self._output.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;") self._output.setMaximumHeight(150) layout.addWidget(self._output) def load_status(self): try: branch = subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=self._repo_path, text=True, ).strip() self._branch_label.setText(f"Branch: {branch}") status = subprocess.check_output( ["git", "status", "--short"], cwd=self._repo_path, text=True, ) log = subprocess.check_output( ["git", "log", "--oneline", "-5"], cwd=self._repo_path, text=True, ) self._status_log.setPlainText(f"--- git status ---\n{status}\n--- ultimi commit ---\n{log}") # Load deleted articles self._deleted_list.clear() deleted = subprocess.check_output( ["git", "log", "--diff-filter=D", "--name-only", "--pretty=format:%H %s", "--", "content/"], cwd=self._repo_path, text=True, ) current_hash = None for line in deleted.splitlines(): if line.strip() == "": continue if line.startswith("content/"): if current_hash: item = QListWidgetItem(f"{line} [{current_hash[:8]}]") item.setData(256, (current_hash, line)) self._deleted_list.addItem(item) else: parts = line.split(" ", 1) current_hash = parts[0] if parts else None except Exception as e: self._output.appendPlainText(f"[Errore] {e}") def _run_worker(self, worker: GitWorker): self._worker = worker worker.output.connect(self._output.appendPlainText) worker.error.connect(lambda e: self._output.appendPlainText(f"[ERRORE] {e}")) worker.finished.connect(lambda ok: self.refresh_needed.emit() if ok else None) worker.start() def _do_pull(self): self._run_worker(GitWorker.pull(self._repo_path, self)) def _do_push_master(self): self._run_worker(GitWorker.push_master(self._repo_path, self)) def _do_push_production(self): self._run_worker(GitWorker.push_production(self._repo_path, self)) def _do_restore(self): item = self._deleted_list.currentItem() if not item: return commit_hash, rel_path = item.data(256) worker = GitWorker.restore_article(self._repo_path, commit_hash, rel_path, self) self._run_worker(worker) self.load_status() ``` - [ ] **Step 2: Wire into main_window.py** Replace git placeholder in `_build_ui`: ```python from ui.git_view import GitView self._git_view = GitView(Path(self.config.blog_repo), parent=self) self._git_view.refresh_needed.connect(self._refresh_articles) self._stack.addWidget(self._git_view) self._page_git = self._stack.count() - 1 ``` Replace `_do_git_push` stub: ```python def _do_git_push(self, branch: str): from workers.git_worker import GitWorker repo = Path(self.config.blog_repo) if branch == "master": worker = GitWorker.push_master(repo, self) else: worker = GitWorker.push_production(repo, self) worker.error.connect(lambda e: self.statusBar().showMessage(f"Git error: {e}", 5000)) worker.finished.connect(lambda ok: self.statusBar().showMessage("Push completato." if ok else "Push fallito.", 4000)) worker.start() def _do_delete_article(self, article: Article): from PyQt6.QtWidgets import QMessageBox from workers.git_worker import GitWorker reply = QMessageBox.question( self, "Elimina articolo", f"Eliminare '{article.slug}' ({article.lang})? L'operazione sarà tracciata in git.", ) if reply == QMessageBox.StandardButton.Yes: rel = str(article.path.parent.relative_to(Path(self.config.blog_repo))) worker = GitWorker.remove_article( Path(self.config.blog_repo), rel, article.slug, article.lang, self ) worker.error.connect(lambda e: self.statusBar().showMessage(f"Errore: {e}", 5000)) worker.finished.connect(lambda ok: self._refresh_articles() if ok else None) worker.start() ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🔧 Git ops" → branch shown, status + last 5 commits visible. Pull/push buttons run git ops with output in log. - [ ] **Step 4: Commit** ```bash git add ui/git_view.py ui/main_window.py git commit -m "feat: git view with status, pull/push, and deleted article restore" ``` --- ## Task 17: Hugo server panel **Files:** - Create: `ui/hugo_panel.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/hugo_panel.py** ```python from __future__ import annotations from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, ) from PyQt6.QtCore import pyqtSignal, QUrl from PyQt6.QtGui import QDesktopServices from workers.hugo_worker import HugoWorker class HugoPanel(QWidget): def __init__(self, repo_path: Path, parent=None): super().__init__(parent) self._worker = HugoWorker(repo_path, parent=self) self._worker.log_line.connect(self._on_log) self._worker.started.connect(self._on_started) self._worker.stopped.connect(self._on_stopped) self._worker.error.connect(lambda e: self._log.appendPlainText(f"[ERRORE] {e}")) self._build_ui() def _build_ui(self): layout = QVBoxLayout(self) top = QHBoxLayout() self._status_label = QLabel("🔴 Hugo server: fermo") self._status_label.setStyleSheet("color:#ff6b6b;font-weight:bold;") top.addWidget(self._status_label) top.addStretch() self._start_btn = QPushButton("▶ Avvia") self._start_btn.clicked.connect(self._worker.start) self._stop_btn = QPushButton("⏹ Ferma") self._stop_btn.clicked.connect(self._worker.stop) self._stop_btn.setEnabled(False) top.addWidget(self._start_btn) top.addWidget(self._stop_btn) self._url_btn = QPushButton("🌐 http://localhost:1313") self._url_btn.setFlat(True) self._url_btn.setStyleSheet("color:#60a5fa;text-decoration:underline;") self._url_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("http://localhost:1313"))) self._url_btn.setVisible(False) top.addWidget(self._url_btn) layout.addLayout(top) self._log = QPlainTextEdit() self._log.setReadOnly(True) self._log.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;") layout.addWidget(self._log) def _on_log(self, line: str): self._log.appendPlainText(line) def _on_started(self): self._status_label.setText("🟢 Hugo server: in esecuzione") self._status_label.setStyleSheet("color:#00ff88;font-weight:bold;") self._start_btn.setEnabled(False) self._stop_btn.setEnabled(True) self._url_btn.setVisible(True) def _on_stopped(self): self._status_label.setText("🔴 Hugo server: fermo") self._status_label.setStyleSheet("color:#ff6b6b;font-weight:bold;") self._start_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._url_btn.setVisible(False) def closeEvent(self, event): self._worker.stop() super().closeEvent(event) ``` - [ ] **Step 2: Wire into main_window.py** Replace hugo placeholder in `_build_ui`: ```python from ui.hugo_panel import HugoPanel self._hugo_panel = HugoPanel(Path(self.config.blog_repo), parent=self) self._stack.addWidget(self._hugo_panel) self._page_hugo = self._stack.count() - 1 ``` Also stop hugo server on app close — override `closeEvent` in `MainWindow`: ```python def closeEvent(self, event): self._hugo_panel._worker.stop() super().closeEvent(event) ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🚀 Hugo server" → panel with start button. Click "▶ Avvia" → log streams hugo output → link `http://localhost:1313` appears clickable. "⏹ Ferma" kills server. - [ ] **Step 4: Commit** ```bash git add ui/hugo_panel.py ui/main_window.py git commit -m "feat: hugo server panel with start/stop and log streaming" ``` --- ## Task 18: Taxonomy view **Files:** - Create: `ui/taxonomy_view.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/taxonomy_view.py** ```python from __future__ import annotations from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QDialog, QFormLayout, QLineEdit, QMessageBox, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor from core.taxonomy import TaxonomyModel, load_taxonomy, save_taxonomy class AddTermDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Aggiungi termine") layout = QVBoxLayout(self) form = QFormLayout() self._it = QLineEdit() self._en = QLineEdit() form.addRow("IT:", self._it) form.addRow("EN:", self._en) layout.addLayout(form) btns = QHBoxLayout() ok = QPushButton("Aggiungi") ok.clicked.connect(self.accept) cancel = QPushButton("Annulla") cancel.clicked.connect(self.reject) btns.addStretch() btns.addWidget(cancel) btns.addWidget(ok) layout.addLayout(btns) @property def it_term(self) -> str: return self._it.text().strip() @property def en_term(self) -> str: return self._en.text().strip() class TaxonomyView(QWidget): def __init__(self, blog_root: Path, parent=None): super().__init__(parent) self._blog_root = blog_root self._tags_it = blog_root / "docs" / "tags-it.txt" self._tags_en = blog_root / "docs" / "tags-en.txt" self._cats_it = blog_root / "docs" / "categories.txt" self._model: TaxonomyModel | None = None self._build_ui() self.load() def _build_ui(self): layout = QVBoxLayout(self) tabs = QTabWidget() # Tags tab tags_tab = QWidget() tl = QVBoxLayout(tags_tab) self._tags_table = QTableWidget(0, 2) self._tags_table.setHorizontalHeaderLabels(["🇮🇹 Italiano", "🇬🇧 English"]) self._tags_table.horizontalHeader().setStretchLastSection(True) tl.addWidget(self._tags_table) tag_btns = QHBoxLayout() add_tag_btn = QPushButton("➕ Aggiungi termine") add_tag_btn.clicked.connect(self._add_term) save_tags_btn = QPushButton("💾 Salva") save_tags_btn.clicked.connect(self._save) tag_btns.addWidget(add_tag_btn) tag_btns.addStretch() tag_btns.addWidget(save_tags_btn) tl.addLayout(tag_btns) tabs.addTab(tags_tab, "🏷 Tags") layout.addWidget(tabs) self._status = QLabel("") self._status.setStyleSheet("color:#888;font-size:10px;padding:4px;") layout.addWidget(self._status) def load(self): if not self._tags_it.exists(): self._status.setText("tags-it.txt non trovato.") return self._model = load_taxonomy(self._tags_it, self._tags_en) self._populate_table() def _populate_table(self): if not self._model: return self._tags_table.setRowCount(0) for it_term, en_term in sorted(self._model.it_to_en.items()): row = self._tags_table.rowCount() self._tags_table.insertRow(row) self._tags_table.setItem(row, 0, QTableWidgetItem(it_term)) self._tags_table.setItem(row, 1, QTableWidgetItem(en_term)) for orphan in self._model.orphans_it: row = self._tags_table.rowCount() self._tags_table.insertRow(row) it_item = QTableWidgetItem(orphan) it_item.setForeground(QColor("#ff6b6b")) en_item = QTableWidgetItem("⚠ mancante") en_item.setForeground(QColor("#ff6b6b")) self._tags_table.setItem(row, 0, it_item) self._tags_table.setItem(row, 1, en_item) self._status.setText( f"{len(self._model.it_to_en)} coppie · {len(self._model.orphans_it)} IT orfani · {len(self._model.orphans_en)} EN orfani" ) def _add_term(self): dlg = AddTermDialog(self) if dlg.exec() and dlg.it_term and dlg.en_term: if self._model: self._model.it_to_en[dlg.it_term] = dlg.en_term self._populate_table() def _save(self): if not self._model: return updated: dict[str, str] = {} for row in range(self._tags_table.rowCount()): it_item = self._tags_table.item(row, 0) en_item = self._tags_table.item(row, 1) if it_item and en_item: it_val = it_item.text().strip() en_val = en_item.text().strip() if it_val and en_val and en_val != "⚠ mancante": updated[it_val] = en_val self._model.it_to_en = updated save_taxonomy(self._model, self._tags_it, self._tags_en) self._status.setText("Salvato.") ``` - [ ] **Step 2: Wire into main_window.py** Replace taxonomy placeholder in `_build_ui`: ```python from ui.taxonomy_view import TaxonomyView self._taxonomy_view = TaxonomyView(Path(self.config.blog_repo), parent=self) self._stack.addWidget(self._taxonomy_view) self._page_taxonomy = self._stack.count() - 1 ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🏷 Tassonomia" → table with IT|EN tag pairs. Orphans shown in red. Add term dialog works. Save updates `docs/tags-it.txt` and `docs/tags-en.txt`. - [ ] **Step 4: Commit** ```bash git add ui/taxonomy_view.py ui/main_window.py git commit -m "feat: taxonomy view with IT/EN pair table and orphan detection" ``` --- ## Task 19: Media view **Files:** - Create: `ui/media_view.py` - Modify: `ui/main_window.py` - [ ] **Step 1: Implement ui/media_view.py** ```python from __future__ import annotations import shutil from datetime import date from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListWidget, QListWidgetItem, QFileDialog, ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QDragEnterEvent, QDropEvent class MediaView(QWidget): def __init__(self, blog_root: Path, parent=None): super().__init__(parent) self._blog_root = blog_root self.setAcceptDrops(True) self._build_ui() self._refresh_list() def _target_dir(self) -> Path: today = date.today() return self._blog_root / "static" / "uppies" / str(today.year) / f"{today.month:02d}" def _build_ui(self): layout = QVBoxLayout(self) header = QLabel("🖼 Media — static/uppies/YYYY/MM/") header.setStyleSheet("color:#fff;font-weight:bold;font-size:13px;padding:10px;") layout.addWidget(header) drop_zone = QLabel("Trascina file qui, oppure usa il pulsante sotto.") drop_zone.setAlignment(Qt.AlignmentFlag.AlignCenter) drop_zone.setStyleSheet("border:2px dashed #2a2a4e;color:#555;padding:20px;border-radius:6px;margin:10px;") drop_zone.setMinimumHeight(80) layout.addWidget(drop_zone) btns = QHBoxLayout() add_btn = QPushButton("📂 Aggiungi file...") add_btn.clicked.connect(self._pick_files) btns.addWidget(add_btn) btns.addStretch() self._path_label = QLabel("") self._path_label.setStyleSheet("color:#a855f7;font-size:10px;") btns.addWidget(self._path_label) layout.addLayout(btns) self._list = QListWidget() layout.addWidget(self._list, stretch=1) self._status = QLabel("") self._status.setStyleSheet("color:#888;font-size:10px;padding:4px;") layout.addWidget(self._status) def _copy_files(self, paths: list[str]): target = self._target_dir() target.mkdir(parents=True, exist_ok=True) last_web_path = "" for src in paths: dest = target / Path(src).name shutil.copy2(src, dest) today = date.today() last_web_path = f"/uppies/{today.year}/{today.month:02d}/{Path(src).name}" if last_web_path: from PyQt6.QtWidgets import QApplication QApplication.clipboard().setText(last_web_path) self._status.setText(f"Copiato in clipboard: {last_web_path}") self._refresh_list() def _pick_files(self): files, _ = QFileDialog.getOpenFileNames(self, "Seleziona file media") if files: self._copy_files(files) def _refresh_list(self): self._list.clear() target = self._target_dir() today = date.today() self._path_label.setText(f"Cartella: static/uppies/{today.year}/{today.month:02d}/") if not target.exists(): return for f in sorted(target.iterdir()): if f.is_file(): self._list.addItem(QListWidgetItem(f.name)) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event: QDropEvent): paths = [u.toLocalFile() for u in event.mimeData().urls() if u.isLocalFile()] if paths: self._copy_files(paths) ``` - [ ] **Step 2: Wire into main_window.py** Replace media placeholder in `_build_ui`: ```python from ui.media_view import MediaView self._media_view = MediaView(Path(self.config.blog_repo), parent=self) self._stack.addWidget(self._media_view) self._page_media = self._stack.count() - 1 ``` - [ ] **Step 3: Launch and verify** ```bash python main.py ``` Expected: click "🖼 Media" → drag a file onto the window → file copied to `static/uppies/YYYY/MM/` → web path copied to clipboard → file appears in list. - [ ] **Step 4: Commit** ```bash git add ui/media_view.py ui/main_window.py git commit -m "feat: media view with drag-drop upload and clipboard path copy" ``` --- ## Task 20: Final wiring + polish **Files:** - Modify: `ui/main_window.py` - Modify: `main.py` - [ ] **Step 1: Add statusbar to MainWindow** In `MainWindow.__init__`, after `self._build_ui()`: ```python self.statusBar().showMessage("my-publisher pronto.") self.statusBar().setStyleSheet("background:#0f0f1a;color:#555;font-size:10px;") ``` - [ ] **Step 2: Default to articles view on startup** At end of `MainWindow.__init__`, after `_refresh_articles()`: ```python # Select articles button and show articles view by default if self._btn_group: self._btn_group[0].setChecked(True) self._stack.setCurrentIndex(self._page_articles) ``` - [ ] **Step 3: Add .gitignore** ```bash cat > /home/danix/Programming/GIT/my-publisher/.gitignore << 'EOF' __pycache__/ *.pyc .superpowers/ EOF ``` - [ ] **Step 4: Run all tests** ```bash pytest tests/ -v ``` Expected: all tests pass. - [ ] **Step 5: End-to-end verification** Run through the full checklist from the spec: 1. `python main.py` → sidebar with articles from real blog repo 2. Click article → frontmatter + markdown preview visible 3. Click "🔧 Frontmatter" → edit `type` via dropdown → save → reopen → value persisted 4. Open Typora → edit file → return to app → `QFileSystemWatcher` triggers refresh 5. Click "🌍 Traduci" → log streams → preview appears after completion 6. Click "🔧 Git ops" → branch and status visible → pull works 7. Click "🖼 Media" → drop a file → appears in list → path in clipboard 8. Delete article → confirm dialog → scompare dalla lista → ripristino da Git ops funziona - [ ] **Step 6: Final commit** ```bash git add . git commit -m "feat: complete my-publisher app — all views wired and working" ```