commit 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0 from: ale date: Thu Jul 30 22:37:21 2026 UTC Editar entradas existentes (post/página/álbum) + fixes de UI - screens/post.py: NewPostScreen acepta existing_file, precarga campos y cuerpo real, escribe sobre el mismo archivo en vez de crear uno nuevo (oculta el campo slug, no aplica al editar) - screens/edit_album.py: título/descripción/tags/cuerpo de un álbum existente (las fotos se siguen agregando por "Agregar fotos") - screens/select_existing.py: picker genérico reutilizado por ambos - menu.py: dos opciones nuevas ("Editar un post..." / "...álbum") - content.py: parse_frontmatter() (tomllib) para leer el TOML existente al editar Bugs encontrados al probar edición real: - La fecha se pisaba con la de hoy en cualquier edición (dt=None por default, nunca se pasaba la fecha original) — agregado _toml_date() que además tolera fechas guardadas como string entre comillas (pasó con un post tocado antes por Sveltia CMS; Zola lo acepta igual, tomllib lo lee como str en vez de date) - El preview de Markdown en EditorScreen no scrolleaba con la rueda del mouse: el widget Markdown no es scrolleable por si solo (extiende Widget, no ScrollableContainer). Envuelto en un VerticalScroll, y agregado sync proporcional real vía self.watch(text_area, "scroll_y", ...) — el "eco" de scroll_y no tiene el mismo problema de timing que tuvo el de slug porque acá se usa watch() en vez de comparar valores manualmente - Sacado el "— Ctrl+S para guardar, Esc para cancelar" del título del editor (ya está en el Footer de abajo, quedaba duplicado) commit - 415a550db68b43a152ad5b756f4b919ae67f3045 commit + 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0 blob - ad46fa519f0d0fc8b1b0b4a22b22a7d7add1d4ce blob + 14d0283655f3642f715371b3a4f3cabf2636525a --- nezzontli_ctl/content.py +++ nezzontli_ctl/content.py @@ -4,12 +4,23 @@ Migrado tal cual del ctl.py anterior (probado ya en es """ 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) @@ -24,13 +35,23 @@ def toml_list(values): 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]") @@ -49,13 +70,12 @@ def build_post_frontmatter(title, description, tags, a 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]") @@ -67,10 +87,9 @@ def build_page_frontmatter(title, description, tags, a 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 @@ -1,4 +1,4 @@ -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 @@ -19,7 +19,7 @@ class EditorScreen(ModalScreen[str]): 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, @@ -28,14 +28,25 @@ class EditorScreen(ModalScreen[str]): 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 @@ -0,0 +1,104 @@ +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//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 @@ -9,8 +9,10 @@ BANNER = pyfiglet.Figlet(font="small").renderText("nez 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"), ] @@ -57,3 +59,37 @@ class MenuScreen(Screen): 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 @@ -9,19 +9,48 @@ from nezzontli_ctl.screens.editor import EditorScreen from nezzontli_ctl.screens.success import SuccessScreen +def list_posts(): + """(label, Path) de cada content/blog//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)") @@ -39,13 +68,35 @@ class NewPostScreen(Screen): 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) @@ -62,18 +113,24 @@ class NewPostScreen(Screen): 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()] @@ -86,26 +143,31 @@ class NewPostScreen(Screen): "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 @@ -0,0 +1,30 @@ +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 @@ -46,6 +46,7 @@ width: 1fr; height: 100%; border: solid $panel; + overflow-y: auto; } #confirm-diff {