From 737700a9a8f9effcb820edc9f6e1e5dff2cf3521 Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sun, 1 Nov 2026 20:46:34 -0500 Subject: [PATCH] Replace content CLI with Textual TUI --- .gitignore | 4 + README.md | 37 +++-- package.json | 3 +- tools/content/app.py | 110 +++++++++++++ tools/content/books.py | 136 ++++++++++++++++ tools/content/cli.py | 27 ---- tools/content/fields.py | 147 +++++++++++++++++ tools/content/media.py | 111 +++++-------- tools/content/music.py | 80 ++++----- tools/content/portfolio.py | 204 +++++++++++++++++++++++ tools/content/requirements.txt | 1 + tools/content/site_themes.py | 87 ++++++++++ tools/content/static_images.py | 30 ++++ tools/content/tui/__init__.py | 0 tools/content/tui/app.tcss | 252 +++++++++++++++++++++++++++++ tools/content/tui/books_pane.py | 162 +++++++++++++++++++ tools/content/tui/content_pane.py | 65 ++++++++ tools/content/tui/dialogs.py | 106 ++++++++++++ tools/content/tui/image_picker.py | 68 ++++++++ tools/content/tui/media_pane.py | 95 +++++++++++ tools/content/tui/music_pane.py | 144 +++++++++++++++++ tools/content/tui/profile_pane.py | 73 +++++++++ tools/content/tui/projects_pane.py | 30 ++++ tools/content/tui/record_editor.py | 140 ++++++++++++++++ tools/content/tui/record_pane.py | 155 ++++++++++++++++++ tools/content/tui/themes.py | 57 +++++++ tools/content/tui/themes_pane.py | 174 ++++++++++++++++++++ tools/content/youtube.py | 72 ++++++++- 28 files changed, 2403 insertions(+), 167 deletions(-) create mode 100644 tools/content/app.py create mode 100644 tools/content/books.py delete mode 100644 tools/content/cli.py create mode 100644 tools/content/fields.py create mode 100644 tools/content/portfolio.py create mode 100644 tools/content/requirements.txt create mode 100644 tools/content/site_themes.py create mode 100644 tools/content/static_images.py create mode 100644 tools/content/tui/__init__.py create mode 100644 tools/content/tui/app.tcss create mode 100644 tools/content/tui/books_pane.py create mode 100644 tools/content/tui/content_pane.py create mode 100644 tools/content/tui/dialogs.py create mode 100644 tools/content/tui/image_picker.py create mode 100644 tools/content/tui/media_pane.py create mode 100644 tools/content/tui/music_pane.py create mode 100644 tools/content/tui/profile_pane.py create mode 100644 tools/content/tui/projects_pane.py create mode 100644 tools/content/tui/record_editor.py create mode 100644 tools/content/tui/record_pane.py create mode 100644 tools/content/tui/themes.py create mode 100644 tools/content/tui/themes_pane.py diff --git a/.gitignore b/.gitignore index 896d20c..eda77b5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ data/ Update.md PROJECT.md blogs/ + +# Content tool +tools/content/.venv/ +__pycache__/ diff --git a/README.md b/README.md index f2c8611..e5a781b 100644 --- a/README.md +++ b/README.md @@ -102,22 +102,33 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional Site content lives in `content/`. -### Content CLI +### Content editor -A small Python tool (standard library only) edits the playlist, shows and movies without touching the JSON by hand. +A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes. ```bash -bun run content music list --titles -bun run content music add https://www.youtube.com/watch?v=VIDEO_ID -bun run content music remove VIDEO_ID -bun run content movie add tt0816692 --rating 10 -bun run content show add https://www.imdb.com/title/tt14688458/ --rating 10 --name "Silo" -bun run content show rate Silo 9 -bun run content movie remove Interstellar -bun run content show list +bun run content:setup +bun run content ``` -Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to skip the lookup. Run `bun run content --help` for every option. +`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual. Run it once. + +| Key | Action | +| --- | --- | +| `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes | +| `a` | Add (YouTube link, IMDb link or Goodreads link) | +| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies and books) | +| `f` | Toggle featured on a project | +| `p` | Preview a theme in the editor | +| `t` | Switch the editor to the next theme | +| `s` | Move a book to another shelf, or make a theme the site default | +| `d` | Delete | +| `K` / `J` | Move a song or entry up or down | +| `u` / `U` | Refresh the selected book, or every book, from Goodreads | +| `r` | Reload from disk | +| `q` | Quit | + +Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; fill in the name field to skip the lookup. Books are scraped from Goodreads once when added and saved to `books.json`. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py `. | File | Holds | | --- | --- | @@ -126,5 +137,7 @@ Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to s | `experience.json` / `leadership.json` | Work experience and leadership roles | | `projects/*.json` | Open source, packages, hackathons, research groups and organizations | | `music.json` | YouTube links for the music player | -| `desktop.json` | Workspaces, desktop icons and the startup command | +| `books.json` | Books scraped from Goodreads for the about page | +| `desktop.json` | Workspaces, desktop icons, the startup command and the default theme | +| `themes.json` | Color themes for the site and the content editor | | `blog/*.md` | Blog posts | diff --git a/package.json b/package.json index eeaee7d..97ebe93 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "start": "bun --bun vite build && bun ./build/index.js", - "content": "python3 tools/content/cli.py", + "content": "tools/content/.venv/bin/python tools/content/app.py", + "content:setup": "python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" diff --git a/tools/content/app.py b/tools/content/app.py new file mode 100644 index 0000000..41ae9db --- /dev/null +++ b/tools/content/app.py @@ -0,0 +1,110 @@ +import sys + +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane + +import portfolio +from tui.books_pane import BooksPane +from tui.media_pane import MediaPane +from tui.music_pane import MusicPane +from tui.profile_pane import ProfilePane +from tui.projects_pane import ProjectsPane +from tui.record_pane import RecordListPane +from tui.themes import default_theme_id, load_site_themes +from tui.themes_pane import ThemesPane + +TABS = [ + ("music", "Music"), + ("shows", "Shows"), + ("movies", "Movies"), + ("books", "Books"), + ("profile", "Profile"), + ("experience", "Experience"), + ("leadership", "Leadership"), + ("projects", "Projects"), + ("themes", "Themes"), +] + + +class ContentApp(App): + TITLE = "sirblob@engineering" + SUB_TITLE = "~/content" + CSS_PATH = "tui/app.tcss" + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("t", "next_theme", "Theme"), + *[Binding(str(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)], + ] + + def __init__(self): + super().__init__() + self.site_theme_ids: list[str] = [] + self.reload_site_themes(default_theme_id()) + + def reload_site_themes(self, show_theme_id: str | None = None): + self.site_theme_ids = [] + for theme in load_site_themes(): + self.register_theme(theme) + self.site_theme_ids.append(theme.name) + if not self.site_theme_ids: + return + target = show_theme_id if show_theme_id in self.site_theme_ids else self.site_theme_ids[0] + if target == self.theme: + fallback = next(theme for theme in self.available_themes if theme != target) + self.theme = fallback + self.theme = target + + def action_next_theme(self): + if not self.site_theme_ids: + return + current = self.site_theme_ids.index(self.theme) if self.theme in self.site_theme_ids else -1 + self.theme = self.site_theme_ids[(current + 1) % len(self.site_theme_ids)] + self.notify(f"Theme: {self.theme}") + + def compose(self) -> ComposeResult: + yield Header() + with TabbedContent(initial="music"): + with TabPane("1 Music", id="music"): + yield MusicPane() + with TabPane("2 Shows", id="shows"): + yield MediaPane("show") + with TabPane("3 Movies", id="movies"): + yield MediaPane("movie") + with TabPane("4 Books", id="books"): + yield BooksPane() + with TabPane("5 Profile", id="profile"): + yield ProfilePane() + with TabPane("6 Experience", id="experience"): + yield RecordListPane(portfolio.EXPERIENCE) + with TabPane("7 Leadership", id="leadership"): + yield RecordListPane(portfolio.LEADERSHIP) + with TabPane("8 Projects", id="projects"): + yield ProjectsPane() + with TabPane("9 Themes", id="themes"): + yield ThemesPane() + yield Footer() + + def on_mount(self): + self.focus_active_table() + + def action_show_tab(self, tab_id: str): + self.query_one(TabbedContent).active = tab_id + + @on(TabbedContent.TabActivated) + def tab_changed(self): + self.focus_active_table() + + def focus_active_table(self): + tabs = self.query_one(TabbedContent) + tabs.get_pane(tabs.active).query_one(DataTable).focus() + + +def main() -> int: + ContentApp().run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/content/books.py b/tools/content/books.py new file mode 100644 index 0000000..e210d96 --- /dev/null +++ b/tools/content/books.py @@ -0,0 +1,136 @@ +import datetime + +import goodreads +import jsonfile + +BOOKS_FILE = "books.json" +SHELVES = ["read", "reading", "want-to-read"] +DESCRIPTION_MAX_LENGTH = 600 +GENRE_LIMIT = 5 + + +def check_rating(rating: int) -> int: + if not 0 <= rating <= 10: + raise ValueError("Rating must be between 0 and 10") + return rating + + +def check_shelf(shelf: str) -> str: + if shelf not in SHELVES: + raise ValueError(f"Shelf must be one of {', '.join(SHELVES)}") + return shelf + + +def load_books() -> list[dict]: + if not jsonfile.content_path(BOOKS_FILE).exists(): + return [] + return jsonfile.load(BOOKS_FILE) + + +def save_books(books: list[dict]) -> None: + jsonfile.save(BOOKS_FILE, books) + + +def find_index(books: list[dict], text: str) -> int | None: + identifier = goodreads.book_id(text) or (text if text.isdigit() else None) + for index, book in enumerate(books): + if identifier and book.get("goodreadsId") == identifier: + return index + if book.get("title", "").lower() == text.lower(): + return index + return None + + +def short_description(text: str | None) -> str | None: + if not text or len(text) <= DESCRIPTION_MAX_LENGTH: + return text + return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..." + + +def stored_fields(scraped: dict) -> dict: + return { + "goodreadsId": scraped["goodreadsId"], + "url": scraped["url"], + "title": scraped["title"], + "authors": scraped["authors"], + "cover": scraped["cover"], + "description": short_description(scraped["description"]), + "pages": scraped["pages"], + "published": scraped["published"], + "genres": scraped["genres"][:GENRE_LIMIT], + "averageRating": scraped["averageRating"], + "ratingCount": scraped["ratingCount"], + } + + +def update_book(text: str, change) -> dict: + books = load_books() + index = find_index(books, text) + if index is None: + raise ValueError(f"{text} is not listed") + books[index] = change(books[index]) + save_books(books) + return books[index] + + +def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict: + check_shelf(shelf) + if rating is not None: + check_rating(rating) + identifier = goodreads.book_id(url) + if not identifier: + raise ValueError(f"{url} is not a Goodreads book link") + if find_index(load_books(), identifier) is not None: + raise ValueError(f"{identifier} is already listed") + + scraped = goodreads.scrape_book(url) + if not scraped["title"]: + raise ValueError("Could not read the book from Goodreads") + + book = stored_fields(scraped) + book["shelf"] = shelf + if rating is not None: + book["rating"] = rating + book["added"] = datetime.date.today().isoformat() + + books = load_books() + books.append(book) + save_books(books) + return book + + +def remove_book(text: str) -> dict: + books = load_books() + index = find_index(books, text) + if index is None: + raise ValueError(f"{text} is not listed") + removed = books.pop(index) + save_books(books) + return removed + + +def rate_book(text: str, rating: int | None) -> dict: + if rating is not None: + check_rating(rating) + + def change(book: dict) -> dict: + updated = {key: value for key, value in book.items() if key != "rating"} + if rating is not None: + updated["rating"] = rating + return updated + + return update_book(text, change) + + +def move_book(text: str, shelf: str) -> dict: + check_shelf(shelf) + return update_book(text, lambda book: {**book, "shelf": shelf}) + + +def refresh_book(text: str) -> dict: + books = load_books() + index = find_index(books, text) + if index is None: + raise ValueError(f"{text} is not listed") + scraped = goodreads.scrape_book(books[index]["url"]) + return update_book(text, lambda book: {**book, **stored_fields(scraped)}) diff --git a/tools/content/cli.py b/tools/content/cli.py deleted file mode 100644 index 90df527..0000000 --- a/tools/content/cli.py +++ /dev/null @@ -1,27 +0,0 @@ -import argparse -import sys - -import media -import music - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="content", description="Edit the website content in content/") - subparsers = parser.add_subparsers(dest="section", required=True) - music.register(subparsers) - media.register(subparsers) - return parser - - -def main() -> int: - arguments = build_parser().parse_args() - try: - arguments.handler(arguments) - except (OSError, ValueError) as error: - print(f"Error: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/content/fields.py b/tools/content/fields.py new file mode 100644 index 0000000..c5c9569 --- /dev/null +++ b/tools/content/fields.py @@ -0,0 +1,147 @@ +from dataclasses import dataclass, field + +SEPARATOR = "|" + +FIELD_HINTS = { + "lines": "one per line", + "tags": "comma separated", + "images": "one image path per line", + "link_map": "one per line: name url", + "link_list": "one per line: label | url", + "awards": "one per line: track | place", + "socials": "one per line: name | icon | url", + "skill_map": "one per line: Category: skill, skill", +} +MULTILINE_KINDS = {"longtext", "lines", "images", "link_map", "link_list", "awards", "socials", "skill_map"} + + +@dataclass +class FieldSpec: + path: str + label: str + kind: str = "text" + required: bool = False + options: list[str] = field(default_factory=list) + + @property + def hint(self) -> str: + return FIELD_HINTS.get(self.kind, "") + + +def get_path(record: dict, path: str): + value = record + for part in path.split("."): + if not isinstance(value, dict): + return None + value = value.get(part) + return value + + +def is_empty(value) -> bool: + return value is None or value is False or value == "" or value == [] or value == {} + + +def set_path(record: dict, path: str, value) -> None: + parts = path.split(".") + parents = [record] + for part in parts[:-1]: + child = parents[-1].get(part) + if not isinstance(child, dict): + child = {} + parents[-1][part] = child + parents.append(child) + + if is_empty(value): + parents[-1].pop(parts[-1], None) + else: + parents[-1][parts[-1]] = value + + for depth in range(len(parts) - 1, 0, -1): + if not parents[depth]: + parents[depth - 1].pop(parts[depth - 1], None) + + +def split_parts(line: str, count: int) -> list[str]: + parts = [part.strip() for part in line.split(SEPARATOR, count - 1)] + return parts + [""] * (count - len(parts)) + + +def non_empty_lines(text: str) -> list[str]: + return [line.strip() for line in text.splitlines() if line.strip()] + + +def to_form_text(kind: str, value) -> str: + if value is None: + return "" + if kind in ("lines", "images"): + return "\n".join(value) + if kind == "tags": + return ", ".join(value) + if kind == "link_map": + return "\n".join(f"{name} {url}" for name, url in value.items()) + if kind == "link_list": + return "\n".join(f"{link.get('label', '')} {SEPARATOR} {link.get('url', '')}" for link in value) + if kind == "awards": + return "\n".join(f"{award.get('track', '')} {SEPARATOR} {award.get('place', '')}" for award in value) + if kind == "socials": + return "\n".join(f"{social.get('name', '')} {SEPARATOR} {social.get('icon', '')} {SEPARATOR} {social.get('url', '')}" for social in value) + if kind == "skill_map": + return "\n".join(f"{category}: {', '.join(skills)}" for category, skills in value.items()) + return str(value) + + +def from_form_text(kind: str, text: str): + text = text.strip() + if kind in ("lines", "images"): + return non_empty_lines(text) + if kind == "tags": + return [tag.strip() for tag in text.split(",") if tag.strip()] + if kind == "link_map": + links = {} + for line in non_empty_lines(text): + name, _, url = line.partition(" ") + if not url.strip(): + raise ValueError(f"Link line needs a name and a url: {line}") + links[name.strip()] = url.strip() + return links + if kind == "link_list": + links = [] + for line in non_empty_lines(text): + label, url = split_parts(line, 2) + if not url: + raise ValueError(f"Link line needs label | url: {line}") + links.append({"label": label, "url": url}) + return links + if kind == "awards": + awards = [] + for line in non_empty_lines(text): + track, place = split_parts(line, 2) + awards.append({"track": track, "place": place} if place else {"track": track}) + return awards + if kind == "socials": + socials = [] + for line in non_empty_lines(text): + name, icon, url = split_parts(line, 3) + if not url: + raise ValueError(f"Social line needs name | icon | url: {line}") + socials.append({"name": name, "icon": icon, "url": url}) + return socials + if kind == "skill_map": + skills = {} + for line in non_empty_lines(text): + category, _, values = line.partition(":") + if not values.strip(): + raise ValueError(f"Skill line needs Category: skill, skill: {line}") + skills[category.strip()] = [skill.strip() for skill in values.split(",") if skill.strip()] + return skills + return text + + +def summary_text(value) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "yes" if value else "" + if isinstance(value, list): + return ", ".join(str(item) for item in value if not isinstance(item, dict)) + return str(value) diff --git a/tools/content/media.py b/tools/content/media.py index b4aa208..cf58c4e 100644 --- a/tools/content/media.py +++ b/tools/content/media.py @@ -4,15 +4,26 @@ import jsonfile ABOUT_FILE = "about.json" MEDIA_KINDS = { "show": {"key": "shows", "label": "TV show", "omdb_type": "series"}, - "movie": {"key": "movies", "label": "movie", "omdb_type": "movie"} + "movie": {"key": "movies", "label": "movie", "omdb_type": "movie"}, } +def check_rating(rating: int) -> int: + if not 0 <= rating <= 10: + raise ValueError("Rating must be between 0 and 10") + return rating + + def load_items(kind: str) -> tuple[dict, list[dict]]: data = jsonfile.load(ABOUT_FILE) return data, data.setdefault(MEDIA_KINDS[kind]["key"], []) +def list_items(kind: str) -> list[dict]: + _, items = load_items(kind) + return sorted(items, key=lambda item: -item.get("rating", 0)) + + def find_index(items: list[dict], text: str) -> int | None: identifier = imdb.imdb_id(text) for index, item in enumerate(items): @@ -23,93 +34,43 @@ def find_index(items: list[dict], text: str) -> int | None: return None -def valid_rating(value: str) -> int: - rating = int(value) - if not 0 <= rating <= 10: - raise ValueError("Rating must be between 0 and 10") - return rating - - -def list_items(arguments) -> None: - _, items = load_items(arguments.kind) - if not items: - print(f"No {MEDIA_KINDS[arguments.kind]['label']}s yet.") - return - for item in sorted(items, key=lambda entry: -entry.get("rating", 0)): - print(f"{item.get('rating', '?'):>3}/10 {item.get('name')} {item.get('url')}") - - -def resolve_name(arguments, identifier: str) -> str | None: - if arguments.name: - return arguments.name +def look_up_name(kind: str, identifier: str) -> str: details = imdb.lookup(identifier) if not details: - print("Could not look up the title. Pass --name, or set OMDB_API_KEY in .env.") - return None - expected_type = MEDIA_KINDS[arguments.kind]["omdb_type"] - if details.get("Type") != expected_type: - print(f"Warning: OMDb lists {details.get('Title')} as a {details.get('Type')}, not a {expected_type}.") - return details.get("Title") + raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env") + return details.get("Title") or identifier -def add_item(arguments) -> None: - identifier = imdb.imdb_id(arguments.title) +def add_item(kind: str, text: str, rating: int, name: str | None = None) -> dict: + identifier = imdb.imdb_id(text) if not identifier: - print(f"{arguments.title} is not an IMDb link or ID.") - return - data, items = load_items(arguments.kind) + raise ValueError(f"{text} is not an IMDb link or ID") + check_rating(rating) + data, items = load_items(kind) if find_index(items, identifier) is not None: - print(f"{identifier} is already listed. Use rate to change its rating.") - return - name = resolve_name(arguments, identifier) - if not name: - return - items.append({"name": name, "rating": arguments.rating, "url": imdb.title_url(identifier)}) + raise ValueError(f"{identifier} is already listed") + item = {"name": name or look_up_name(kind, identifier), "rating": rating, "url": imdb.title_url(identifier)} + items.append(item) jsonfile.save(ABOUT_FILE, data) - print(f"Added {name} ({arguments.rating}/10)") + return item -def remove_item(arguments) -> None: - data, items = load_items(arguments.kind) - index = find_index(items, arguments.title) +def remove_item(kind: str, text: str) -> dict: + data, items = load_items(kind) + index = find_index(items, text) if index is None: - print(f"{arguments.title} is not listed.") - return + raise ValueError(f"{text} is not listed") removed = items.pop(index) jsonfile.save(ABOUT_FILE, data) - print(f"Removed {removed.get('name')}") + return removed -def rate_item(arguments) -> None: - data, items = load_items(arguments.kind) - index = find_index(items, arguments.title) +def rate_item(kind: str, text: str, rating: int) -> dict: + check_rating(rating) + data, items = load_items(kind) + index = find_index(items, text) if index is None: - print(f"{arguments.title} is not listed.") - return - items[index]["rating"] = arguments.rating + raise ValueError(f"{text} is not listed") + items[index]["rating"] = rating jsonfile.save(ABOUT_FILE, data) - print(f"Rated {items[index].get('name')} {arguments.rating}/10") - - -def register(subparsers) -> None: - for kind, settings in MEDIA_KINDS.items(): - parser = subparsers.add_parser(kind, help=f"Manage the {settings['label']}s on the about page") - parser.set_defaults(kind=kind) - actions = parser.add_subparsers(dest="action", required=True) - - actions.add_parser("list", help=f"List {settings['label']}s").set_defaults(handler=list_items) - - add_parser = actions.add_parser("add", help=f"Add a {settings['label']} by IMDb link or ID") - add_parser.add_argument("title", help="IMDb link or ID, like tt0816692") - add_parser.add_argument("--rating", type=valid_rating, required=True, help="Your rating from 0 to 10") - add_parser.add_argument("--name", help="Display name, looked up on OMDb when left out") - add_parser.set_defaults(handler=add_item) - - remove_parser = actions.add_parser("remove", help=f"Remove a {settings['label']}") - remove_parser.add_argument("title", help="IMDb link, ID or name") - remove_parser.set_defaults(handler=remove_item) - - rate_parser = actions.add_parser("rate", help=f"Change a {settings['label']}'s rating") - rate_parser.add_argument("title", help="IMDb link, ID or name") - rate_parser.add_argument("rating", type=valid_rating, help="New rating from 0 to 10") - rate_parser.set_defaults(handler=rate_item) + return items[index] diff --git a/tools/content/music.py b/tools/content/music.py index 03790b4..917826b 100644 --- a/tools/content/music.py +++ b/tools/content/music.py @@ -4,68 +4,48 @@ import youtube MUSIC_FILE = "music.json" -def load_videos() -> tuple[dict, list[str]]: +def load_playlist() -> tuple[dict, list[str]]: data = jsonfile.load(MUSIC_FILE) return data, data.setdefault("videos", []) +def list_songs() -> list[str]: + _, videos = load_playlist() + return [youtube.video_id(video) or video for video in videos] + + def find_index(videos: list[str], identifier: str) -> int | None: return next((index for index, video in enumerate(videos) if youtube.video_id(video) == identifier), None) -def list_songs(arguments) -> None: - _, videos = load_videos() - if not videos: - print("The playlist is empty.") - return - for number, video in enumerate(videos, start=1): - identifier = youtube.video_id(video) or "?" - title = youtube.video_title(identifier) if arguments.titles else None - print(f"{number:>3}. {identifier} {title or video}") +def add_song(text: str) -> str: + identifier = youtube.video_id(text) + if not identifier: + raise ValueError(f"{text} is not a YouTube link or video ID") + data, videos = load_playlist() + if find_index(videos, identifier) is not None: + raise ValueError(f"{identifier} is already in the playlist") + videos.append(youtube.watch_url(identifier)) + jsonfile.save(MUSIC_FILE, data) + return identifier -def add_songs(arguments) -> None: - data, videos = load_videos() - added = 0 - for text in arguments.songs: - identifier = youtube.video_id(text) - if not identifier: - print(f"Skipped {text}: not a YouTube link or video ID.") - continue - if find_index(videos, identifier) is not None: - print(f"Skipped {identifier}: already in the playlist.") - continue - videos.append(youtube.watch_url(identifier)) - added += 1 - print(f"Added {youtube.video_title(identifier) or identifier}") - if added: - jsonfile.save(MUSIC_FILE, data) - - -def remove_song(arguments) -> None: - data, videos = load_videos() - identifier = youtube.video_id(arguments.song) - index = find_index(videos, identifier) if identifier else None +def remove_song(identifier: str) -> None: + data, videos = load_playlist() + index = find_index(videos, identifier) if index is None: - print(f"{arguments.song} is not in the playlist.") - return + raise ValueError(f"{identifier} is not in the playlist") videos.pop(index) jsonfile.save(MUSIC_FILE, data) - print(f"Removed {identifier}") -def register(subparsers) -> None: - parser = subparsers.add_parser("music", help="Manage the music playlist") - actions = parser.add_subparsers(dest="action", required=True) - - list_parser = actions.add_parser("list", help="List songs in the playlist") - list_parser.add_argument("--titles", action="store_true", help="Look up song titles on YouTube") - list_parser.set_defaults(handler=list_songs) - - add_parser = actions.add_parser("add", help="Add songs by YouTube link or video ID") - add_parser.add_argument("songs", nargs="+", help="YouTube links or video IDs") - add_parser.set_defaults(handler=add_songs) - - remove_parser = actions.add_parser("remove", help="Remove a song by YouTube link or video ID") - remove_parser.add_argument("song", help="YouTube link or video ID") - remove_parser.set_defaults(handler=remove_song) +def move_song(identifier: str, step: int) -> None: + data, videos = load_playlist() + index = find_index(videos, identifier) + if index is None: + raise ValueError(f"{identifier} is not in the playlist") + target = index + step + if not 0 <= target < len(videos): + return + videos[index], videos[target] = videos[target], videos[index] + jsonfile.save(MUSIC_FILE, data) diff --git a/tools/content/portfolio.py b/tools/content/portfolio.py new file mode 100644 index 0000000..9d41cb1 --- /dev/null +++ b/tools/content/portfolio.py @@ -0,0 +1,204 @@ +from dataclasses import dataclass, field + +import jsonfile +from fields import FieldSpec, get_path + + +@dataclass +class Collection: + id: str + title: str + file: str + fields: list[FieldSpec] + columns: list[tuple[str, str]] = field(default_factory=list) + single: bool = False + + @property + def title_field(self) -> FieldSpec: + return self.fields[0] + + +PROJECT_BASICS = [ + FieldSpec("name", "Name", required=True), + FieldSpec("description", "Description", "longtext"), + FieldSpec("featured", "Featured", "flag"), + FieldSpec("tags", "Tags", "tags"), +] +PROJECT_COLUMNS = [("Name", "name"), ("Featured", "featured"), ("Tags", "tags")] + +PROFILE = Collection( + "profile", + "Profile", + "profile.json", + [ + FieldSpec("name", "Full name", required=True), + FieldSpec("displayName", "Display name"), + FieldSpec("title", "Title"), + FieldSpec("tagline", "Tagline", "longtext"), + FieldSpec("bio", "Bio", "longtext"), + FieldSpec("email", "Email"), + FieldSpec("photo", "Portfolio photo", "image"), + FieldSpec("avatar", "Avatar", "image"), + FieldSpec("education.school", "School"), + FieldSpec("education.degree", "Degree"), + FieldSpec("education.graduation", "Graduation"), + FieldSpec("interests", "Interests", "tags"), + FieldSpec("skills", "Skills", "skill_map"), + FieldSpec("socials", "Socials", "socials"), + FieldSpec("donateUrl", "Donate url"), + FieldSpec("giteaUrl", "Gitea url"), + ], + single=True, +) + +EXPERIENCE = Collection( + "experience", + "Experience", + "experience.json", + [ + FieldSpec("role", "Role", required=True), + FieldSpec("organization", "Organization"), + FieldSpec("school", "School"), + FieldSpec("period", "Period"), + FieldSpec("employmentType", "Employment type"), + FieldSpec("highlights", "Highlights", "lines"), + FieldSpec("tech", "Tech", "tags"), + FieldSpec("awards", "Awards", "lines"), + FieldSpec("links", "Links", "link_list"), + ], + [("Role", "role"), ("Organization", "organization"), ("Period", "period")], +) + +LEADERSHIP = Collection( + "leadership", + "Leadership", + "leadership.json", + [ + FieldSpec("role", "Role", required=True), + FieldSpec("organization", "Organization"), + FieldSpec("url", "Url"), + ], + [("Role", "role"), ("Organization", "organization")], +) + +PROJECT_COLLECTIONS = [ + Collection( + "opensource", + "Open Source", + "projects/opensource.json", + [*PROJECT_BASICS, FieldSpec("version", "Version"), FieldSpec("period", "Period"), FieldSpec("image", "Image", "image"), FieldSpec("links", "Links", "link_map")], + PROJECT_COLUMNS, + ), + Collection( + "packages", + "Packages", + "projects/packages.json", + [*PROJECT_BASICS, FieldSpec("deprecated", "Deprecated", "flag"), FieldSpec("links", "Links", "link_map")], + PROJECT_COLUMNS, + ), + Collection( + "hackathons", + "Hackathons", + "projects/hackathons.json", + [ + *PROJECT_BASICS, + FieldSpec("image", "Image", "image"), + FieldSpec("event.name", "Event"), + FieldSpec("event.university", "University"), + FieldSpec("event.location", "Location"), + FieldSpec("event.year", "Year"), + FieldSpec("awards", "Awards", "awards"), + FieldSpec("links", "Links", "link_map"), + ], + [("Name", "name"), ("Event", "event.name"), ("Featured", "featured")], + ), + Collection( + "research", + "Research groups", + "projects/research.json", + [ + FieldSpec("name", "Name", required=True), + FieldSpec("shortName", "Short name"), + FieldSpec("institution", "Institution"), + FieldSpec("role", "Role"), + FieldSpec("period", "Period"), + FieldSpec("description", "Description", "longtext"), + FieldSpec("lab.name", "Lab name"), + FieldSpec("lab.url", "Lab url"), + FieldSpec("focus", "Focus", "tags"), + FieldSpec("links", "Links", "link_list"), + ], + [("Name", "name"), ("Role", "role"), ("Period", "period")], + ), + Collection( + "organizations", + "Organizations", + "projects/organizations.json", + [ + *PROJECT_BASICS, + FieldSpec("organization", "Roles"), + FieldSpec("awards", "Awards", "awards"), + FieldSpec("gallery", "Gallery", "images"), + FieldSpec("links", "Links", "link_map"), + FieldSpec("socials", "Socials", "socials"), + ], + PROJECT_COLUMNS, + ), +] + + +def find_collection(collection_id: str) -> Collection: + for collection in [PROFILE, EXPERIENCE, LEADERSHIP, *PROJECT_COLLECTIONS]: + if collection.id == collection_id: + return collection + raise ValueError(f"Unknown collection {collection_id}") + + +def load_records(collection: Collection): + return jsonfile.load(collection.file) + + +def save_records(collection: Collection, records) -> None: + jsonfile.save(collection.file, records) + + +def record_title(collection: Collection, record: dict) -> str: + return str(get_path(record, collection.title_field.path) or "untitled") + + +def check_required(collection: Collection, record: dict) -> None: + for spec in collection.fields: + if spec.required and not get_path(record, spec.path): + raise ValueError(f"{spec.label} is required") + + +def save_record(collection: Collection, index: int | None, record: dict) -> int: + check_required(collection, record) + if collection.single: + save_records(collection, record) + return 0 + records = load_records(collection) + if index is None: + records.append(record) + index = len(records) - 1 + else: + records[index] = record + save_records(collection, records) + return index + + +def remove_record(collection: Collection, index: int) -> dict: + records = load_records(collection) + removed = records.pop(index) + save_records(collection, records) + return removed + + +def move_record(collection: Collection, index: int, step: int) -> int: + records = load_records(collection) + target = index + step + if not 0 <= target < len(records): + return index + records[index], records[target] = records[target], records[index] + save_records(collection, records) + return target diff --git a/tools/content/requirements.txt b/tools/content/requirements.txt new file mode 100644 index 0000000..ac86f2b --- /dev/null +++ b/tools/content/requirements.txt @@ -0,0 +1 @@ +textual>=8.2,<9 diff --git a/tools/content/site_themes.py b/tools/content/site_themes.py new file mode 100644 index 0000000..b0f4386 --- /dev/null +++ b/tools/content/site_themes.py @@ -0,0 +1,87 @@ +import re + +import jsonfile + +THEMES_FILE = "themes.json" +DESKTOP_FILE = "desktop.json" +THEME_MODES = ["dark", "light"] +COLOR_KEYS = [ + "accent", + "selection", + "muted", + "background", + "dark_background", + "lighter_background", + "foreground", + "red", + "yellow", + "orange", + "green", + "cyan", + "blue", + "magenta", +] +HEX_COLOR_PATTERN = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def load_themes() -> list[dict]: + return jsonfile.load(THEMES_FILE) + + +def save_themes(themes: list[dict]) -> None: + jsonfile.save(THEMES_FILE, themes) + + +def find_theme(themes: list[dict], theme_id: str) -> dict | None: + return next((theme for theme in themes if theme.get("id") == theme_id), None) + + +def default_theme_id() -> str | None: + try: + return jsonfile.load(DESKTOP_FILE).get("defaultTheme") + except (OSError, ValueError): + return None + + +def set_default_theme(theme_id: str) -> None: + desktop = jsonfile.load(DESKTOP_FILE) + desktop["defaultTheme"] = theme_id + jsonfile.save(DESKTOP_FILE, desktop) + + +def check_theme(theme: dict) -> dict: + if not ID_PATTERN.match(theme.get("id", "")): + raise ValueError("Theme id must be lowercase letters, numbers and dashes") + if theme.get("mode") not in THEME_MODES: + raise ValueError("Mode must be dark or light") + for key in COLOR_KEYS: + if not HEX_COLOR_PATTERN.match(theme.get("colors", {}).get(key, "")): + raise ValueError(f"{key} must be a hex color like #818cf8") + for color in theme.get("activeBorder") or []: + if not HEX_COLOR_PATTERN.match(color): + raise ValueError("Active border colors must be hex colors") + return theme + + +def save_theme(original_id: str | None, theme: dict) -> None: + check_theme(theme) + themes = load_themes() + duplicate = find_theme(themes, theme["id"]) + if duplicate and theme["id"] != original_id: + raise ValueError(f"A theme with the id {theme['id']} already exists") + if original_id is None: + themes.append(theme) + else: + index = next(index for index, item in enumerate(themes) if item.get("id") == original_id) + themes[index] = theme + if original_id != theme["id"] and default_theme_id() == original_id: + set_default_theme(theme["id"]) + save_themes(themes) + + +def remove_theme(theme_id: str) -> None: + if default_theme_id() == theme_id: + raise ValueError("That is the default theme. Pick another default first") + themes = [theme for theme in load_themes() if theme.get("id") != theme_id] + save_themes(themes) diff --git a/tools/content/static_images.py b/tools/content/static_images.py new file mode 100644 index 0000000..9dcc83f --- /dev/null +++ b/tools/content/static_images.py @@ -0,0 +1,30 @@ +from jsonfile import PROJECT_ROOT + +STATIC_DIR = PROJECT_ROOT / "static" +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".ico"} + + +def site_path(file_path) -> str: + return "/" + file_path.relative_to(STATIC_DIR).as_posix() + + +def list_images() -> list[dict]: + if not STATIC_DIR.exists(): + return [] + images = [] + for file_path in sorted(STATIC_DIR.rglob("*")): + if file_path.is_file() and file_path.suffix.lower() in IMAGE_EXTENSIONS: + images.append({"path": site_path(file_path), "size": file_path.stat().st_size}) + return images + + +def image_exists(path: str) -> bool: + return (STATIC_DIR / path.lstrip("/")).is_file() + + +def format_size(size: int) -> str: + for unit in ["B", "KB", "MB"]: + if size < 1024: + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" diff --git a/tools/content/tui/__init__.py b/tools/content/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/content/tui/app.tcss b/tools/content/tui/app.tcss new file mode 100644 index 0000000..6260d18 --- /dev/null +++ b/tools/content/tui/app.tcss @@ -0,0 +1,252 @@ +Screen { + background: $background; +} + +Header { + background: $panel; + color: $foreground; +} + +HeaderTitle { + color: $primary; + text-style: bold; +} + +TabbedContent { + height: 1fr; +} + +ContentTabs { + background: $background; +} + +Tab { + color: $text-muted; +} + +Tab.-active { + color: $primary; + text-style: bold; +} + +Tab:hover { + color: $foreground; +} + +Underline > .underline--bar { + color: $primary; + background: $panel; +} + +TabPane { + padding: 0; +} + +ContentPane { + height: 1fr; + margin: 1 1 0 1; + background: $surface; + border: solid $border-blurred; + border-title-color: $text-muted; + border-subtitle-color: $text-muted; +} + +ContentPane:focus-within { + border: solid $primary; + border-title-color: $primary; + border-subtitle-color: $accent; +} + +ContentPane DataTable { + height: 1fr; + background: $surface; +} + +DataTable > .datatable--header { + background: $surface; + color: $accent; + text-style: bold; +} + +DataTable > .datatable--even-row { + background: $surface; +} + +DataTable > .datatable--odd-row { + background: $panel 40%; +} + +.split-layout { + height: 1fr; +} + +.split-layout DataTable { + width: 3fr; +} + +.detail-scroll { + width: 2fr; + padding: 0 1; + background: $surface; + border-left: solid $border-blurred; +} + +ContentPane:focus-within .detail-scroll { + border-left: solid $primary; +} + +Footer { + background: $panel; +} + +FormDialog, +ConfirmDialog { + align: center middle; + background: $background 60%; +} + +.dialog { + width: 72; + height: auto; + max-width: 95%; + padding: 1 2; + background: $surface; + border: solid $primary; +} + +.dialog-title { + color: $primary; + text-style: bold; +} + +.field-label { + margin-top: 1; + color: $text-muted; +} + +.dialog Input, +.dialog Select { + width: 100%; +} + +.dialog Input { + background: $background; + border: solid $border-blurred; +} + +.dialog Input:focus { + border: solid $primary; +} + +#dialog-error { + height: auto; + margin-top: 1; + color: $error; +} + +.dialog-buttons { + height: auto; + margin-top: 1; + align-horizontal: right; +} + +.dialog-buttons Button { + margin-left: 1; + border: none; + min-width: 10; +} + +Toast { + background: $panel; + border-left: outer $primary; +} + +RecordEditor { + background: $background; +} + +.editor-title { + width: 100%; + padding: 0 2; + background: $panel; + color: $primary; + text-style: bold; +} + +.editor-form { + margin: 1 2 0 2; + padding: 0 2 1 2; + background: $surface; + border: solid $primary; +} + +.editor-form Input, +.editor-form Select { + width: 100%; + background: $background; + border: solid $border-blurred; +} + +.editor-form Input:focus, +.editor-form TextArea:focus { + border: solid $primary; +} + +.editor-area { + height: 6; + background: $background; + border: solid $border-blurred; +} + +.editor-area.longtext { + height: 8; +} + +#editor-error { + height: auto; + margin-top: 1; + color: $error; +} + +.field-row { + height: auto; +} + +.field-row Input { + width: 1fr; +} + +.browse-button { + min-width: 10; + margin-left: 1; + border: none; +} + +.swatch { + width: 8; + height: 3; + margin-left: 1; + content-align: center middle; + border: solid $border-blurred; +} + +.picker { + width: 80; + height: 80%; +} + +.picker OptionList { + height: 1fr; + margin-top: 1; + background: $background; + border: solid $border-blurred; +} + +.picker OptionList:focus { + border: solid $primary; +} + +#category-select { + width: 100%; + margin: 0 0 1 0; +} diff --git a/tools/content/tui/books_pane.py b/tools/content/tui/books_pane.py new file mode 100644 index 0000000..2c011cf --- /dev/null +++ b/tools/content/tui/books_pane.py @@ -0,0 +1,162 @@ +from rich.markup import escape +from textual import on, work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import DataTable, Static + +import books +from tui.content_pane import ContentPane +from tui.dialogs import ConfirmDialog, FormDialog, FormField + + +def book_details(book: dict) -> str: + rows = [ + ("Shelf", book.get("shelf")), + ("Your rating", f"{book['rating']}/10" if book.get("rating") is not None else None), + ("Goodreads", f"{book['averageRating']} from {book.get('ratingCount') or 0:,} ratings" if book.get("averageRating") else None), + ("Pages", book.get("pages")), + ("Published", book.get("published")), + ("Genres", ", ".join(book.get("genres") or [])), + ("Added", book.get("added")), + ] + lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""] + lines += [f"[dim]{label:<12}[/dim]{escape(str(value))}" for label, value in rows if value] + if book.get("description"): + lines += ["", escape(book["description"])] + lines += ["", f"[dim]{escape(book.get('url', ''))}[/dim]"] + return "\n".join(lines) + + +class BooksPane(ContentPane): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("e", "rate", "Rate"), + Binding("s", "shelf", "Shelf"), + Binding("d", "delete", "Delete"), + Binding("u", "refresh", "Refresh"), + Binding("U", "refresh_all", "Refresh all"), + Binding("r", "reload", "Reload"), + ] + + content_file = "books.json" + + def compose(self) -> ComposeResult: + with Horizontal(classes="split-layout"): + yield DataTable(cursor_type="row", zebra_stripes=True) + with VerticalScroll(classes="detail-scroll"): + yield Static("Add a book with [b]a[/b] and a Goodreads link.", id="book-detail") + + def on_mount(self): + table = self.table() + table.add_column("Shelf", key="shelf") + table.add_column("Rating", key="rating") + table.add_column("Title", key="title") + table.add_column("Authors", key="authors") + self.reload() + + def book_by_id(self, identifier: str | None) -> dict | None: + return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None) + + def reload(self, focus_key: str | None = None): + table = self.table() + table.clear() + shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)} + sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1))) + for book in sorted_books: + rating = f"{book['rating']}/10" if book.get("rating") is not None else "-" + table.add_row(book.get("shelf", ""), rating, book.get("title", ""), ", ".join(book.get("authors") or []), key=book["goodreadsId"]) + self.restore_cursor(focus_key) + self.show_details(self.selected_key()) + + def show_details(self, identifier: str | None): + book = self.book_by_id(identifier) + detail = self.query_one("#book-detail", Static) + detail.update(book_details(book) if book else "Add a book with [b]a[/b] and a Goodreads link.") + + @on(DataTable.RowHighlighted) + def highlight_changed(self, event: DataTable.RowHighlighted): + self.show_details(event.row_key.value if event.row_key else None) + + def action_add(self): + fields = [ + FormField("link", "Goodreads link", placeholder="https://www.goodreads.com/book/show/170448.Animal_Farm", required=True), + FormField("shelf", "Shelf", kind="select", options=books.SHELVES, value="read"), + FormField("rating", "Your rating (0 to 10, optional)", kind="rating"), + ] + self.app.push_screen(FormDialog("Add a book", fields, "Add"), self.add_book) + + def add_book(self, values: dict | None): + if not values: + return + + def added(book: dict): + self.reload(book["goodreadsId"]) + self.notify(f"Added {book['title']} to {book['shelf']}") + + self.run_in_background("Reading the book from Goodreads...", lambda: books.add_book(values["link"], values["shelf"], values["rating"]), added) + + def action_rate(self): + book = self.book_by_id(self.selected_key()) + if not book: + return + current = "" if book.get("rating") is None else str(book["rating"]) + fields = [FormField("rating", f"Rating for {book['title']} (empty to clear)", kind="rating", value=current)] + + def rated(values: dict | None): + if values is not None: + self.run_now(lambda: books.rate_book(book["goodreadsId"], values["rating"]), f"Updated {book['title']}", book["goodreadsId"]) + + self.app.push_screen(FormDialog("Change rating", fields), rated) + + def action_shelf(self): + book = self.book_by_id(self.selected_key()) + if not book: + return + fields = [FormField("shelf", f"Shelf for {book['title']}", kind="select", options=books.SHELVES, value=book.get("shelf", "read"))] + + def moved(values: dict | None): + if values: + self.run_now(lambda: books.move_book(book["goodreadsId"], values["shelf"]), f"Moved {book['title']} to {values['shelf']}", book["goodreadsId"]) + + self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved) + + def action_delete(self): + book = self.book_by_id(self.selected_key()) + if not book: + return + + def confirmed(should_delete: bool | None): + if should_delete: + self.run_now(lambda: books.remove_book(book["goodreadsId"]), f"Removed {book['title']}") + + self.app.push_screen(ConfirmDialog(f"Remove {book['title']}?"), confirmed) + + def action_refresh(self): + book = self.book_by_id(self.selected_key()) + if not book: + return + + def refreshed(updated: dict): + self.reload(updated["goodreadsId"]) + self.notify(f"Refreshed {updated['title']}") + + self.run_in_background(f"Refreshing {book['title']}...", lambda: books.refresh_book(book["goodreadsId"]), refreshed) + + def action_refresh_all(self): + self.notify("Refreshing every book from Goodreads...") + self.refresh_every_book() + + @work(thread=True, exclusive=True, group="refresh") + def refresh_every_book(self): + failures = 0 + all_books = books.load_books() + for book in all_books: + try: + books.refresh_book(book["goodreadsId"]) + except (OSError, ValueError) as error: + failures += 1 + self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning") + self.app.call_from_thread(lambda: self.reload(self.selected_key())) + summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books" + self.app.call_from_thread(self.notify, summary) diff --git a/tools/content/tui/content_pane.py b/tools/content/tui/content_pane.py new file mode 100644 index 0000000..633da04 --- /dev/null +++ b/tools/content/tui/content_pane.py @@ -0,0 +1,65 @@ +from typing import Any, Callable + +from textual import work +from textual.containers import Vertical +from textual.widgets import DataTable + + +class ContentPane(Vertical): + content_file = "" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_content_file(self.content_file) + + def set_content_file(self, name: str): + self.content_file = name + self.border_title = f"[b]kitty[/b] ~/content/{name}" + + def table(self) -> DataTable: + return self.query_one(DataTable) + + def selected_key(self) -> str | None: + table = self.table() + if table.row_count == 0: + return None + row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) + return row_key.value + + def restore_cursor(self, key: str | None): + table = self.table() + if key is None or table.row_count == 0: + return + try: + table.move_cursor(row=table.get_row_index(key)) + except Exception: + table.move_cursor(row=0) + + def reload(self, focus_key: str | None = None): + raise NotImplementedError + + def action_reload(self): + self.reload(self.selected_key()) + self.notify("Reloaded from disk") + + def run_now(self, task: Callable[[], Any], message: str | Callable[[Any], str], focus_key: str | None = None): + try: + result = task() + except (OSError, ValueError) as error: + self.notify(str(error), title="Error", severity="error") + return + self.reload(focus_key) + self.notify(message(result) if callable(message) else message) + + def run_in_background(self, waiting_message: str, task: Callable[[], Any], on_success: Callable[[Any], None]): + self.notify(waiting_message) + self.background_task(task, on_success) + + @work(thread=True) + def background_task(self, task: Callable[[], Any], on_success: Callable[[Any], None]): + try: + result = task() + except (OSError, ValueError) as error: + self.app.call_from_thread(self.notify, str(error), title="Error", severity="error") + return + self.app.call_from_thread(on_success, result) diff --git a/tools/content/tui/dialogs.py b/tools/content/tui/dialogs.py new file mode 100644 index 0000000..0c33f57 --- /dev/null +++ b/tools/content/tui/dialogs.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass, field + +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label, Select + + +@dataclass +class FormField: + name: str + label: str + kind: str = "text" + placeholder: str = "" + required: bool = False + value: str = "" + options: list[str] = field(default_factory=list) + + +class FormDialog(ModalScreen[dict | None]): + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__(self, title: str, fields: list[FormField], submit_label: str = "Save"): + super().__init__() + self.form_title = title + self.fields = fields + self.submit_label = submit_label + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog"): + yield Label(self.form_title, classes="dialog-title") + for form_field in self.fields: + yield Label(form_field.label, classes="field-label") + yield self.field_widget(form_field) + yield Label("", id="dialog-error") + with Horizontal(classes="dialog-buttons"): + yield Button(self.submit_label, variant="primary", id="submit") + yield Button("Cancel", id="cancel") + + def field_widget(self, form_field: FormField): + widget_id = f"field-{form_field.name}" + if form_field.kind == "select": + options = [(option, option) for option in form_field.options] + return Select(options, value=form_field.value or form_field.options[0], allow_blank=False, id=widget_id) + input_type = "integer" if form_field.kind == "rating" else "text" + return Input(value=form_field.value, placeholder=form_field.placeholder, type=input_type, id=widget_id) + + def show_error(self, message: str): + self.query_one("#dialog-error", Label).update(message) + + def read_value(self, form_field: FormField): + text = str(self.query_one(f"#field-{form_field.name}").value or "").strip() + if form_field.required and not text: + raise ValueError(f"{form_field.label} is required") + if form_field.kind != "rating": + return text or None + if not text: + return None + rating = int(text) + if not 0 <= rating <= 10: + raise ValueError("Rating must be between 0 and 10") + return rating + + def submit(self): + try: + values = {form_field.name: self.read_value(form_field) for form_field in self.fields} + except ValueError as error: + self.show_error(str(error)) + return + self.dismiss(values) + + @on(Input.Submitted) + def submit_from_input(self): + self.submit() + + @on(Button.Pressed, "#submit") + def submit_from_button(self): + self.submit() + + @on(Button.Pressed, "#cancel") + def action_cancel(self): + self.dismiss(None) + + +class ConfirmDialog(ModalScreen[bool]): + BINDINGS = [("y", "confirm", "Yes"), ("n", "cancel", "No"), ("escape", "cancel", "No")] + + def __init__(self, message: str): + super().__init__() + self.message = message + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog"): + yield Label(self.message, classes="dialog-title") + with Horizontal(classes="dialog-buttons"): + yield Button("Delete", variant="error", id="confirm") + yield Button("Cancel", id="cancel") + + @on(Button.Pressed, "#confirm") + def action_confirm(self): + self.dismiss(True) + + @on(Button.Pressed, "#cancel") + def action_cancel(self): + self.dismiss(False) diff --git a/tools/content/tui/image_picker.py b/tools/content/tui/image_picker.py new file mode 100644 index 0000000..aedaa02 --- /dev/null +++ b/tools/content/tui/image_picker.py @@ -0,0 +1,68 @@ +from rich.text import Text +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Label, OptionList +from textual.widgets.option_list import Option + +import static_images + + +class ImagePicker(ModalScreen[str | None]): + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("down", "focus_list", "List", show=False), + ] + + def __init__(self, current: str = "", start_filter: str = ""): + super().__init__() + self.current = current + self.start_filter = start_filter + self.images: list[dict] = [] + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog picker"): + yield Label("Pick an image from static/", classes="dialog-title") + yield Input(value=self.start_filter, placeholder="Type to filter, Enter to pick", id="image-filter") + yield OptionList(id="image-list") + yield Label("", id="image-count", classes="field-label") + + def on_mount(self): + self.images = static_images.list_images() + self.show_images(self.start_filter) + + def show_images(self, text: str): + query = text.strip().lower() + matches = [image for image in self.images if query in image["path"].lower()] + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options( + Option(Text.assemble(image["path"], (" " + static_images.format_size(image["size"]), "dim")), id=image["path"]) + for image in matches + ) + current_index = next((index for index, image in enumerate(matches) if image["path"] == self.current), 0) + if matches: + option_list.highlighted = current_index + self.query_one("#image-count", Label).update(f"{len(matches)} of {len(self.images)} images") + + @on(Input.Changed, "#image-filter") + def filter_changed(self, event: Input.Changed): + self.show_images(event.value) + + @on(Input.Submitted, "#image-filter") + def pick_highlighted(self): + option_list = self.query_one(OptionList) + if option_list.highlighted is not None and option_list.option_count: + self.dismiss(option_list.get_option_at_index(option_list.highlighted).id) + + @on(OptionList.OptionSelected) + def pick_option(self, event: OptionList.OptionSelected): + self.dismiss(event.option.id) + + def action_focus_list(self): + self.query_one(OptionList).focus() + + def action_cancel(self): + self.dismiss(None) diff --git a/tools/content/tui/media_pane.py b/tools/content/tui/media_pane.py new file mode 100644 index 0000000..53eaef8 --- /dev/null +++ b/tools/content/tui/media_pane.py @@ -0,0 +1,95 @@ +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widgets import DataTable + +import imdb +import media +from tui.content_pane import ContentPane +from tui.dialogs import ConfirmDialog, FormDialog, FormField + + +class MediaPane(ContentPane): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("e", "rate", "Rate"), + Binding("d", "delete", "Delete"), + Binding("r", "reload", "Reload"), + ] + + content_file = "about.json" + + def __init__(self, kind: str, **kwargs): + super().__init__(**kwargs) + self.kind = kind + self.label = media.MEDIA_KINDS[kind]["label"] + self.border_subtitle = media.MEDIA_KINDS[kind]["key"] + + def compose(self) -> ComposeResult: + yield DataTable(cursor_type="row", zebra_stripes=True) + + def on_mount(self): + table = self.table() + table.add_column("Rating", key="rating") + table.add_column("Title", key="title") + table.add_column("IMDb", key="imdb") + self.reload() + + def reload(self, focus_key: str | None = None): + table = self.table() + table.clear() + for item in media.list_items(self.kind): + identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "") + table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier) + self.restore_cursor(focus_key) + + def action_add(self): + fields = [ + FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True), + FormField("rating", "Your rating (0 to 10)", kind="rating", required=True), + FormField("name", "Name (looked up on OMDb when empty)"), + ] + self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item) + + def add_item(self, values: dict | None): + if not values: + return + identifier = imdb.imdb_id(values["link"]) + + def added(item: dict): + self.reload(identifier) + self.notify(f"Added {item['name']} ({item['rating']}/10)") + + self.run_in_background( + f"Adding {identifier or values['link']}...", + lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"]), + added, + ) + + def action_rate(self): + identifier = self.selected_key() + if not identifier: + return + current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {}) + fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))] + + def rated(values: dict | None): + if values: + self.run_now( + lambda: media.rate_item(self.kind, identifier, values["rating"]), + lambda item: f"Rated {item['name']} {item['rating']}/10", + identifier, + ) + + self.app.push_screen(FormDialog("Change rating", fields), rated) + + def action_delete(self): + identifier = self.selected_key() + if not identifier: + return + name = self.table().get_cell(identifier, "title") + + def confirmed(should_delete: bool | None): + if should_delete: + self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}") + + self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed) diff --git a/tools/content/tui/music_pane.py b/tools/content/tui/music_pane.py new file mode 100644 index 0000000..08b985c --- /dev/null +++ b/tools/content/tui/music_pane.py @@ -0,0 +1,144 @@ +from rich.markup import escape +from textual import on, work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import DataTable, Static + +import music +import youtube +from tui.content_pane import ContentPane +from tui.dialogs import ConfirmDialog, FormDialog, FormField + +LOADING = "..." +DESCRIPTION_PREVIEW_LENGTH = 500 +EMPTY_DETAILS = "Add a song with [b]a[/b] and a YouTube link." + + +def song_details(identifier: str, details: dict | None) -> str: + if not details: + return f"[b]{escape(identifier)}[/b]\n\n[dim]Loading details from YouTube...[/dim]" + rows = [ + ("Artist", details.get("artist")), + ("Length", youtube.format_duration(details["durationSeconds"]) if details.get("durationSeconds") else None), + ("Views", f"{details['views']:,}" if details.get("views") is not None else None), + ("Published", details.get("published")), + ("Video ID", identifier), + ] + lines = [f"[b]{escape(details.get('title') or 'Title not found')}[/b]", ""] + lines += [f"[dim]{label:<11}[/dim]{escape(str(value))}" for label, value in rows if value] + description = details.get("description") + if description: + preview = description[:DESCRIPTION_PREVIEW_LENGTH] + ("..." if len(description) > DESCRIPTION_PREVIEW_LENGTH else "") + lines += ["", escape(preview)] + lines += ["", f"[dim]{escape(details['url'])}[/dim]", f"[dim]{escape(details['cover'])}[/dim]"] + return "\n".join(lines) + + +class MusicPane(ContentPane): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("d", "delete", "Delete"), + Binding("K", "move(-1)", "Move up"), + Binding("J", "move(1)", "Move down"), + Binding("u", "refresh_details", "Refresh"), + Binding("r", "reload", "Reload"), + ] + + content_file = "music.json" + song_info: dict[str, dict] = {} + + def compose(self) -> ComposeResult: + with Horizontal(classes="split-layout"): + yield DataTable(cursor_type="row", zebra_stripes=True) + with VerticalScroll(classes="detail-scroll"): + yield Static(EMPTY_DETAILS, id="song-detail") + + def on_mount(self): + table = self.table() + table.add_column("#", key="number") + table.add_column("Title", key="title") + table.add_column("Artist", key="artist") + table.add_column("Length", key="length") + self.reload() + + def row_cells(self, identifier: str) -> tuple[str, str, str]: + details = self.song_info.get(identifier) + if not details: + return identifier, LOADING, LOADING + return details.get("title") or identifier, details.get("artist") or "-", youtube.format_duration(details.get("durationSeconds")) + + def reload(self, focus_key: str | None = None): + table = self.table() + table.clear() + for number, identifier in enumerate(music.list_songs(), start=1): + table.add_row(str(number), *self.row_cells(identifier), key=identifier) + self.restore_cursor(focus_key) + self.show_details(self.selected_key()) + self.update_summary() + self.load_details() + + def update_summary(self): + songs = music.list_songs() + known_lengths = [self.song_info[song].get("durationSeconds") for song in songs if song in self.song_info] + total = sum(length for length in known_lengths if length) + length_text = youtube.format_duration(total) if known_lengths else LOADING + self.border_subtitle = f"{len(songs)} songs · {length_text}" + + def show_details(self, identifier: str | None): + detail = self.query_one("#song-detail", Static) + detail.update(song_details(identifier, self.song_info.get(identifier)) if identifier else EMPTY_DETAILS) + + @on(DataTable.RowHighlighted) + def highlight_changed(self, event: DataTable.RowHighlighted): + self.show_details(event.row_key.value if event.row_key else None) + + @work(thread=True, exclusive=True, group="details") + def load_details(self, refresh_ids: tuple[str, ...] = ()): + for identifier in music.list_songs(): + if identifier in self.song_info and identifier not in refresh_ids: + continue + self.song_info[identifier] = youtube.video_details(identifier) + self.app.call_from_thread(self.show_song, identifier) + + def show_song(self, identifier: str): + table = self.table() + try: + for column, value in zip(["title", "artist", "length"], self.row_cells(identifier)): + table.update_cell(identifier, column, value, update_width=True) + except Exception: + return + self.update_summary() + if self.selected_key() == identifier: + self.show_details(identifier) + + def action_refresh_details(self): + identifier = self.selected_key() + if identifier: + self.notify("Refreshing from YouTube...") + self.load_details((identifier,)) + + def action_add(self): + fields = [FormField("link", "YouTube link or video ID", placeholder="https://music.youtube.com/watch?v=...", required=True)] + self.app.push_screen(FormDialog("Add a song", fields, "Add"), self.add_song) + + def add_song(self, values: dict | None): + if values: + self.run_now(lambda: music.add_song(values["link"]), lambda identifier: f"Added {identifier}", youtube.video_id(values["link"])) + + def action_delete(self): + identifier = self.selected_key() + if not identifier: + return + title = (self.song_info.get(identifier) or {}).get("title") or identifier + + def confirmed(should_delete: bool | None): + if should_delete: + self.run_now(lambda: music.remove_song(identifier), f"Removed {title}") + + self.app.push_screen(ConfirmDialog(f"Remove {title} from the playlist?"), confirmed) + + def action_move(self, step: int): + identifier = self.selected_key() + if identifier: + self.run_now(lambda: music.move_song(identifier, step), "Moved", identifier) diff --git a/tools/content/tui/profile_pane.py b/tools/content/tui/profile_pane.py new file mode 100644 index 0000000..7f22388 --- /dev/null +++ b/tools/content/tui/profile_pane.py @@ -0,0 +1,73 @@ +from rich.markup import escape +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import DataTable, Static + +import portfolio +from fields import get_path, to_form_text +from tui.content_pane import ContentPane +from tui.record_editor import RecordEditor + +PREVIEW_LENGTH = 60 + + +class ProfilePane(ContentPane): + BINDINGS = [ + Binding("e", "edit", "Edit"), + Binding("r", "reload", "Reload"), + ] + + content_file = portfolio.PROFILE.file + + def compose(self) -> ComposeResult: + with Horizontal(classes="split-layout"): + yield DataTable(cursor_type="row", zebra_stripes=True) + with VerticalScroll(classes="detail-scroll"): + yield Static("", id="profile-detail") + + def on_mount(self): + table = self.table() + table.add_column("Field", key="field") + table.add_column("Value", key="value") + self.reload() + + def reload(self, focus_key: str | None = None): + profile = portfolio.load_records(portfolio.PROFILE) + table = self.table() + table.clear() + for spec in portfolio.PROFILE.fields: + text = to_form_text(spec.kind, get_path(profile, spec.path)).replace("\n", " · ") + preview = text if len(text) <= PREVIEW_LENGTH else text[:PREVIEW_LENGTH] + "..." + table.add_row(spec.label, preview, key=spec.path) + self.restore_cursor(focus_key) + self.show_field(self.selected_key()) + + def show_field(self, path: str | None): + spec = next((item for item in portfolio.PROFILE.fields if item.path == path), None) + detail = self.query_one("#profile-detail", Static) + if not spec: + detail.update("") + return + value = to_form_text(spec.kind, get_path(portfolio.load_records(portfolio.PROFILE), spec.path)) + hint = f"\n[dim]{spec.hint}[/dim]" if spec.hint else "" + detail.update(f"[b]{escape(spec.label)}[/b]{hint}\n\n{escape(value) or '[dim]empty[/dim]'}\n\n[dim]Press e or Enter to edit the profile.[/dim]") + + @on(DataTable.RowHighlighted) + def highlight_changed(self, event: DataTable.RowHighlighted): + self.show_field(event.row_key.value if event.row_key else None) + + @on(DataTable.RowSelected) + def row_selected(self): + self.action_edit() + + def action_edit(self): + profile = portfolio.load_records(portfolio.PROFILE) + focus_key = self.selected_key() + + def saved(result: dict | None): + if result is not None: + self.run_now(lambda: portfolio.save_record(portfolio.PROFILE, None, result), "Saved profile", focus_key) + + self.app.push_screen(RecordEditor("Edit profile", portfolio.PROFILE.fields, profile), saved) diff --git a/tools/content/tui/projects_pane.py b/tools/content/tui/projects_pane.py new file mode 100644 index 0000000..87051cb --- /dev/null +++ b/tools/content/tui/projects_pane.py @@ -0,0 +1,30 @@ +from textual import on +from textual.app import ComposeResult +from textual.widgets import Select + +import portfolio +from tui.record_pane import RecordListPane + +IMAGE_FOLDERS = {"hackathons": "hacks", "organizations": "organizations", "opensource": "projects"} + + +class ProjectsPane(RecordListPane): + def __init__(self, **kwargs): + super().__init__(portfolio.PROJECT_COLLECTIONS[0], **kwargs) + + def compose_header(self) -> ComposeResult: + options = [(collection.title, collection.id) for collection in portfolio.PROJECT_COLLECTIONS] + yield Select(options, value=self.collection.id, allow_blank=False, id="category-select") + + @on(Select.Changed, "#category-select") + def category_changed(self, event: Select.Changed): + if event.value == self.collection.id: + return + self.collection = portfolio.find_collection(str(event.value)) + self.set_content_file(self.collection.file) + self.build_columns() + self.reload() + self.table().focus() + + def image_folder(self) -> str: + return IMAGE_FOLDERS.get(self.collection.id, "") diff --git a/tools/content/tui/record_editor.py b/tools/content/tui/record_editor.py new file mode 100644 index 0000000..4062ae7 --- /dev/null +++ b/tools/content/tui/record_editor.py @@ -0,0 +1,140 @@ +import copy +from typing import Callable + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.color import Color +from textual.containers import Horizontal, VerticalScroll +from textual.screen import Screen +from textual.widgets import Button, Footer, Input, Label, Select, Static, Switch, TextArea + +from fields import MULTILINE_KINDS, FieldSpec, from_form_text, get_path, set_path, to_form_text +from tui.image_picker import ImagePicker + + +class RecordEditor(Screen[dict | None]): + BINDINGS = [ + Binding("ctrl+s", "save", "Save"), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__(self, title: str, fields: list[FieldSpec], record: dict, validate: Callable[[dict], None] | None = None, image_folder: str = ""): + super().__init__() + self.editor_title = title + self.fields = fields + self.record = copy.deepcopy(record) + self.validate = validate + self.image_folder = image_folder + + def compose(self) -> ComposeResult: + yield Label(self.editor_title, classes="editor-title") + with VerticalScroll(classes="editor-form"): + for index, spec in enumerate(self.fields): + hint = f" [dim]({spec.hint})[/dim]" if spec.hint else "" + required = " [b]*[/b]" if spec.required else "" + yield Label(f"{spec.label}{required}{hint}", classes="field-label") + yield from self.field_widgets(index, spec) + yield Label("", id="editor-error") + with Horizontal(classes="dialog-buttons"): + yield Button("Save", variant="primary", id="save") + yield Button("Cancel", id="cancel") + yield Footer() + + def field_widgets(self, index: int, spec: FieldSpec): + widget_id = f"field-{index}" + value = get_path(self.record, spec.path) + if spec.kind == "flag": + yield Switch(value=bool(value), id=widget_id) + elif spec.kind == "select": + yield Select([(option, option) for option in spec.options], value=value or spec.options[0], allow_blank=False, id=widget_id) + elif spec.kind in MULTILINE_KINDS: + yield TextArea(to_form_text(spec.kind, value), id=widget_id, soft_wrap=True, classes=f"editor-area {spec.kind}") + if spec.kind == "images": + yield Button("Add image", id=f"browse-{index}", classes="browse-button") + elif spec.kind == "image": + with Horizontal(classes="field-row"): + yield Input(to_form_text(spec.kind, value), id=widget_id) + yield Button("Browse", id=f"browse-{index}", classes="browse-button") + elif spec.kind == "color": + with Horizontal(classes="field-row"): + yield Input(to_form_text(spec.kind, value), id=widget_id, classes="color-input") + yield Static(" ", id=f"swatch-{index}", classes="swatch") + else: + yield Input(to_form_text(spec.kind, value), id=widget_id) + + def on_mount(self): + for index, spec in enumerate(self.fields): + if spec.kind == "color": + self.update_swatch(index) + + def update_swatch(self, index: int): + text = self.query_one(f"#field-{index}", Input).value.strip() + swatch = self.query_one(f"#swatch-{index}", Static) + try: + swatch.styles.background = Color.parse(text) + swatch.update(" ") + except Exception: + swatch.styles.background = None + swatch.update("?") + + @on(Input.Changed, ".color-input") + def color_changed(self, event: Input.Changed): + self.update_swatch(int(event.input.id.removeprefix("field-"))) + + def read_record(self) -> dict: + record = copy.deepcopy(self.record) + for index, spec in enumerate(self.fields): + widget = self.query_one(f"#field-{index}") + if spec.kind == "flag": + value = widget.value + elif spec.kind == "select": + value = widget.value + elif isinstance(widget, TextArea): + value = from_form_text(spec.kind, widget.text) + else: + value = from_form_text(spec.kind, widget.value) + if spec.required and not value: + raise ValueError(f"{spec.label} is required") + set_path(record, spec.path, value) + return record + + def action_save(self): + try: + record = self.read_record() + if self.validate: + self.validate(record) + except ValueError as error: + self.query_one("#editor-error", Label).update(str(error)) + self.notify(str(error), title="Not saved", severity="error") + return + self.dismiss(record) + + def action_cancel(self): + self.dismiss(None) + + @on(Button.Pressed, "#save") + def save_pressed(self): + self.action_save() + + @on(Button.Pressed, "#cancel") + def cancel_pressed(self): + self.action_cancel() + + @on(Button.Pressed, ".browse-button") + def browse_pressed(self, event: Button.Pressed): + index = int(event.button.id.removeprefix("browse-")) + widget = self.query_one(f"#field-{index}") + current = widget.value if isinstance(widget, Input) else "" + + def picked(path: str | None): + if not path: + return + if isinstance(widget, TextArea): + existing = widget.text.rstrip() + widget.load_text(f"{existing}\n{path}" if existing else path) + else: + widget.value = path + widget.focus() + + self.app.push_screen(ImagePicker(current, self.image_folder), picked) diff --git a/tools/content/tui/record_pane.py b/tools/content/tui/record_pane.py new file mode 100644 index 0000000..4f9c109 --- /dev/null +++ b/tools/content/tui/record_pane.py @@ -0,0 +1,155 @@ +from rich.markup import escape +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import DataTable, Static + +import portfolio +from fields import get_path, summary_text, to_form_text +from portfolio import Collection +from tui.content_pane import ContentPane +from tui.dialogs import ConfirmDialog +from tui.record_editor import RecordEditor + + +def record_details(collection: Collection, record: dict) -> str: + lines = [f"[b]{escape(portfolio.record_title(collection, record))}[/b]", ""] + for spec in collection.fields[1:]: + value = get_path(record, spec.path) + if value in (None, "", [], {}, False): + continue + text = "yes" if value is True else to_form_text(spec.kind, value) + if "\n" in text or len(text) > 60: + lines += [f"[dim]{escape(spec.label)}[/dim]", escape(text), ""] + else: + lines.append(f"[dim]{escape(spec.label):<14}[/dim]{escape(text)}") + return "\n".join(lines) + + +class RecordListPane(ContentPane): + BINDINGS = [ + Binding("a", "add", "Add"), + Binding("e", "edit", "Edit"), + Binding("d", "delete", "Delete"), + Binding("f", "toggle_featured", "Featured"), + Binding("K", "move(-1)", "Move up"), + Binding("J", "move(1)", "Move down"), + Binding("r", "reload", "Reload"), + ] + + def __init__(self, collection: Collection, **kwargs): + super().__init__(**kwargs) + self.collection = collection + self.set_content_file(collection.file) + + def compose(self) -> ComposeResult: + yield from self.compose_header() + with Horizontal(classes="split-layout"): + yield DataTable(cursor_type="row", zebra_stripes=True) + with VerticalScroll(classes="detail-scroll"): + yield Static("", classes="record-detail") + + def compose_header(self) -> ComposeResult: + yield from () + + def on_mount(self): + self.build_columns() + self.reload() + + def build_columns(self): + table = self.table() + table.clear(columns=True) + table.add_column("#", key="number") + for label, path in self.collection.columns: + table.add_column(label, key=path) + + def records(self) -> list[dict]: + return portfolio.load_records(self.collection) + + def reload(self, focus_key: str | None = None): + table = self.table() + table.clear() + for index, record in enumerate(self.records()): + cells = [summary_text(get_path(record, path)) for _, path in self.collection.columns] + table.add_row(str(index + 1), *cells, key=str(index)) + self.restore_cursor(focus_key) + self.show_details(self.selected_key()) + + def selected_index(self) -> int | None: + key = self.selected_key() + return int(key) if key is not None else None + + def show_details(self, key: str | None): + records = self.records() + detail = self.query_one(".record-detail", Static) + if key is None or int(key) >= len(records): + detail.update(f"No {self.collection.title.lower()} yet. Press [b]a[/b] to add one.") + return + detail.update(record_details(self.collection, records[int(key)])) + + @on(DataTable.RowHighlighted) + def highlight_changed(self, event: DataTable.RowHighlighted): + self.show_details(event.row_key.value if event.row_key else None) + + @on(DataTable.RowSelected) + def row_selected(self): + self.action_edit() + + def open_editor(self, index: int | None, record: dict): + title = f"Edit {portfolio.record_title(self.collection, record)}" if index is not None else f"New {self.collection.title.lower()} entry" + + def saved(result: dict | None): + if result is None: + return + self.run_now( + lambda: portfolio.save_record(self.collection, index, result), + f"Saved {portfolio.record_title(self.collection, result)}", + str(index) if index is not None else str(len(self.records())), + ) + + self.app.push_screen(RecordEditor(title, self.collection.fields, record, image_folder=self.image_folder()), saved) + + def image_folder(self) -> str: + return "" + + def action_add(self): + self.open_editor(None, {}) + + def action_edit(self): + index = self.selected_index() + if index is not None: + self.open_editor(index, self.records()[index]) + + def action_delete(self): + index = self.selected_index() + if index is None: + return + title = portfolio.record_title(self.collection, self.records()[index]) + + def confirmed(should_delete: bool | None): + if should_delete: + self.run_now(lambda: portfolio.remove_record(self.collection, index), f"Removed {title}", str(max(0, index - 1))) + + self.app.push_screen(ConfirmDialog(f"Remove {title}?"), confirmed) + + def action_move(self, step: int): + index = self.selected_index() + if index is None: + return + target = index + step + self.run_now(lambda: portfolio.move_record(self.collection, index, step), "Moved", str(target)) + + def action_toggle_featured(self): + index = self.selected_index() + if index is None: + return + if not any(spec.path == "featured" for spec in self.collection.fields): + self.notify(f"{self.collection.title} entries have no featured flag", severity="warning") + return + record = self.records()[index] + record["featured"] = not record.get("featured", False) + if not record["featured"]: + record.pop("featured") + state = "Featured" if record.get("featured") else "Unfeatured" + self.run_now(lambda: portfolio.save_record(self.collection, index, record), f"{state} {portfolio.record_title(self.collection, record)}", str(index)) diff --git a/tools/content/tui/themes.py b/tools/content/tui/themes.py new file mode 100644 index 0000000..6f8b710 --- /dev/null +++ b/tools/content/tui/themes.py @@ -0,0 +1,57 @@ +from textual.theme import Theme + +import site_themes + + +def to_textual_theme(theme: dict) -> Theme: + colors = theme["colors"] + border_start, border_end = theme.get("activeBorder") or (colors["accent"], colors["accent"]) + return Theme( + name=theme["id"], + primary=border_start, + secondary=colors["blue"], + accent=border_end, + foreground=colors["foreground"], + background=colors["dark_background"], + surface=colors["background"], + panel=colors["lighter_background"], + boost=colors["selection"], + warning=colors["yellow"], + error=colors["red"], + success=colors["green"], + dark=theme.get("mode") != "light", + variables={ + "text-muted": colors["muted"], + "border": border_start, + "border-blurred": colors["selection"], + "block-cursor-background": colors["accent"], + "block-cursor-foreground": colors["dark_background"], + "block-cursor-text-style": "bold", + "block-cursor-blurred-background": colors["selection"], + "block-hover-background": colors["selection"], + "footer-key-foreground": colors["accent"], + "input-selection-background": f"{colors['accent']} 35%", + "input-cursor-background": colors["foreground"], + "scrollbar": colors["selection"], + "scrollbar-hover": colors["muted"], + "scrollbar-active": colors["accent"], + }, + ) + + +def load_site_themes() -> list[Theme]: + try: + themes = site_themes.load_themes() + except (OSError, ValueError): + return [] + textual_themes = [] + for theme in themes: + try: + textual_themes.append(to_textual_theme(theme)) + except (KeyError, ValueError): + continue + return textual_themes + + +def default_theme_id() -> str | None: + return site_themes.default_theme_id() diff --git a/tools/content/tui/themes_pane.py b/tools/content/tui/themes_pane.py new file mode 100644 index 0000000..67c8994 --- /dev/null +++ b/tools/content/tui/themes_pane.py @@ -0,0 +1,174 @@ +import copy + +from rich.markup import escape +from rich.text import Text +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import DataTable, Static + +import site_themes +from fields import FieldSpec +from tui.content_pane import ContentPane +from tui.dialogs import ConfirmDialog +from tui.record_editor import RecordEditor + +SWATCH_KEYS = ["accent", "red", "yellow", "orange", "green", "cyan", "blue", "magenta"] +THEME_FIELDS = [ + FieldSpec("id", "Id", required=True), + FieldSpec("name", "Name", required=True), + FieldSpec("mode", "Mode", "select", options=site_themes.THEME_MODES), + FieldSpec("wallpaper", "Wallpaper", "image"), + FieldSpec("borderStart", "Active border start", "color"), + FieldSpec("borderEnd", "Active border end", "color"), + *[FieldSpec(f"colors.{key}", key.replace("_", " ").capitalize(), "color", required=True) for key in site_themes.COLOR_KEYS], +] + + +def palette_swatches(theme: dict) -> Text: + colors = theme.get("colors", {}) + return Text.assemble(*[("██", colors[key]) for key in SWATCH_KEYS if key in colors]) + + +def to_form_record(theme: dict) -> dict: + record = copy.deepcopy(theme) + border = record.pop("activeBorder", None) or [] + if len(border) == 2: + record["borderStart"], record["borderEnd"] = border + return record + + +def from_form_record(record: dict) -> dict: + theme = copy.deepcopy(record) + start, end = theme.pop("borderStart", None), theme.pop("borderEnd", None) + if start and end: + theme["activeBorder"] = [start, end] + elif start or end: + raise ValueError("Set both active border colors, or leave both empty") + ordered_keys = ["id", "name", "mode", "wallpaper", "activeBorder", "colors"] + return {key: theme[key] for key in ordered_keys if key in theme} + + +def theme_details(theme: dict, is_default: bool) -> str: + colors = theme.get("colors", {}) + lines = [f"[b]{escape(theme.get('name', theme.get('id', '')))}[/b]{' [dim](default)[/dim]' if is_default else ''}", ""] + lines.append(f"[dim]{'Id':<20}[/dim]{escape(theme.get('id', ''))}") + lines.append(f"[dim]{'Mode':<20}[/dim]{escape(theme.get('mode', ''))}") + if theme.get("wallpaper"): + lines.append(f"[dim]{'Wallpaper':<20}[/dim]{escape(theme['wallpaper'])}") + if theme.get("activeBorder"): + start, end = theme["activeBorder"] + lines.append(f"[dim]{'Active border':<20}[/dim][{start}]██[/] {start} [{end}]██[/] {end}") + lines.append("") + for key in site_themes.COLOR_KEYS: + value = colors.get(key, "") + swatch = f"[{value}]██████[/]" if value else " " + lines.append(f"{swatch} [dim]{key.replace('_', ' '):<19}[/dim]{escape(value)}") + return "\n".join(lines) + + +class ThemesPane(ContentPane): + BINDINGS = [ + Binding("a", "add", "Duplicate"), + Binding("e", "edit", "Edit"), + Binding("d", "delete", "Delete"), + Binding("s", "set_default", "Set default"), + Binding("p", "preview", "Preview"), + Binding("r", "reload", "Reload"), + ] + + content_file = site_themes.THEMES_FILE + + def compose(self) -> ComposeResult: + with Horizontal(classes="split-layout"): + yield DataTable(cursor_type="row", zebra_stripes=True) + with VerticalScroll(classes="detail-scroll"): + yield Static("", id="theme-detail") + + def on_mount(self): + table = self.table() + table.add_column("", key="default") + table.add_column("Id", key="id") + table.add_column("Name", key="name") + table.add_column("Mode", key="mode") + table.add_column("Palette", key="palette") + self.reload() + + def reload(self, focus_key: str | None = None): + default_id = site_themes.default_theme_id() + table = self.table() + table.clear() + for theme in site_themes.load_themes(): + marker = "*" if theme.get("id") == default_id else "" + table.add_row(marker, theme.get("id", ""), theme.get("name", ""), theme.get("mode", ""), palette_swatches(theme), key=theme.get("id")) + self.restore_cursor(focus_key) + self.show_details(self.selected_key()) + + def selected_theme(self) -> dict | None: + return site_themes.find_theme(site_themes.load_themes(), self.selected_key() or "") + + def show_details(self, theme_id: str | None): + theme = site_themes.find_theme(site_themes.load_themes(), theme_id or "") + detail = self.query_one("#theme-detail", Static) + detail.update(theme_details(theme, theme_id == site_themes.default_theme_id()) if theme else "") + + @on(DataTable.RowHighlighted) + def highlight_changed(self, event: DataTable.RowHighlighted): + self.show_details(event.row_key.value if event.row_key else None) + + @on(DataTable.RowSelected) + def row_selected(self): + self.action_edit() + + def open_editor(self, original_id: str | None, theme: dict, title: str): + def saved(record: dict | None): + if record is None: + return + try: + updated = from_form_record(record) + site_themes.save_theme(original_id, updated) + except (OSError, ValueError) as error: + self.notify(str(error), title="Not saved", severity="error") + return + self.app.reload_site_themes(updated["id"]) + self.reload(updated["id"]) + self.notify(f"Saved {updated['name']}") + + validate = lambda record: site_themes.check_theme(from_form_record(record)) + editor = RecordEditor(title, THEME_FIELDS, to_form_record(theme), validate=validate, image_folder="wallpapers") + self.app.push_screen(editor, saved) + + def action_edit(self): + theme = self.selected_theme() + if theme: + self.open_editor(theme["id"], theme, f"Edit theme {theme.get('name', theme['id'])}") + + def action_add(self): + template = self.selected_theme() or {"mode": "dark", "colors": {}} + theme = copy.deepcopy(template) + theme["id"] = f"{template.get('id', 'theme')}-copy" + theme["name"] = f"{template.get('name', 'New theme')} Copy" + self.open_editor(None, theme, "New theme") + + def action_delete(self): + theme = self.selected_theme() + if not theme: + return + + def confirmed(should_delete: bool | None): + if should_delete: + self.run_now(lambda: site_themes.remove_theme(theme["id"]), f"Removed {theme.get('name', theme['id'])}") + + self.app.push_screen(ConfirmDialog(f"Remove the {theme.get('name', theme['id'])} theme?"), confirmed) + + def action_set_default(self): + theme = self.selected_theme() + if theme: + self.run_now(lambda: site_themes.set_default_theme(theme["id"]), f"{theme.get('name', theme['id'])} is now the default theme", theme["id"]) + + def action_preview(self): + theme = self.selected_theme() + if theme: + self.app.reload_site_themes(theme["id"]) + self.notify(f"Previewing {theme.get('name', theme['id'])}") diff --git a/tools/content/youtube.py b/tools/content/youtube.py index 88827f2..3b9aec8 100644 --- a/tools/content/youtube.py +++ b/tools/content/youtube.py @@ -6,6 +6,13 @@ import urllib.request VIDEO_ID_PATTERN = re.compile(r"^[\w-]{11}$") YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be"} PATH_PREFIXES = ("/shorts/", "/embed/", "/live/") +PLAYER_RESPONSE_MARKER = "ytInitialPlayerResponse = " +TOPIC_SUFFIX = " - Topic" +REQUEST_HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Accept-Language": "en-US,en;q=0.9", + "Cookie": "CONSENT=YES+1", +} def video_id(text: str) -> str | None: @@ -30,10 +37,71 @@ def watch_url(identifier: str) -> str: return f"https://music.youtube.com/watch?v={identifier}" -def video_title(identifier: str) -> str | None: +def cover_url(identifier: str) -> str: + return f"https://i.ytimg.com/vi/{identifier}/hqdefault.jpg" + + +def oembed(identifier: str) -> dict: query = urllib.parse.urlencode({"url": f"https://www.youtube.com/watch?v={identifier}", "format": "json"}) try: with urllib.request.urlopen(f"https://www.youtube.com/oembed?{query}", timeout=10) as response: - return json.load(response).get("title") + return json.load(response) except (OSError, ValueError): + return {} + + +def player_response(identifier: str) -> dict: + request = urllib.request.Request(f"https://www.youtube.com/watch?v={identifier}", headers=REQUEST_HEADERS) + try: + with urllib.request.urlopen(request, timeout=15) as response: + page = response.read().decode("utf-8", errors="replace") + except OSError: + return {} + start = page.find(PLAYER_RESPONSE_MARKER) + if start == -1: + return {} + try: + data, _ = json.JSONDecoder().raw_decode(page, start + len(PLAYER_RESPONSE_MARKER)) + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + +def clean_artist(name: str | None) -> str | None: + if name and name.endswith(TOPIC_SUFFIX): + return name[: -len(TOPIC_SUFFIX)] + return name + + +def as_number(value) -> int | None: + try: + return int(value) + except (TypeError, ValueError): return None + + +def video_details(identifier: str) -> dict: + response = player_response(identifier) + details = response.get("videoDetails") or {} + microformat = (response.get("microformat") or {}).get("playerMicroformatRenderer") or {} + fallback = oembed(identifier) if not details.get("title") else {} + + return { + "id": identifier, + "title": details.get("title") or fallback.get("title"), + "artist": clean_artist(details.get("author") or fallback.get("author_name")), + "durationSeconds": as_number(details.get("lengthSeconds")), + "views": as_number(details.get("viewCount")), + "published": (microformat.get("publishDate") or "")[:10] or None, + "description": details.get("shortDescription") or None, + "cover": cover_url(identifier), + "url": watch_url(identifier), + } + + +def format_duration(seconds: int | None) -> str: + if seconds is None: + return "-" + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"