--- /dev/null
+# 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"
+```