commit - /dev/null
commit + bd3729dd6f06c79e0afe271673ff815f499637fc
blob - /dev/null
blob + c1c662bded269273bcf972cedc545e4a6bb06935 (mode 644)
--- /dev/null
+++ .gitignore
+.venv/
+__pycache__/
+*.egg-info/
+*.pyc
blob - /dev/null
blob + d4009889a9672adf4dd541e34133bd29f82f9d5b (mode 644)
--- /dev/null
+++ README.md
+# 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
+{
+ 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
+"""TUI para crear/editar contenido de Nezzontli sin CMS externo."""
blob - /dev/null
blob + d9e8986f18b84aa36e03c2a917eec3aaecf3a118 (mode 644)
--- /dev/null
+++ nezzontli_ctl/__main__.py
+from nezzontli_ctl.app import main
+
+if __name__ == "__main__":
+ main()
blob - /dev/null
blob + c595569c9ec88b0df3bbcd0b056bf354a36d73f1 (mode 644)
--- /dev/null
+++ nezzontli_ctl/config.py
+"""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
+"""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
+"""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
+[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"]