# 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"
```
