Commit Diff


commit - /dev/null
commit + bd3729dd6f06c79e0afe271673ff815f499637fc
blob - /dev/null
blob + c1c662bded269273bcf972cedc545e4a6bb06935 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,4 @@
+.venv/
+__pycache__/
+*.egg-info/
+*.pyc
blob - /dev/null
blob + d4009889a9672adf4dd541e34133bd29f82f9d5b (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,35 @@
+# nezzontli-ctl
+
+TUI (Textual) para crear/editar contenido de [nezzontli.xyz](https://nezzontli.xyz)
+sin depender de un CMS externo — ninguno soporta git-lfs, que es como se
+versionan las fotos de los álbumes. Esta herramienta usa git real (el mismo
+`git lfs install` de siempre), así que no tiene ese problema.
+
+Es un proyecto separado del repo del sitio: opera *sobre* `~/projects/website`
+(u otra ruta, ver `NEZZONTLI_REPO_PATH` abajo), no vive adentro de él.
+
+## Desarrollo
+
+```bash
+nix develop   # crea/activa .venv y hace pip install -e . automáticamente
+ctl           # o: python3 -m nezzontli_ctl
+```
+
+`NEZZONTLI_REPO_PATH` (variable de entorno, opcional) apunta al clone del
+sitio sobre el que trabaja. Default: `~/projects/website`. También se puede
+fijar de forma persistente en `~/.config/nezzontli-ctl/config.json`
+(`repo_path`) o cambiarla desde la pantalla de Configuración.
+
+## Instalación (una vez validada)
+
+```bash
+cd nezzontli-ctl
+pipx install -e .
+ctl   # ya disponible en cualquier terminal, sin activar nada
+```
+
+## Estado
+
+En desarrollo — todavía no integrada al flujo real de contenido de
+nezzontli.xyz (eso sigue por `website/scripts/ctl.py`, el CLI simple ya
+probado, hasta que esta versión quede validada).
blob - /dev/null
blob + 8a11d497d5c1f1169a6a3475579031de503e3ddb (mode 644)
--- /dev/null
+++ flake.nix
@@ -0,0 +1,33 @@
+{
+  description = "nezzontli-ctl — TUI (Textual) para gestionar contenido de nezzontli.xyz sin CMS";
+
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+  };
+
+  outputs = { self, nixpkgs }:
+    let
+      system = "x86_64-linux";
+      pkgs = import nixpkgs { inherit system; };
+    in
+    {
+      devShells.${system}.default = pkgs.mkShell {
+        # Deps del proyecto (textual/pyfiglet/cowsay) van por pip/pyproject.toml,
+        # no por nixpkgs: cowsay no está empaquetado ahí, y mezclar deps de nix
+        # con deps de pip para el mismo proyecto es frágil. Nix solo fija la
+        # versión de Python.
+        packages = [ pkgs.python313 ];
+
+        shellHook = ''
+          if [ ! -d .venv ]; then
+            echo "Creando .venv..."
+            python3 -m venv .venv
+          fi
+          source .venv/bin/activate
+          pip install -q -e . --disable-pip-version-check
+          echo "nezzontli-ctl listo. Corré 'ctl' o 'python3 -m nezzontli_ctl'."
+          echo "Apunta a: ''${NEZZONTLI_REPO_PATH:-$HOME/projects/website}"
+        '';
+      };
+    };
+}
blob - /dev/null
blob + 4b26ed12a52578e1989ccef36699c40eb108fa88 (mode 644)
--- /dev/null
+++ nezzontli_ctl/__init__.py
@@ -0,0 +1 @@
+"""TUI para crear/editar contenido de Nezzontli sin CMS externo."""
blob - /dev/null
blob + d9e8986f18b84aa36e03c2a917eec3aaecf3a118 (mode 644)
--- /dev/null
+++ nezzontli_ctl/__main__.py
@@ -0,0 +1,4 @@
+from nezzontli_ctl.app import main
+
+if __name__ == "__main__":
+    main()
blob - /dev/null
blob + c595569c9ec88b0df3bbcd0b056bf354a36d73f1 (mode 644)
--- /dev/null
+++ nezzontli_ctl/config.py
@@ -0,0 +1,50 @@
+"""Rutas del repo del sitio (nezzontli.xyz) y preferencias de usuario.
+
+nezzontli-ctl es una herramienta aparte que opera sobre OTRO repo (el del
+sitio, normalmente ~/projects/website) — no vive adentro de él, así que la
+ruta no se puede derivar de la ubicación de este archivo. Se configura vía
+NEZZONTLI_REPO_PATH o queda en ~/.config/nezzontli-ctl/config.json.
+"""
+
+import json
+import os
+from pathlib import Path
+
+PREFS_DIR = Path.home() / ".config" / "nezzontli-ctl"
+PREFS_FILE = PREFS_DIR / "config.json"
+
+DEFAULT_PREFS = {
+    "cowsay_char": "cow",
+    "repo_path": str(Path.home() / "projects" / "website"),
+}
+
+
+def load_prefs():
+    if not PREFS_FILE.is_file():
+        return dict(DEFAULT_PREFS)
+    try:
+        data = json.loads(PREFS_FILE.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return dict(DEFAULT_PREFS)
+    prefs = dict(DEFAULT_PREFS)
+    prefs.update(data)
+    return prefs
+
+
+def save_prefs(prefs):
+    PREFS_DIR.mkdir(parents=True, exist_ok=True)
+    PREFS_FILE.write_text(json.dumps(prefs, indent=2), encoding="utf-8")
+
+
+def get_repo_root():
+    env_path = os.environ.get("NEZZONTLI_REPO_PATH")
+    if env_path:
+        return Path(env_path).expanduser().resolve()
+    return Path(load_prefs()["repo_path"]).expanduser().resolve()
+
+
+REPO_ROOT = get_repo_root()
+BLOG_DIR = REPO_ROOT / "content" / "blog"
+PHOTOS_DIR = REPO_ROOT / "content" / "photos"
+IMAGES_DIR = REPO_ROOT / "static" / "images"
+IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".gif", ".GIF"}
blob - /dev/null
blob + ad46fa519f0d0fc8b1b0b4a22b22a7d7add1d4ce (mode 644)
--- /dev/null
+++ nezzontli_ctl/content.py
@@ -0,0 +1,98 @@
+"""Lógica pura para armar TOML/Markdown de posts, páginas y álbumes.
+
+Migrado tal cual del ctl.py anterior (probado ya en esa versión).
+"""
+
+import re
+from datetime import date
+from pathlib import Path
+
+from nezzontli_ctl.config import IMAGE_EXTENSIONS
+
+
+def slugify(text):
+    text = text.strip().lower()
+    text = re.sub(r"[^a-z0-9]+", "-", text)
+    return text.strip("-") or "sin-titulo"
+
+
+def toml_str(value):
+    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
+
+
+def toml_list(values):
+    return "[" + ", ".join(toml_str(v) for v in values) + "]"
+
+
+def build_post_frontmatter(title, description, tags, authors, katex=False, comments=None, dt=None):
+    dt = dt or date.today()
+    lines = [
+        f"title = {toml_str(title)}",
+        f"description = {toml_str(description)}",
+        f"authors = {toml_list(authors)}",
+        f"date = {dt.isoformat()}",
+    ]
+    if tags:
+        lines.append("[taxonomies]")
+        lines.append(f"tags = {toml_list(tags)}")
+    if katex or comments:
+        lines.append("[extra]")
+        if katex:
+            lines.append("katex = true")
+        if comments:
+            lines.append("")
+            lines.append("[extra.comments]")
+            lines.append(f"host = {toml_str(comments['host'])}")
+            lines.append(f"user = {toml_str(comments['user'])}")
+            lines.append(f"id = {toml_str(comments['id'])}")
+    return "+++\n" + "\n".join(lines) + "\n+++\n"
+
+
+def build_page_frontmatter(title, description, tags, authors, related=None, dt=None):
+    dt = dt or date.today()
+    lines = [
+        'template = "article.html"',
+        f"title = {toml_str(title)}",
+        f"description = {toml_str(description)}",
+        f"authors = {toml_list(authors)}",
+        f"date = {dt.isoformat()}",
+    ]
+    if tags:
+        lines.append("[taxonomies]")
+        lines.append(f"tags = {toml_list(tags)}")
+    if related:
+        lines.append("[extra]")
+        lines.append(f"related_pages = {toml_list(related)}")
+    return "+++\n" + "\n".join(lines) + "\n+++\n"
+
+
+def build_album_frontmatter(title, description, tags, cover_path, dt=None):
+    dt = dt or date.today()
+    lines = [
+        f"title = {toml_str(title)}",
+        f"date = {dt.isoformat()}",
+        f"description = {toml_str(description)}",
+    ]
+    if tags:
+        lines.append("[taxonomies]")
+        lines.append(f"tags = {toml_list(tags)}")
+    lines.append("[extra]")
+    lines.append(f"cover_image = {toml_str(cover_path)}")
+    return "+++\n" + "\n".join(lines) + "\n+++\n"
+
+
+def gallery_shortcode(image_paths_with_alt):
+    items = ", ".join(toml_str(p) for p in image_paths_with_alt)
+    return f"{{{{ gallery(photos=[{items}]) }}}}\n"
+
+
+def collect_images(source_dir):
+    source = Path(source_dir).expanduser().resolve()
+    if not source.is_dir():
+        raise ValueError(f"{source} no es un directorio.")
+    files = sorted(
+        p for p in source.iterdir() if p.suffix in IMAGE_EXTENSIONS and p.is_file()
+    )
+    if not files:
+        raise ValueError(f"No se encontraron imágenes en {source}.")
+    return files
blob - /dev/null
blob + 6818b835256f6aca2d9d93aca20ffe8473cd89df (mode 644)
--- /dev/null
+++ nezzontli_ctl/git_ops.py
@@ -0,0 +1,34 @@
+"""Operaciones git reales (no la API REST de ningún host — así las fotos
+pasan por el filtro de git-lfs como cualquier commit hecho a mano)."""
+
+import subprocess
+
+from nezzontli_ctl.config import REPO_ROOT
+
+
+def _git(*args, check=True):
+    return subprocess.run(
+        ["git", *args], cwd=REPO_ROOT, check=check, capture_output=True, text=True
+    )
+
+
+def stage(paths):
+    _git("add", *[str(p) for p in paths])
+
+
+def diff_stat_cached():
+    result = _git("diff", "--cached", "--stat")
+    return result.stdout
+
+
+def commit_and_push(message):
+    _git("commit", "-m", message)
+    push = subprocess.run(
+        ["git", "push", "origin", "main"],
+        cwd=REPO_ROOT,
+        capture_output=True,
+        text=True,
+    )
+    ok = push.returncode == 0
+    output = (push.stdout or "") + (push.stderr or "")
+    return ok, output
blob - /dev/null
blob + 226e4bd680918eddfb44ed2d44479ded5788a298 (mode 644)
--- /dev/null
+++ pyproject.toml
@@ -0,0 +1,20 @@
+[project]
+name = "nezzontli-ctl"
+version = "0.1.0"
+description = "TUI para crear/editar contenido de Nezzontli (posts, páginas, álbumes de fotos)"
+requires-python = ">=3.10"
+dependencies = [
+    "textual>=0.80",
+    "pyfiglet>=1.0",
+    "cowsay>=6.1",
+]
+
+[project.scripts]
+ctl = "nezzontli_ctl.app:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["nezzontli_ctl"]