commit bd3729dd6f06c79e0afe271673ff815f499637fc from: ale date: Thu Jul 30 20:45:40 2026 UTC Estructura inicial: pyproject.toml, config/content/git_ops, flake.nix Proyecto separado de website — opera sobre ese repo (NEZZONTLI_REPO_PATH) en vez de vivir adentro. En desarrollo, todavía no reemplaza a website/scripts/ctl.py. 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"]