commit 67e37ea4e902fd4b618e5df04e97d18df742d5ab from: ale date: Fri Jul 31 00:41:38 2026 UTC Menú de dos niveles (Crear/Editar) + eliminar entradas con doble confirmación El menú principal pasaba de 4 a 9 opciones con post/page/album duplicados entre crear y editar — más difícil de escanear. Ahora es: Crear nuevo…, Editar existente…, Configuración, Salir; cada uno abre un submenú con las 3-4 opciones específicas y un '← Volver'. 'Agregar fotos a un álbum existente' se movió a Editar (opera sobre un álbum ya creado). Se agrega la opción de editar páginas extra de un proyecto (ej. content/blog/cortometraje/rama1.md) — antes solo se podía editar el index.md de cada proyecto, no las páginas extra generadas con 'page'. list_extra_pages() las lista, NewPageScreen ahora acepta existing_file igual que NewPostScreen (oculta proyecto/slug, precarga título/descripción/ tags/páginas relacionadas, preserva la fecha original). Se agrega un botón 'Eliminar' en los tres formularios de edición (post, página, álbum) con confirmación DOBLE (ConfirmDeleteScreen, mismo modal pregunta dos veces antes de devolver True) antes de borrar y pushear: - post: borra content/blog// completo (incluye sus páginas extra) - página: borra solo ese .md, no toca el resto del proyecto - álbum: borra content/photos// Y static/images// (las fotos en sí, para no dejar basura LFS huérfana) Probado de punta a punta contra un fixture git descartable (bare repo como origin) para los tres flujos de borrado: confirma que el path correcto se borra del disco, se commitea y se pushea al origin. commit - 418394b83f2ec0036a9953fffbf57be5c0ec36e4 commit + 67e37ea4e902fd4b618e5df04e97d18df742d5ab blob - 097a72e20e41acd456b02cc89e3597b744065c59 blob + e315514f4a5db03fa3d154b4651e66412a61237b --- nezzontli_ctl/screens/edit_album.py +++ nezzontli_ctl/screens/edit_album.py @@ -1,10 +1,13 @@ -from textual.containers import Vertical +import shutil + +from textual.containers import Horizontal, 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 import content, git_ops +from nezzontli_ctl.config import IMAGES_DIR, PHOTOS_DIR from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen from nezzontli_ctl.screens.editor import EditorScreen from nezzontli_ctl.screens.success import SuccessScreen @@ -54,7 +57,9 @@ class EditAlbumScreen(Screen): "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") + with Horizontal(classes="form-row"): + yield Button("Guardar cambios →", id="continue-button", variant="primary") + yield Button("Eliminar", id="delete-button", variant="error") def on_mount(self) -> None: data, body = content.parse_frontmatter(self._existing_file.read_text(encoding="utf-8")) @@ -69,7 +74,34 @@ class EditAlbumScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "continue-button": self.run_worker(self._continue(), exclusive=True) + elif event.button.id == "delete-button": + self.run_worker(self._delete(), exclusive=True) + async def _delete(self) -> None: + album_dir = self._existing_file.parent + slug = album_dir.name + title = self.query_one("#title-input", Input).value.strip() or slug + images_dir = IMAGES_DIR / slug + confirmed = await self.app.push_screen_wait( + ConfirmDeleteScreen( + f'el álbum "{title}" (content/photos/{slug}/ y static/images/{slug}/, fotos incluidas)' + ) + ) + if not confirmed: + return + shutil.rmtree(album_dir) + paths = [album_dir] + if images_dir.is_dir(): + shutil.rmtree(images_dir) + paths.append(images_dir) + git_ops.stage(paths) + ok, output = git_ops.commit_and_push(f'Elimina álbum: "{title}"') + if not ok: + self.query_one("#album-error", Static).update(f"[red]Error al pushear:[/red]\n{output}") + return + await self.app.push_screen_wait(SuccessScreen("¡Eliminado!")) + self.app.pop_screen() + async def _continue(self) -> None: error = self.query_one("#album-error", Static) title = self.query_one("#title-input", Input).value.strip() blob - /dev/null blob + b4b48a7a0b888e290e222d1283530539c707bacf (mode 644) --- /dev/null +++ nezzontli_ctl/screens/confirm_delete.py @@ -0,0 +1,44 @@ +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static + + +class ConfirmDeleteScreen(ModalScreen[bool]): + """Pide confirmar la eliminación DOS VECES (dos preguntas seguidas en el + mismo modal) antes de devolver True. False si se cancela en cualquiera + de las dos.""" + + BINDINGS = [("escape", "cancel", "Cancelar")] + + _MESSAGES = [ + "¿Seguro que querés eliminar esto?", + "¿Estás completamente seguro? Esta acción no se puede deshacer.", + ] + + def __init__(self, what: str): + super().__init__() + self._what = what + self._step = 0 + + def compose(self): + with Vertical(classes="form-container"): + yield Label("Eliminar", classes="form-label") + yield Static(f"Se va a eliminar: {self._what}", id="delete-target") + yield Static(self._MESSAGES[0], id="delete-message") + with Horizontal(): + yield Button("Sí, eliminar", id="delete-yes", variant="error") + yield Button("Cancelar", id="delete-no") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id != "delete-yes": + self.dismiss(False) + return + if self._step == 0: + self._step = 1 + self.query_one("#delete-message", Static).update(self._MESSAGES[1]) + self.query_one("#delete-yes", Button).label = "Sí, eliminar definitivamente" + return + self.dismiss(True) + + def action_cancel(self) -> None: + self.dismiss(False) blob - 0530ec9eed21f8484ac903f0647ebffaced46a96 blob + 74523264276e087ffbaaf6b6ac444a307dc4be25 --- nezzontli_ctl/screens/menu.py +++ nezzontli_ctl/screens/menu.py @@ -7,12 +7,8 @@ from textual.widgets.option_list import Option BANNER = pyfiglet.Figlet(font="small").renderText("nezzontli") 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"), + ("create", "Crear nuevo…"), + ("edit", "Editar existente…"), ("settings", "Configuración"), ("quit", "Salir"), ] @@ -39,6 +35,40 @@ class MenuScreen(Screen): self.app.push_screen(SettingsScreen()) return + if key == "create": + self.app.push_screen(CreateMenuScreen()) + return + if key == "edit": + self.app.push_screen(EditMenuScreen()) + return + + +CREATE_MENU_ITEMS = [ + ("post", "Nuevo post de blog"), + ("page", "Nueva página en un proyecto existente"), + ("album", "Nuevo álbum de fotos"), + ("back", "← Volver"), +] + + +class CreateMenuScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + def compose(self): + with Vertical(id="menu-container"): + yield Static(BANNER, id="banner") + yield Static("crear nuevo", id="subtitle") + yield OptionList( + *[Option(label, id=key) for key, label in CREATE_MENU_ITEMS], + id="menu-options", + ) + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + key = event.option.id + if key == "back": + self.app.pop_screen() + return if key == "post": from nezzontli_ctl.screens.post import NewPostScreen @@ -54,6 +84,35 @@ class MenuScreen(Screen): self.app.push_screen(NewAlbumScreen()) return + + +EDIT_MENU_ITEMS = [ + ("edit-post", "Post de blog"), + ("edit-page", "Página extra de un proyecto"), + ("edit-album", "Álbum de fotos"), + ("add-photos", "Agregar fotos a un álbum existente"), + ("back", "← Volver"), +] + + +class EditMenuScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + def compose(self): + with Vertical(id="menu-container"): + yield Static(BANNER, id="banner") + yield Static("editar existente", id="subtitle") + yield OptionList( + *[Option(label, id=key) for key, label in EDIT_MENU_ITEMS], + id="menu-options", + ) + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + key = event.option.id + if key == "back": + self.app.pop_screen() + return if key == "add-photos": from nezzontli_ctl.screens.add_photos import AddPhotosScreen @@ -62,6 +121,9 @@ class MenuScreen(Screen): if key == "edit-post": self.run_worker(self._pick_and_edit_post(), exclusive=True) return + if key == "edit-page": + self.run_worker(self._pick_and_edit_page(), exclusive=True) + return if key == "edit-album": self.run_worker(self._pick_and_edit_album(), exclusive=True) return @@ -80,6 +142,20 @@ class MenuScreen(Screen): if chosen is not None: self.app.push_screen(NewPostScreen(existing_file=chosen)) + async def _pick_and_edit_page(self) -> None: + from nezzontli_ctl.screens.page import NewPageScreen, list_extra_pages + from nezzontli_ctl.screens.select_existing import SelectExistingScreen + + items = list_extra_pages() + if not items: + self.notify("No hay páginas extra todavía.") + return + chosen = await self.app.push_screen_wait( + SelectExistingScreen(items, title="Elegí una página para editar") + ) + if chosen is not None: + self.app.push_screen(NewPageScreen(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 blob - 270f8d92b763d235d0362cd824cf0ed3cf95a210 blob + ce855ff773a42908f87019417993cdd69ef3c232 --- nezzontli_ctl/screens/page.py +++ nezzontli_ctl/screens/page.py @@ -1,10 +1,11 @@ -from textual.containers import Vertical +from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, Input, Label, Select, SelectionList, Static -from nezzontli_ctl import content +from nezzontli_ctl import content, git_ops from nezzontli_ctl.config import BLOG_DIR from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen from nezzontli_ctl.screens.editor import EditorScreen from nezzontli_ctl.screens.success import SuccessScreen @@ -17,36 +18,116 @@ def _list_projects(): ) +def list_extra_pages(): + """(label, Path) de cada content/blog//.md que NO es + index.md — las páginas extra de un proyecto (ramas, capítulos, etc.), + para el picker de edición.""" + if not BLOG_DIR.is_dir(): + return [] + items = [] + for project_dir in sorted(BLOG_DIR.iterdir()): + if not project_dir.is_dir(): + continue + for page_file in sorted(project_dir.glob("*.md")): + if page_file.stem == "index": + continue + try: + data, _ = content.parse_frontmatter(page_file.read_text(encoding="utf-8")) + except Exception: + data = {} + title = data.get("title", page_file.stem) + items.append((f"{title} ({project_dir.name}/{page_file.name})", page_file)) + return items + + class NewPageScreen(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 projects = _list_projects() with Vertical(classes="form-container"): - yield Label("Nueva página en un proyecto existente", classes="form-label") + yield Label( + "Editar página" if editing else "Nueva página en un proyecto existente", + classes="form-label", + ) yield Static("", id="page-error") - yield Label("Proyecto") - yield Select( - [(p, p) for p in projects], id="project-select", allow_blank=True, prompt="Elegí un proyecto" - ) + if editing: + yield Label("Proyecto") + yield Static(self._existing_file.parent.name, id="project-static") + else: + yield Label("Proyecto") + yield Select( + [(p, p) for p in projects], + id="project-select", + allow_blank=True, + prompt="Elegí un proyecto", + ) yield Label("Título") yield Input(id="title-input") - yield Label("Slug (nombre de archivo)") - yield Input(id="slug-input") + if not editing: + yield Label("Slug (nombre de archivo)") + yield Input(id="slug-input") yield Label("Descripción") yield Input(id="description-input") yield Label("Tags (separados por coma)") yield Input(id="tags-input") yield Label("Páginas relacionadas (opcional)", id="related-label") yield SelectionList(id="related-list") - yield Button("Continuar →", id="continue-button", variant="primary") + with Horizontal(classes="form-row"): + yield Button( + "Guardar cambios →" if editing else "Continuar →", + id="continue-button", + variant="primary", + ) + if editing: + yield Button("Eliminar", id="delete-button", variant="error") def on_mount(self) -> None: - self.query_one("#related-label").display = False - self.query_one("#related-list").display = False + related_list = self.query_one("#related-list", SelectionList) + if self._existing_file is None: + self.query_one("#related-label").display = False + related_list.display = False + return + 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) + self._slug_touched = True # no aplica al editar, no hay campo de slug + + related_saved = data.get("extra", {}).get("related_pages", []) + siblings = sorted( + p.stem + for p in self._existing_file.parent.glob("*.md") + if p.stem not in ("index", self._existing_file.stem) + ) + if siblings: + for name in siblings: + related_list.add_option((name, name)) + for name in related_saved: + if name in siblings: + related_list.select(name) + self.query_one("#related-label").display = True + related_list.display = True + else: + self.query_one("#related-label").display = False + related_list.display = False + def on_select_changed(self, event: Select.Changed) -> None: if event.select.id != "project-select": return @@ -67,8 +148,6 @@ class NewPageScreen(Screen): self.query_one("#related-label").display = False related_list.display = False - _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) @@ -79,48 +158,77 @@ class NewPageScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "continue-button": self.run_worker(self._continue(), exclusive=True) + elif event.button.id == "delete-button": + self.run_worker(self._delete(), exclusive=True) + async def _delete(self) -> None: + project = self._existing_file.parent.name + title = self.query_one("#title-input", Input).value.strip() or self._existing_file.stem + confirmed = await self.app.push_screen_wait( + ConfirmDeleteScreen(f'la página "{title}" ({project}/{self._existing_file.name})') + ) + if not confirmed: + return + self._existing_file.unlink() + git_ops.stage([self._existing_file]) + ok, output = git_ops.commit_and_push(f'{project}: elimina página "{title}"') + if not ok: + self.query_one("#page-error", Static).update(f"[red]Error al pushear:[/red]\n{output}") + return + await self.app.push_screen_wait(SuccessScreen("¡Eliminado!")) + self.app.pop_screen() + async def _continue(self) -> None: + editing = self._existing_file is not None error = self.query_one("#page-error", Static) - project = self.query_one("#project-select", Select).value 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) - if not project or project is Select.BLANK: - error.update("[red]Elegí un proyecto.[/red]") - return + if editing: + project = self._existing_file.parent.name + target_file = self._existing_file + else: + project = self.query_one("#project-select", Select).value + if not project or project is Select.BLANK: + error.update("[red]Elegí un proyecto.[/red]") + return + slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title) + target_file = BLOG_DIR / project / f"{slug}.md" + if target_file.exists(): + error.update(f"[red]content/blog/{project}/{slug}.md ya existe.[/red]") + return if not title or not description: error.update("[red]Título y descripción son obligatorios.[/red]") return - target_file = BLOG_DIR / project / f"{slug}.md" - if target_file.exists(): - error.update(f"[red]content/blog/{project}/{slug}.md ya existe.[/red]") - return error.update("") tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()] related_list = self.query_one("#related-list", SelectionList) related = list(related_list.selected) if related_list.display else [] + 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_page_frontmatter( - title, description, tags, ["B.E. Alejandro"], related=related + title, description, tags, ["B.E. Alejandro"], + related=related, dt=self._existing_date, ) target_file.write_text(frontmatter + "\n" + body, encoding="utf-8") + message = f'{project}: actualiza "{title}"' if editing else f'{project}: nueva página "{title}"' pushed = await self.app.push_screen_wait( ConfirmPushScreen( [target_file], - f'{project}: nueva página "{title}"', + 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 - 8d5958201c891f3c13b90dcb4a0cc28c6d066865 blob + a3bcac9feb9c939f3a49d53684643596593f92b4 --- nezzontli_ctl/screens/post.py +++ nezzontli_ctl/screens/post.py @@ -1,10 +1,13 @@ +import shutil + from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, Input, Label, Static, Switch -from nezzontli_ctl import content +from nezzontli_ctl import content, git_ops from nezzontli_ctl.config import BLOG_DIR from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen from nezzontli_ctl.screens.editor import EditorScreen from nezzontli_ctl.screens.success import SuccessScreen @@ -68,11 +71,14 @@ class NewPostScreen(Screen): yield Input(id="comments-user") yield Label("ID del toot") yield Input(id="comments-id") - yield Button( - "Guardar cambios →" if editing else "Continuar →", - id="continue-button", - variant="primary", - ) + with Horizontal(classes="form-row"): + yield Button( + "Guardar cambios →" if editing else "Continuar →", + id="continue-button", + variant="primary", + ) + if editing: + yield Button("Eliminar", id="delete-button", variant="error") def on_mount(self) -> None: self.query_one("#comments-fields").display = False @@ -111,7 +117,26 @@ class NewPostScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "continue-button": self.run_worker(self._continue(), exclusive=True) + elif event.button.id == "delete-button": + self.run_worker(self._delete(), exclusive=True) + async def _delete(self) -> None: + project_dir = self._existing_file.parent + title = self.query_one("#title-input", Input).value.strip() or project_dir.name + confirmed = await self.app.push_screen_wait( + ConfirmDeleteScreen(f'el post "{title}" (content/blog/{project_dir.name}/ completo)') + ) + if not confirmed: + return + shutil.rmtree(project_dir) + git_ops.stage([project_dir]) + ok, output = git_ops.commit_and_push(f'Elimina post: "{title}"') + if not ok: + self.query_one("#post-error", Static).update(f"[red]Error al pushear:[/red]\n{output}") + return + await self.app.push_screen_wait(SuccessScreen("¡Eliminado!")) + self.app.pop_screen() + async def _continue(self) -> None: editing = self._existing_file is not None title = self.query_one("#title-input", Input).value.strip()