From: Danilo M. Date: Sun, 3 May 2026 08:58:46 +0000 (+0200) Subject: chore: add CLAUDE.md, HANDOFF.md, design spec, implementation plan, frontmatter fixes X-Git-Tag: v1.0~5 X-Git-Url: https://git.danix.xyz/?a=commitdiff_plain;h=3f23288296c70ab619cfd100a70b900a019b5f7e;p=publisher.git chore: add CLAUDE.md, HANDOFF.md, design spec, implementation plan, frontmatter fixes --- diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..952aac6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +PyQt6 desktop GUI for Linux that centralizes the danix.xyz Hugo blog publishing workflow. Centralizes: article management, translations (IT↔EN via RunPod), git operations, hugo server, media uploads, and taxonomy management. + +Blog repo: `/home/danix/Programming/GIT/danix.xyz-hacker-theme` +Translation script: `/home/danix/bin/transart.py` (TranslateGemma 27b on RunPod) +Config file: `~/.config/my-publisher/config.toml` + +## Commands + +```bash +# Run app +python main.py + +# Run all tests +pytest tests/ -v + +# Run single test file +pytest tests/test_config.py -v + +# Run single test +pytest tests/test_config.py::test_config_round_trips -v + +# Install deps +pip install -r requirements.txt +``` + +## Architecture + +Single Python process. Three layers: + +**`core/`** — UI-agnostic business logic: +- `config.py` — load/save `~/.config/my-publisher/config.toml` via tomlkit +- `models.py` — `Article` dataclass + `ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]` +- `article_scanner.py` — scans `content/it/` and `content/en/`, builds `list[Article]`, detects translation pairs by matching slugs across languages +- `frontmatter.py` — parse/write Hugo TOML frontmatter (delimited by `+++`, not YAML `---`) +- `taxonomy.py` — load/save `docs/tags-it.txt` + `docs/tags-en.txt` (line-aligned pairs) + +**`workers/`** — async operations via Qt signals: +- `git_worker.py` — `QThread` for pull, push master/production, `git rm`, restore deleted +- `hugo_worker.py` — `QProcess` for `hugo server -D`, streams stdout line by line +- `translation_worker.py` — `QProcess` for `transart.py`, streams stdout for progress display + +Workers emit `output(str)`, `error(str)`, `finished(bool)` signals. UI connects to these — never blocks the main thread. + +**`ui/`** — PyQt6 widgets: +- `main_window.py` — `QMainWindow` with sidebar + `QStackedWidget`. Sidebar has groups: CONTENUTO / WORKFLOW / DEPLOY. Uses `QFileSystemWatcher` on `content/it/` and `content/en/` to auto-refresh on external changes (e.g. Typora edits). +- Each sidebar section maps to a widget in the stack. + +## Key Conventions + +**Hugo frontmatter is TOML, not YAML.** Delimited by `+++`. Always use `tomlkit` (not `tomllib`) to preserve key ordering and comments when writing back. + +**Translation pairs are matched by slug.** An IT article at `content/it/articles/my-post/index.md` and EN at `content/en/articles/my-post/index.md` share slug `my-post`. `article_scanner.py` builds the pairing. + +**Git branches:** `master` = staging (hugo server testing), `production` = live deploy (post-receive hook on server). + +**Media path convention:** Files upload to `static/uppies/YYYY/MM/filename`. Web path served as `/uppies/YYYY/MM/filename`. + +**Tests are UI-free.** `core/` and `workers/` are tested with pytest + tmp_path fixtures. Qt UI components are not unit-tested — verify manually by running the app. + +**Config path override in tests:** Pass `path` explicitly to `Config.load(path)` / `Config.save(path)` rather than monkeypatching the module-level constant. + +## Spec and Plan + +Full design spec: `docs/superpowers/specs/2026-05-01-my-publisher-design.md` +Implementation plan (20 tasks): `docs/superpowers/plans/2026-05-01-my-publisher.md` + +Implementation is in progress — tasks 1–4 complete (scaffold, config, models, frontmatter parser). Tasks 5–20 pending. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..07b13a6 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,18 @@ +Who this is for: + Danix is a developer and blogger running a bilingual (IT/EN) Hugo site at danix.xyz. He is building a PyQt6 desktop GUI called my-publisher to centralize his blog publishing workflow. + +What we covered: + We went through the full brainstorming and design process for my-publisher, including visual mockups of the app layout (sidebar with grouped sections, article list with IT/EN tabs, article detail panel). We settled on a PyQt6 single-process architecture with QThread/QProcess for + async ops. We defined all features: article management with translation status indicators, a dedicated "Senza Traduzione" sidebar entry as a worklist, article detail panel with frontmatter display + markdown preview + action buttons, frontmatter editor with type dropdown (5 fixed + types: Life/Photo/Link/Quote/Tech), translation view with streaming log from transart.py, git ops panel, hugo server panel, taxonomy manager (IT/EN tag pairs), media upload to static/uppies/YYYY/MM/, and git-based soft-delete with restore. We wrote the full design spec and a 20-task implementation plan, then began subagent-driven implementation. + +What was confirmed: + App layout: sidebar with groups CONTENUTO / WORKFLOW / DEPLOY + QStackedWidget content area. Blog repo is at /home/danix/Programming/GIT/danix.xyz-hacker-theme, translation script at /home/danix/bin/transart.py, config stored at ~/.config/my-publisher/config.toml. Git branches: master = staging, production = live via post-receive hook. Hugo frontmatter uses TOML delimited by +++ (not YAML). Translation pairs matched by slug across content/it/ and content/en/. Media served from /uppies/YYYY/MM/. ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]. + Design spec at docs/superpowers/specs/2026-05-01-my-publisher-design.md. Implementation plan at docs/superpowers/plans/2026-05-01-my-publisher.md. + +Still in progress: + Implementation is underway via subagent-driven development. Tasks 1-4 are complete and committed (scaffold, config module, data models, frontmatter parser). Tasks 5-20 are pending. The /init command was just run and produced CLAUDE.md. The code quality review for Task 4 (frontmatter parser) was interrupted before it completed — that review was in flight when the handoff was triggered. + +Next steps: + Resume subagent-driven development from Task 5 (taxonomy module). Before starting, optionally run the interrupted Task 4 code quality review (git range ba5438a..b4d3b52). Then proceed in order: Task 5 taxonomy, Task 6 article scanner, Task 7-9 workers, Task 10 main window, Tasks + 11-20 UI components. Use the plan at docs/superpowers/plans/2026-05-01-my-publisher.md and follow the subagent-driven-development skill for each task (implementer subagent, then spec review, then code quality review before marking complete). \ No newline at end of file diff --git a/core/frontmatter.py b/core/frontmatter.py index 4e9eff3..06c36d1 100644 --- a/core/frontmatter.py +++ b/core/frontmatter.py @@ -24,5 +24,5 @@ def write_frontmatter(path: Path, frontmatter: dict, body: str) -> None: doc = tomlkit.loads(original_toml) for k, v in frontmatter.items(): doc[k] = v - new_text = f"+++\n{tomlkit.dumps(doc)}+++\n\n{body}" + new_text = f"+++\n{tomlkit.dumps(doc).strip()}\n+++\n\n{body}" path.write_text(new_text, encoding="utf-8") diff --git a/docs/superpowers/plans/2026-05-01-my-publisher.md b/docs/superpowers/plans/2026-05-01-my-publisher.md new file mode 100644 index 0000000..ff60e3f --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-my-publisher.md @@ -0,0 +1,2655 @@ +# 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" +``` diff --git a/docs/superpowers/specs/2026-05-01-my-publisher-design.md b/docs/superpowers/specs/2026-05-01-my-publisher-design.md new file mode 100644 index 0000000..8b831f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-my-publisher-design.md @@ -0,0 +1,223 @@ +# my-publisher — Design Spec + +**Date:** 2026-05-01 +**Status:** Approved + +--- + +## Context + +Workflow attuale per pubblicare articoli sul blog danix.xyz è frammentato: git manuale da terminale, hugo server lanciato a mano, traduzione via script CLI, Typora aperto separatamente, frontmatter editato a mano. L'obiettivo è centralizzare tutto in una GUI desktop Linux che automatizzi le operazioni ripetitive e riduca il friction nel publishing workflow. + +Blog: Hugo bilingual IT/EN, repo `danix.xyz-hacker-theme`, due branch (`master` = staging, `production` = live via post-receive hook). Script traduzione: `/home/danix/bin/transart.py` (RunPod + TranslateGemma 27b). + +--- + +## Architettura + +**Stack:** Python 3 + PyQt6. Processo singolo con `QThread`/`QProcess` per operazioni async — UI non blocca mai. + +``` +my-publisher/ +├── main.py +├── ui/ +│ ├── main_window.py # finestra principale, sidebar + QStackedWidget +│ ├── articles_view.py # lista articoli tab IT/EN + voce "Senza Traduzione" +│ ├── article_detail.py # pannello dettaglio (frontmatter + preview + azioni) +│ ├── frontmatter_editor.py # dialog editing TOML frontmatter +│ ├── translation_view.py # progress log + preview + azioni post-traduzione +│ ├── git_view.py # stato repo + operazioni git +│ ├── media_view.py # upload file in static/uppies/YYYY/MM/ +│ └── hugo_panel.py # start/stop hugo server + log +├── workers/ +│ ├── git_worker.py # QThread: pull, push master, push production, git rm, restore +│ ├── hugo_worker.py # QProcess: hugo server start/stop, streaming log +│ └── translation_worker.py # QProcess: transart.py, streaming stdout +├── core/ +│ ├── article_scanner.py # scansiona content/it/ e content/en/, costruisce ArticleModel +│ ├── frontmatter.py # parse/write TOML frontmatter (tomlkit) +│ └── config.py # percorsi configurabili +└── docs/superpowers/specs/ +``` + +**Configurazione:** `~/.config/my-publisher/config.toml` +```toml +blog_repo = "/home/danix/Programming/GIT/danix.xyz-hacker-theme" +transart_script = "/home/danix/bin/transart.py" +typora_bin = "typora" +``` +Prima apertura → dialog setup se config mancante. + +--- + +## Layout UI + +**Struttura:** sidebar sinistra con gruppi + area contenuto destra (QStackedWidget). + +### Sidebar + +``` +📰 my-publisher [🔄] + +CONTENUTO + 📋 Articoli + ⚠️ Senza Traduzione [4] + ➕ Nuovo articolo + 🏷 Tassonomia + 🖼 Media + +WORKFLOW + 🌍 Traduzioni + 🔧 Git ops + 🚀 Hugo server + +DEPLOY + 🧪 Test (master) + 🚢 Production +``` + +Icona 🔄 in cima → refresh manuale `article_scanner`. `QFileSystemWatcher` su `content/it/` e `content/en/` → re-scan silenzioso automatico su modifiche esterne (Typora, terminale). + +--- + +## Componenti + +### Lista Articoli (`articles_view`) + +Tab IT / EN. In ogni tab: lista articoli con badge stato traduzione. +- Articolo con traduzione: `🇬🇧 ✓` verde +- Articolo senza traduzione: `🇬🇧 ✗` rosso + bordo sinistro rosso + +Voce sidebar "Senza Traduzione" → lista cross-lingua di tutti gli articoli privi di traduzione, con badge "manca 🇬🇧" / "manca 🇮🇹" + pulsante "Traduci" diretto. + +Click articolo → emette `article_selected(Article)` → carica `article_detail`. + +### Pannello Dettaglio (`article_detail`) + +Layout: header + split colonne. + +**Header:** path articolo + stato traduzione + 5 pulsanti: +| Pulsante | Azione | +|---|---| +| ✏️ Typora | `subprocess.Popen(["typora", path])` | +| 🔧 Frontmatter | apre `frontmatter_editor` dialog | +| 🌍 Traduci | apre `translation_view` | +| 🧪 Push master | `git_worker` → push master | +| 🚢 Pubblica | `git_worker` → push production | + +**Colonna sinistra:** frontmatter leggibile (campi + valori) + bottone "Modifica frontmatter". + +**Colonna destra:** preview markdown (`QTextBrowser`, renderer Qt built-in). Shortcodes Hugo strippati via regex prima del render. + +### Editor Frontmatter (`frontmatter_editor`) + +Dialog modale. Form generato dinamicamente dai campi TOML dell'articolo. Salva con `tomlkit` per preservare commenti e formato originale. + +Il campo `type` è un `QComboBox` con i 5 tipi predefiniti del sito (non editabile liberamente): + +```python +ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"] +``` + +Tutti gli altri campi sono field testuali liberi. Tags e categorie mostrano i valori esistenti come chip con possibilità di aggiungere/rimuovere — solo termini già presenti nella tassonomia sono accettati (autocompletamento dal file `tags-{lang}.txt`). + +### Vista Traduzione (`translation_view`) + +Sostituisce `article_detail` durante e dopo la traduzione. + +1. **In corso:** `QProcess` lancia `transart.py`, ogni riga stdout → append in `QPlainTextEdit` (log streaming in tempo reale). +2. **Completata:** preview markdown della traduzione + 4 pulsanti: + - ✏️ Apri in Typora + - 🔄 Rigenera traduzione + - 🧪 Push master + - 🚢 Pubblica + +### Git ops (`git_view`) + +Stato repo: branch corrente, `git status` (file modificati), `git log --oneline -5`. + +Pulsanti: Pull, Push master, Push production. Output in `QPlainTextEdit`. + +**Eliminazione articoli:** pulsante "Elimina" in `article_detail` → `git rm` + commit automatico `"remove: ()"`. Sezione "Articoli eliminati" in `git_view` → `git log --diff-filter=D --pretty=...` → pulsante "Ripristina" → `git checkout -- `. + +Refresh automatico all'apertura della scheda. + +Le voci sidebar "🧪 Test (master)" e "🚢 Production" nel gruppo DEPLOY aprono `git_view` con focus sul branch corrispondente e pulsante azione primario evidenziato. Equivalenti ai pulsanti "Push master" / "Pubblica" nell'`article_detail` — shortcut rapidi senza dover aprire un articolo. + +### Tassonomia (`taxonomy_view`) + +Scheda dedicata nella sidebar sotto CONTENUTO. Gestisce tag e categorie in modo bilingue. + +**Visualizzazione:** due tab — Tags / Categorie. Ogni tab mostra lista di termini con colonne IT | EN. Evidenziati in rosso i termini privi di corrispondenza nell'altra lingua (non presenti in `docs/tags-it.txt` / `docs/tags-en.txt`). + +**Operazioni:** +- Aggiungere nuovo termine (IT + EN insieme) +- Modificare termine esistente +- Proposta traduzione automatica: pulsante "Traduci mancanti" → lancia `transart.py` o chiamata diretta all'API Ollama per tradurre i termini orfani in batch +- Salva → aggiorna `docs/tags-it.txt` e `docs/tags-en.txt` mantenendo ordine alfabetico e allineamento 1:1 tra i due file + +**Integrità:** `article_scanner` segnala nel dettaglio articolo se un tag usato non è presente nel file tassonomia. + +### Media (`media_view`) + +Upload file in `static/uppies/YYYY/MM/` (anno/mese corrente automatico). +- Drag & drop nella finestra +- Pulsante "Aggiungi file" → file dialog +- Post-copia: path relativo copiato in clipboard automaticamente (`/uppies/YYYY/MM/file.ext`) +- Lista file presenti nella cartella del mese corrente + +### Hugo Server (`hugo_panel`) + +`QProcess` per `hugo server -D`. Start/stop dalla sidebar con icona status (🟢 running / 🔴 stopped). Log accessibile nel pannello. URL `http://localhost:1313` cliccabile. + +--- + +## Modello Dati + +```python +@dataclass +class Article: + slug: str + lang: str # "it" | "en" + path: Path # path assoluto a index.md + frontmatter: dict + has_translation: bool + translation_path: Path | None +``` + +`ArticleModel` = lista di `Article`. `article_scanner` scansiona `content/{it,en}/articles/*/index.md`, costruisce pairs per slug, determina `has_translation`. + +```python +ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"] +``` + +`TaxonomyModel`: dizionario `{term_it: term_en}` caricato da `docs/tags-it.txt` e `docs/tags-en.txt` (file allineati riga per riga). Stesso schema per categorie. + +--- + +## Gestione Errori + +Ogni worker emette `error(str)` → statusbar + dialog non-bloccante. Git push fallito → stderr visibile. Traduzione fallita → log completo + pulsante "Riprova". + +--- + +## Dipendenze Python + +``` +PyQt6 +tomlkit # parse/write TOML preservando formato +mistune # markdown → HTML per preview (fallback a QTextBrowser nativo) +``` + +--- + +## Verifica (test end-to-end) + +1. Avviare app → sidebar popolata con articoli del blog reale +2. Click articolo → frontmatter corretto + preview markdown visibile +3. Modificare frontmatter → salvare → rileggere file su disco con `tomlkit` +4. Aprire Typora → modificare articolo → tornare in app → refresh automatico aggiorna preview +5. Tradurre articolo → log streaming visibile → preview traduzione al termine +6. Push master → `git log` sul repo mostra commit +7. Upload media → file in `static/uppies/YYYY/MM/` → path in clipboard +8. Eliminare articolo → scompare dalla lista → ripristino da Git ops funziona diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py index 2257a8a..6cd4b44 100644 --- a/tests/test_frontmatter.py +++ b/tests/test_frontmatter.py @@ -1,6 +1,5 @@ import pytest from pathlib import Path -import tempfile from core.frontmatter import parse_frontmatter, write_frontmatter SAMPLE_MD = """\ @@ -42,6 +41,13 @@ def test_write_preserves_format(tmp_path): assert fm2["title"] == "Updated Title" assert fm2["type"] == "Tech" +def test_write_is_idempotent(tmp_path): + f = tmp_path / "index.md" + f.write_text(SAMPLE_MD) + fm, body = parse_frontmatter(f) + write_frontmatter(f, fm, body) + assert f.read_text() == SAMPLE_MD + def test_parse_raises_on_missing_delimiters(tmp_path): f = tmp_path / "index.md" f.write_text("# No frontmatter\n\nJust body.")