Replace content CLI with Textual TUI

This commit is contained in:
SirBlob committed 2026-11-01 20:46:34 -05:00
1 parent 0d58ddd430
commit 737700a9a8
28 files changed
+2403 -167

No files matched your search

+4
View File
@@ -29,3 +29,7 @@ data/
Update.md
PROJECT.md
blogs/
# Content tool
tools/content/.venv/
__pycache__/
+25 -12
View File
@@ -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 <url>`.
| 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 |
+2 -1
View File
@@ -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"
+110
View File
@@ -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())
+136
View File
@@ -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)})
-27
View File
@@ -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())
+147
View File
@@ -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)
+36 -75
View File
@@ -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]
+30 -50
View File
@@ -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)
+204
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
textual>=8.2,<9
+87
View File
@@ -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)
+30
View File
@@ -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"
View File
Whitespace-only changes.
+252
View File
@@ -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;
}
+162
View File
@@ -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)
+65
View File
@@ -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)
+106
View File
@@ -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)
+68
View File
@@ -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)
+95
View File
@@ -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)
+144
View File
@@ -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)
+73
View File
@@ -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)
+30
View File
@@ -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, "")
+140
View File
@@ -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)
+155
View File
@@ -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))
+57
View File
@@ -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()
+174
View File
@@ -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'])}")
+70 -2
View File
@@ -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}"