Replace content CLI with Textual TUI
This commit is contained in:
1 parent
0d58ddd430
commit
737700a9a8
28 files changed
+2398
-162
No files matched your search
@@ -29,3 +29,7 @@ data/
|
||||
Update.md
|
||||
PROJECT.md
|
||||
blogs/
|
||||
|
||||
# Content tool
|
||||
tools/content/.venv/
|
||||
__pycache__/
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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)})
|
||||
@@ -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())
|
||||
@@ -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
@@ -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]
|
||||
+25
-45
@@ -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_songs(arguments) -> None:
|
||||
data, videos = load_videos()
|
||||
added = 0
|
||||
for text in arguments.songs:
|
||||
def add_song(text: str) -> str:
|
||||
identifier = youtube.video_id(text)
|
||||
if not identifier:
|
||||
print(f"Skipped {text}: not a YouTube link or video ID.")
|
||||
continue
|
||||
raise ValueError(f"{text} is not a YouTube link or video ID")
|
||||
data, videos = load_playlist()
|
||||
if find_index(videos, identifier) is not None:
|
||||
print(f"Skipped {identifier}: already in the playlist.")
|
||||
continue
|
||||
raise ValueError(f"{identifier} is already in the playlist")
|
||||
videos.append(youtube.watch_url(identifier))
|
||||
added += 1
|
||||
print(f"Added {youtube.video_title(identifier) or identifier}")
|
||||
if added:
|
||||
jsonfile.save(MUSIC_FILE, data)
|
||||
return identifier
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
textual>=8.2,<9
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Whitespace-only changes.
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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'])}")
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user