commit - 415a550db68b43a152ad5b756f4b919ae67f3045
commit + 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0
blob - ad46fa519f0d0fc8b1b0b4a22b22a7d7add1d4ce
blob + 14d0283655f3642f715371b3a4f3cabf2636525a
--- nezzontli_ctl/content.py
+++ nezzontli_ctl/content.py
"""
import re
+import tomllib
from datetime import date
from pathlib import Path
from nezzontli_ctl.config import IMAGE_EXTENSIONS
+def parse_frontmatter(text):
+ """Separa +++ TOML +++ / cuerpo. Devuelve (dict, cuerpo)."""
+ if not text.startswith("+++"):
+ return {}, text
+ _, rest = text.split("+++", 1)
+ raw_toml, body = rest.split("+++", 1)
+ data = tomllib.loads(raw_toml)
+ return data, body.lstrip("\n")
+
+
def slugify(text):
text = text.strip().lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
return "[" + ", ".join(toml_str(v) for v in values) + "]"
+def _toml_date(dt):
+ """dt puede ser un date (lo normal) o un string (si el archivo original
+ tenía la fecha entre comillas — Zola la acepta igual, pero tomllib la
+ parsea como string, no como date)."""
+ if dt is None:
+ dt = date.today()
+ if hasattr(dt, "isoformat"):
+ return dt.isoformat()
+ return toml_str(str(dt))
+
+
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()}",
+ f"date = {_toml_date(dt)}",
]
if tags:
lines.append("[taxonomies]")
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()}",
+ f"date = {_toml_date(dt)}",
]
if tags:
lines.append("[taxonomies]")
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"date = {_toml_date(dt)}",
f"description = {toml_str(description)}",
]
if tags:
blob - 29079fcf882fe122f05af357d3cac88cb2ebcc2a
blob + 77afa72c4c380a08898428d2c7d5b293c68b7161
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
-from textual.containers import Horizontal, Vertical
+from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Footer, Markdown, Static, TextArea
def compose(self):
with Vertical():
- yield Static(f" {self._title} — Ctrl+S para guardar, Esc para cancelar", id="editor-title")
+ yield Static(f" {self._title}", id="editor-title")
with Horizontal(classes="editor-split"):
yield TextArea(
self._initial_text,
id="editor-textarea",
classes="editor-pane",
)
- yield Markdown(self._initial_text, id="editor-preview", classes="editor-pane")
+ with VerticalScroll(id="editor-preview-scroll", classes="editor-pane"):
+ yield Markdown(self._initial_text, id="editor-preview")
yield Footer()
def on_mount(self) -> None:
text_area = self.query_one("#editor-textarea", TextArea)
text_area.focus()
text_area.cursor_location = text_area.document.end
+ self.watch(text_area, "scroll_y", self._sync_preview_scroll)
+ def _sync_preview_scroll(self, old_value: float, new_value: float) -> None:
+ text_area = self.query_one("#editor-textarea", TextArea)
+ preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
+ max_ta = text_area.max_scroll_y
+ if not max_ta:
+ return
+ ratio = max(0.0, min(1.0, new_value / max_ta))
+ preview_scroll.scroll_y = ratio * preview_scroll.max_scroll_y
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
preview = self.query_one("#editor-preview", Markdown)
preview.update(event.text_area.text)
blob - /dev/null
blob + 097a72e20e41acd456b02cc89e3597b744065c59 (mode 644)
--- /dev/null
+++ nezzontli_ctl/screens/edit_album.py
+from textual.containers import Vertical
+from textual.screen import Screen
+from textual.widgets import Button, Input, Label, Static
+
+from nezzontli_ctl import content
+from nezzontli_ctl.config import PHOTOS_DIR
+from nezzontli_ctl.screens.confirm import ConfirmPushScreen
+from nezzontli_ctl.screens.editor import EditorScreen
+from nezzontli_ctl.screens.success import SuccessScreen
+
+
+def list_albums():
+ """(label, Path) de cada content/photos/<slug>/index.md."""
+ if not PHOTOS_DIR.is_dir():
+ return []
+ items = []
+ for album_dir in sorted(PHOTOS_DIR.iterdir()):
+ index_file = album_dir / "index.md"
+ if not index_file.is_file():
+ continue
+ try:
+ data, _ = content.parse_frontmatter(index_file.read_text(encoding="utf-8"))
+ except Exception:
+ data = {}
+ title = data.get("title", album_dir.name)
+ items.append((f"{title} ({album_dir.name})", index_file))
+ return items
+
+
+class EditAlbumScreen(Screen):
+ """Edita título/descripción/tags/cuerpo de un álbum existente. Para
+ agregar fotos nuevas, usar 'Agregar fotos a un álbum existente'."""
+
+ BINDINGS = [("escape", "app.pop_screen", "Volver")]
+
+ def __init__(self, existing_file):
+ super().__init__()
+ self._existing_file = existing_file
+ self._existing_body = ""
+ self._cover_image = None
+ self._existing_date = None
+
+ def compose(self):
+ with Vertical(classes="form-container"):
+ yield Label("Editar álbum", classes="form-label")
+ yield Static("", id="album-error")
+ yield Label("Título")
+ yield Input(id="title-input")
+ yield Label("Descripción")
+ yield Input(id="description-input")
+ yield Label("Tags (separados por coma)")
+ yield Input(id="tags-input")
+ yield Static(
+ "Para agregar fotos, usá \"Agregar fotos a un álbum existente\" en el menú.",
+ classes="form-row",
+ )
+ yield Button("Guardar cambios →", id="continue-button", variant="primary")
+
+ def on_mount(self) -> None:
+ data, body = content.parse_frontmatter(self._existing_file.read_text(encoding="utf-8"))
+ self._existing_body = body
+ self._cover_image = data.get("extra", {}).get("cover_image")
+ self._existing_date = data.get("date")
+ self.query_one("#title-input", Input).value = data.get("title", "")
+ self.query_one("#description-input", Input).value = data.get("description", "")
+ tags = data.get("taxonomies", {}).get("tags", [])
+ self.query_one("#tags-input", Input).value = ", ".join(tags)
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "continue-button":
+ self.run_worker(self._continue(), exclusive=True)
+
+ async def _continue(self) -> None:
+ error = self.query_one("#album-error", Static)
+ title = self.query_one("#title-input", Input).value.strip()
+ description = self.query_one("#description-input", Input).value.strip()
+ if not title or not description:
+ error.update("[red]Título y descripción son obligatorios.[/red]")
+ return
+ error.update("")
+
+ tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()]
+
+ body = await self.app.push_screen_wait(
+ EditorScreen(initial_text=self._existing_body, title=f'Cuerpo de "{title}"')
+ )
+ if body is None:
+ return
+
+ frontmatter = content.build_album_frontmatter(
+ title, description, tags, self._cover_image or "", dt=self._existing_date
+ )
+ self._existing_file.write_text(frontmatter + "\n" + body, encoding="utf-8")
+
+ pushed = await self.app.push_screen_wait(
+ ConfirmPushScreen(
+ [self._existing_file],
+ f'Actualiza álbum: "{title}"',
+ preview_markdown=f"# {title}\n\n{body}",
+ )
+ )
+ if pushed:
+ await self.app.push_screen_wait(SuccessScreen("¡Actualizado!"))
+ self.app.pop_screen()
blob - 8e5e51db427e37ae7ed7b4c2e937b93438e80bbb
blob + 0530ec9eed21f8484ac903f0647ebffaced46a96
--- nezzontli_ctl/screens/menu.py
+++ nezzontli_ctl/screens/menu.py
MENU_ITEMS = [
("post", "Nuevo post de blog"),
("page", "Nueva página en un proyecto existente"),
+ ("edit-post", "Editar un post o página existente"),
("album", "Nuevo álbum de fotos"),
("add-photos", "Agregar fotos a un álbum existente"),
+ ("edit-album", "Editar un álbum existente"),
("settings", "Configuración"),
("quit", "Salir"),
]
self.app.push_screen(AddPhotosScreen())
return
+ if key == "edit-post":
+ self.run_worker(self._pick_and_edit_post(), exclusive=True)
+ return
+ if key == "edit-album":
+ self.run_worker(self._pick_and_edit_album(), exclusive=True)
+ return
+
+ async def _pick_and_edit_post(self) -> None:
+ from nezzontli_ctl.screens.post import NewPostScreen, list_posts
+ from nezzontli_ctl.screens.select_existing import SelectExistingScreen
+
+ items = list_posts()
+ if not items:
+ self.notify("No hay posts todavía.")
+ return
+ chosen = await self.app.push_screen_wait(
+ SelectExistingScreen(items, title="Elegí un post para editar")
+ )
+ if chosen is not None:
+ self.app.push_screen(NewPostScreen(existing_file=chosen))
+
+ async def _pick_and_edit_album(self) -> None:
+ from nezzontli_ctl.screens.edit_album import EditAlbumScreen, list_albums
+ from nezzontli_ctl.screens.select_existing import SelectExistingScreen
+
+ items = list_albums()
+ if not items:
+ self.notify("No hay álbumes todavía.")
+ return
+ chosen = await self.app.push_screen_wait(
+ SelectExistingScreen(items, title="Elegí un álbum para editar")
+ )
+ if chosen is not None:
+ self.app.push_screen(EditAlbumScreen(existing_file=chosen))
blob - 51cee41e2bf7261fe4dcf7c1a8c1ba0e3cca852b
blob + 8d5958201c891f3c13b90dcb4a0cc28c6d066865
--- nezzontli_ctl/screens/post.py
+++ nezzontli_ctl/screens/post.py
from nezzontli_ctl.screens.success import SuccessScreen
+def list_posts():
+ """(label, Path) de cada content/blog/<slug>/index.md, para el picker de edición."""
+ if not BLOG_DIR.is_dir():
+ return []
+ items = []
+ for project_dir in sorted(BLOG_DIR.iterdir()):
+ index_file = project_dir / "index.md"
+ if not index_file.is_file():
+ continue
+ try:
+ data, _ = content.parse_frontmatter(index_file.read_text(encoding="utf-8"))
+ except Exception:
+ data = {}
+ title = data.get("title", project_dir.name)
+ items.append((f"{title} ({project_dir.name})", index_file))
+ return items
+
+
class NewPostScreen(Screen):
BINDINGS = [("escape", "app.pop_screen", "Volver")]
_slug_touched = False
+ _last_auto_slug = None
+ def __init__(self, existing_file=None):
+ super().__init__()
+ self._existing_file = existing_file
+ self._existing_body = ""
+ self._existing_date = None
+
def compose(self):
+ editing = self._existing_file is not None
with Vertical(classes="form-container"):
- yield Label("Nuevo post de blog", classes="form-label")
+ yield Label(
+ "Editar post" if editing else "Nuevo post de blog", classes="form-label"
+ )
yield Static("", id="post-error")
yield Label("Título")
yield Input(id="title-input")
- yield Label("Slug (carpeta)")
- yield Input(id="slug-input")
+ if not editing:
+ yield Label("Slug (carpeta)")
+ yield Input(id="slug-input")
yield Label("Descripción")
yield Input(id="description-input")
yield Label("Tags (separados por coma)")
yield Input(id="comments-user")
yield Label("ID del toot")
yield Input(id="comments-id")
- yield Button("Continuar →", id="continue-button", variant="primary")
+ yield Button(
+ "Guardar cambios →" if editing else "Continuar →",
+ id="continue-button",
+ variant="primary",
+ )
def on_mount(self) -> None:
self.query_one("#comments-fields").display = False
+ if self._existing_file is not None:
+ data, body = content.parse_frontmatter(
+ self._existing_file.read_text(encoding="utf-8")
+ )
+ self._existing_body = body
+ self._existing_date = data.get("date")
+ self.query_one("#title-input", Input).value = data.get("title", "")
+ self.query_one("#description-input", Input).value = data.get("description", "")
+ tags = data.get("taxonomies", {}).get("tags", [])
+ self.query_one("#tags-input", Input).value = ", ".join(tags)
+ extra = data.get("extra", {})
+ self.query_one("#katex-switch", Switch).value = bool(extra.get("katex", False))
+ comments = extra.get("comments")
+ if comments:
+ self.query_one("#comments-switch", Switch).value = True
+ self.query_one("#comments-fields").display = True
+ self.query_one("#comments-host", Input).value = comments.get("host", "masto.es")
+ self.query_one("#comments-user", Input).value = comments.get("user", "")
+ self.query_one("#comments-id", Input).value = comments.get("id", "")
+ self._slug_touched = True # no aplica al editar, no hay campo de slug
- _last_auto_slug = None
-
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "title-input" and not self._slug_touched:
self._last_auto_slug = content.slugify(event.value)
self.run_worker(self._continue(), exclusive=True)
async def _continue(self) -> None:
+ editing = self._existing_file is not None
title = self.query_one("#title-input", Input).value.strip()
description = self.query_one("#description-input", Input).value.strip()
- slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title)
error = self.query_one("#post-error", Static)
if not title or not description:
error.update("[red]Título y descripción son obligatorios.[/red]")
return
- target_dir = BLOG_DIR / slug
- if target_dir.exists():
- error.update(f"[red]content/blog/{slug}/ ya existe.[/red]")
- return
+
+ if editing:
+ target_file = self._existing_file
+ else:
+ slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title)
+ target_dir = BLOG_DIR / slug
+ if target_dir.exists():
+ error.update(f"[red]content/blog/{slug}/ ya existe.[/red]")
+ return
+ target_file = target_dir / "index.md"
error.update("")
tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()]
"id": self.query_one("#comments-id", Input).value.strip(),
}
+ initial_body = self._existing_body if editing else f"# {title}\n\n"
body = await self.app.push_screen_wait(
- EditorScreen(initial_text=f"# {title}\n\n", title=f"Cuerpo de \"{title}\"")
+ EditorScreen(initial_text=initial_body, title=f"Cuerpo de \"{title}\"")
)
if body is None:
return
frontmatter = content.build_post_frontmatter(
- title, description, tags, ["B.E. Alejandro"], katex=katex, comments=comments
+ title, description, tags, ["B.E. Alejandro"],
+ katex=katex, comments=comments, dt=self._existing_date,
)
- target_dir.mkdir(parents=True)
- target_file = target_dir / "index.md"
+ if not editing:
+ target_file.parent.mkdir(parents=True)
target_file.write_text(frontmatter + "\n" + body, encoding="utf-8")
+ message = f'Actualiza: "{title}"' if editing else f"Nuevo post: {title}"
pushed = await self.app.push_screen_wait(
ConfirmPushScreen(
- [target_dir],
- f"Nuevo post: {title}",
+ [target_file if editing else target_file.parent],
+ message,
preview_markdown=f"# {title}\n\n{body}",
)
)
if pushed:
- await self.app.push_screen_wait(SuccessScreen("¡Publicado!"))
+ await self.app.push_screen_wait(
+ SuccessScreen("¡Actualizado!" if editing else "¡Publicado!")
+ )
self.app.pop_screen()
blob - /dev/null
blob + a7c221c679c11bc5425c7e3e1747d16cef0caa96 (mode 644)
--- /dev/null
+++ nezzontli_ctl/screens/select_existing.py
+from textual.containers import Vertical
+from textual.screen import ModalScreen
+from textual.widgets import OptionList, Static
+from textual.widgets.option_list import Option
+
+
+class SelectExistingScreen(ModalScreen):
+ """Lista genérica de (label, valor) para elegir uno. Devuelve el valor
+ elegido (lo que sea — un Path, un string) o None si se cancela."""
+
+ BINDINGS = [("escape", "cancel", "Cancelar")]
+
+ def __init__(self, items, title="Elegí uno"):
+ super().__init__()
+ self._items = list(items)
+ self._title = title
+
+ def compose(self):
+ with Vertical(classes="form-container"):
+ yield Static(f" {self._title}")
+ yield OptionList(
+ *[Option(label, id=str(i)) for i, (label, _value) in enumerate(self._items)]
+ )
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ index = int(event.option.id)
+ self.dismiss(self._items[index][1])
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
blob - 2c97151cbe88cf7c9e1d0a32bf02b13e2d76d0ce
blob + 60f5416419df679aa787d2359d83a41de5734327
--- nezzontli_ctl/styles.tcss
+++ nezzontli_ctl/styles.tcss
width: 1fr;
height: 100%;
border: solid $panel;
+ overflow-y: auto;
}
#confirm-diff {