Compare commits

...
3 Commits
Author SHA1 Message Date
SirBlob 737700a9a8 Replace content CLI with Textual TUI 2026-11-01 20:46:34 -05:00
SirBlob 0d58ddd430 Move themes to JSON 2026-11-01 20:46:34 -05:00
SirBlob 0cfebc385f Show Goodreads books on about page 2026-11-01 20:46:34 -05:00
42 changed files with 3175 additions and 671 deletions

No files matched your search

+4
View File
@@ -29,3 +29,7 @@ data/
Update.md
PROJECT.md
blogs/
# Content tool
tools/content/.venv/
__pycache__/
+25 -12
View File
@@ -102,22 +102,33 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
Site content lives in `content/`.
### Content CLI
### Content editor
A small Python tool (standard library only) edits the playlist, shows and movies without touching the JSON by hand.
A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes.
```bash
bun run content music list --titles
bun run content music add https://www.youtube.com/watch?v=VIDEO_ID
bun run content music remove VIDEO_ID
bun run content movie add tt0816692 --rating 10
bun run content show add https://www.imdb.com/title/tt14688458/ --rating 10 --name "Silo"
bun run content show rate Silo 9
bun run content movie remove Interstellar
bun run content show list
bun run content:setup
bun run content
```
Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to skip the lookup. Run `bun run content --help` for every option.
`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual. Run it once.
| Key | Action |
| --- | --- |
| `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes |
| `a` | Add (YouTube link, IMDb link or Goodreads link) |
| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies and books) |
| `f` | Toggle featured on a project |
| `p` | Preview a theme in the editor |
| `t` | Switch the editor to the next theme |
| `s` | Move a book to another shelf, or make a theme the site default |
| `d` | Delete |
| `K` / `J` | Move a song or entry up or down |
| `u` / `U` | Refresh the selected book, or every book, from Goodreads |
| `r` | Reload from disk |
| `q` | Quit |
Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; fill in the name field to skip the lookup. Books are scraped from Goodreads once when added and saved to `books.json`. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py <url>`.
| File | Holds |
| --- | --- |
@@ -126,5 +137,7 @@ Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to s
| `experience.json` / `leadership.json` | Work experience and leadership roles |
| `projects/*.json` | Open source, packages, hackathons, research groups and organizations |
| `music.json` | YouTube links for the music player |
| `desktop.json` | Workspaces, desktop icons and the startup command |
| `books.json` | Books scraped from Goodreads for the about page |
| `desktop.json` | Workspaces, desktop icons, the startup command and the default theme |
| `themes.json` | Color themes for the site and the content editor |
| `blog/*.md` | Blog posts |
+1 -2
View File
@@ -75,6 +75,5 @@
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
],
"books": []
]
}
+3
View File
@@ -0,0 +1,3 @@
[
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" }
]
+96
View File
@@ -0,0 +1,96 @@
[
{
"id": "cerberus",
"name": "Cerberus",
"mode": "dark",
"wallpaper": "/wallpapers/astronaut2.jpg",
"activeBorder": ["#818cf8", "#e879f9"],
"colors": { "accent": "#818cf8", "selection": "#404040", "muted": "#a3a3a3", "background": "#171717", "dark_background": "#0f0f0f", "lighter_background": "#262626", "foreground": "#f5f5f5", "red": "#ef4444", "yellow": "#fbbf24", "orange": "#fb923c", "green": "#2dd4bf", "cyan": "#67e8f9", "blue": "#818cf8", "magenta": "#e879f9" }
},
{
"id": "flats",
"name": "Flats",
"mode": "dark",
"wallpaper": "/wallpapers/pastel-car.jpg",
"colors": { "accent": "#5fa3e8", "selection": "#1e2228", "muted": "#78828c", "background": "#0f1012", "dark_background": "#0a0b0c", "lighter_background": "#191c20", "foreground": "#cbd2d9", "red": "#d53838", "yellow": "#d58638", "orange": "#d56338", "green": "#38d586", "cyan": "#38d4d5", "blue": "#3886d5", "magenta": "#d53886" }
},
{
"id": "tokyo-night",
"name": "Tokyo Night",
"mode": "dark",
"wallpaper": "/wallpapers/sunset-lake.png",
"colors": { "accent": "#7aa2f7", "selection": "#292e42", "muted": "#565f89", "background": "#1a1b26", "dark_background": "#13141c", "lighter_background": "#24283b", "foreground": "#a9b1d6", "red": "#f7768e", "yellow": "#e0af68", "orange": "#eb927b", "green": "#9ece6a", "cyan": "#449dab", "blue": "#7aa2f7", "magenta": "#ad8ee6" }
},
{
"id": "night-cat",
"name": "Night Cat",
"mode": "dark",
"wallpaper": "/wallpapers/cat.jpg",
"activeBorder": ["#ebe68f", "#2c5a78"],
"colors": { "accent": "#ebe68f", "selection": "#24495f", "muted": "#557386", "background": "#0a161e", "dark_background": "#060e14", "lighter_background": "#13252f", "foreground": "#c8d6df", "red": "#e06c75", "yellow": "#ebe68f", "orange": "#e5a46e", "green": "#8ccf9e", "cyan": "#6fc3d4", "blue": "#5a9cc4", "magenta": "#b392d4" }
},
{
"id": "catppuccin",
"name": "Catppuccin",
"mode": "dark",
"wallpaper": "/wallpapers/totoro.png",
"colors": { "accent": "#89b4fa", "selection": "#45475a", "muted": "#6c7086", "background": "#1e1e2e", "dark_background": "#161622", "lighter_background": "#313244", "foreground": "#cdd6f4", "red": "#f38ba8", "yellow": "#f9e2af", "orange": "#fab387", "green": "#a6e3a1", "cyan": "#94e2d5", "blue": "#89b4fa", "magenta": "#cba6f7" }
},
{
"id": "arch",
"name": "Arch Linux",
"mode": "dark",
"wallpaper": "/wallpapers/pacman-black.png",
"activeBorder": ["#33ccff", "#00ff99"],
"colors": { "accent": "#1793d1", "selection": "#21262d", "muted": "#8b949e", "background": "#0d1117", "dark_background": "#090c10", "lighter_background": "#161b22", "foreground": "#c9d1d9", "red": "#ff7b72", "yellow": "#d29922", "orange": "#ffa657", "green": "#3fb950", "cyan": "#39c5cf", "blue": "#58a6ff", "magenta": "#bc8cff" }
},
{
"id": "gruvbox",
"name": "Gruvbox",
"mode": "dark",
"wallpaper": "/wallpapers/awesome.png",
"colors": { "accent": "#7daea3", "selection": "#504945", "muted": "#928374", "background": "#282828", "dark_background": "#1e1e1e", "lighter_background": "#3c3836", "foreground": "#d4be98", "red": "#ea6962", "yellow": "#d8a657", "orange": "#e1875c", "green": "#a9b665", "cyan": "#89b482", "blue": "#7daea3", "magenta": "#d3869b" }
},
{
"id": "nord",
"name": "Nord",
"mode": "dark",
"wallpaper": "/wallpapers/nord_minimal_cat.png",
"colors": { "accent": "#81a1c1", "selection": "#434c5e", "muted": "#7b88a1", "background": "#2e3440", "dark_background": "#222730", "lighter_background": "#3b4252", "foreground": "#d8dee9", "red": "#bf616a", "yellow": "#ebcb8b", "orange": "#d5967a", "green": "#a3be8c", "cyan": "#88c0d0", "blue": "#81a1c1", "magenta": "#b48ead" }
},
{
"id": "kanagawa",
"name": "Kanagawa",
"mode": "dark",
"wallpaper": "/wallpapers/pixel-earth.png",
"colors": { "accent": "#dcd7ba", "selection": "#363646", "muted": "#727169", "background": "#1f1f28", "dark_background": "#17171e", "lighter_background": "#2a2a37", "foreground": "#dcd7ba", "red": "#c34043", "yellow": "#c0a36e", "orange": "#c17158", "green": "#76946a", "cyan": "#6a9589", "blue": "#7e9cd8", "magenta": "#957fb8" }
},
{
"id": "wintry",
"name": "Wintry",
"mode": "dark",
"wallpaper": "/wallpapers/perfect-blue.jpg",
"colors": { "accent": "#7dd3fc", "selection": "#334155", "muted": "#94a3b8", "background": "#0f172a", "dark_background": "#0a1020", "lighter_background": "#1e293b", "foreground": "#f1f5f9", "red": "#f43f5e", "yellow": "#fde047", "orange": "#fb923c", "green": "#4ade80", "cyan": "#67e8f9", "blue": "#60a5fa", "magenta": "#c084fc" }
},
{
"id": "rose",
"name": "Rose",
"mode": "dark",
"wallpaper": "/wallpapers/cherry_blossoms.jpg",
"colors": { "accent": "#fb7185", "selection": "#5f484f", "muted": "#c48b95", "background": "#1f181b", "dark_background": "#171214", "lighter_background": "#2e2327", "foreground": "#ffe4e6", "red": "#f43f5e", "yellow": "#fde047", "orange": "#fb923c", "green": "#34d399", "cyan": "#67e8f9", "blue": "#60a5fa", "magenta": "#e879f9" }
},
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"mode": "light",
"wallpaper": "/wallpapers/yosemite-color-block.jpg",
"colors": { "accent": "#1e66f5", "selection": "#ccd0da", "muted": "#8c8fa1", "background": "#eff1f5", "dark_background": "#e3e4e8", "lighter_background": "#dce0e8", "foreground": "#4c4f69", "red": "#d20f39", "yellow": "#df8e1d", "orange": "#d84e2b", "green": "#40a02b", "cyan": "#179299", "blue": "#1e66f5", "magenta": "#ea76cb" }
},
{
"id": "rose-pine",
"name": "Rose Pine Dawn",
"mode": "light",
"wallpaper": "/wallpapers/material-pastel.jpg",
"colors": { "accent": "#56949f", "selection": "#dfdad9", "muted": "#9893a5", "background": "#faf4ed", "dark_background": "#ede7e1", "lighter_background": "#f2e9e1", "foreground": "#575279", "red": "#b4637a", "yellow": "#ea9d34", "orange": "#cf8057", "green": "#286983", "cyan": "#d7827e", "blue": "#56949f", "magenta": "#907aa9" }
}
]
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "vite build",
"preview": "vite preview",
"start": "bun --bun vite build && bun ./build/index.js",
"content": "python3 tools/content/cli.py",
"content": "tools/content/.venv/bin/python tools/content/app.py",
"content:setup": "python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
-24
View File
@@ -1,24 +0,0 @@
import type { Book } from '#lib/content';
const coverBaseUrl = 'https://covers.openlibrary.org/b/isbn';
const bookPageBaseUrl = 'https://openlibrary.org/isbn';
function cleanIsbn(isbn: string): string {
return isbn.replace(/[^0-9X]/gi, '');
}
export function bookCover(book: Book): string | null {
return book.isbn ? `${coverBaseUrl}/${cleanIsbn(book.isbn)}-L.jpg?default=false` : null;
}
export function bookLink(book: Book): string | null {
if (book.url) return book.url;
return book.isbn ? `${bookPageBaseUrl}/${cleanIsbn(book.isbn)}` : null;
}
export function sortBooks(books: Book[]): Book[] {
return [...books].sort((first, second) => {
const readingOrder = Number(second.status === 'reading') - Number(first.status === 'reading');
return readingOrder || (second.rating ?? 0) - (first.rating ?? 0);
});
}
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import { bookCover, bookLink } from '#lib/books/books';
let { book }: { book: Book } = $props();
let coverFailed = $state(false);
const cover = $derived(bookCover(book));
const link = $derived(bookLink(book));
</script>
<svelte:element
this={link ? 'a' : 'div'}
class="book-card"
href={link}
target={link ? '_blank' : undefined}
rel={link ? 'noopener noreferrer' : undefined}
title="{book.title} by {book.author}"
>
<div class="cover">
{#if cover && !coverFailed}
<img src={cover} alt="" loading="lazy" data-no-viewer onerror={() => (coverFailed = true)} />
{:else}
<span class="placeholder">
<Icon icon="mdi:book-open-page-variant-outline" width="36" />
<span class="placeholder-title">{book.title}</span>
</span>
{/if}
{#if book.status === 'reading'}
<span class="badge reading"><Icon icon="mdi:bookmark" width="13" />reading</span>
{:else if book.rating}
<span class="badge"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
{/if}
</div>
<div class="text">
<span class="title">{book.title}</span>
<span class="author">{book.author}</span>
</div>
</svelte:element>
<style>
.book-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
a.book-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
a.book-card:hover .cover img {
transform: scale(1.05);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
padding: 0.75rem;
color: var(--muted);
text-align: center;
}
.placeholder-title {
font-size: 0.8rem;
}
.badge {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--background);
color: var(--yellow);
font-size: 0.78rem;
}
.badge.reading {
background: var(--green);
color: var(--background);
}
.text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.title,
.author {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
color: var(--accent);
font-weight: 700;
}
.author {
color: var(--muted);
font-size: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
.cover img {
transition: none;
}
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import type { Book } from '#lib/content';
import Pagination from '#lib/components/ui/Pagination.svelte';
import BookCard from './BookCard.svelte';
let { books }: { books: Book[] } = $props();
const booksPerPage = 12;
let currentPage = $state(1);
const totalPages = $derived(Math.max(1, Math.ceil(books.length / booksPerPage)));
const visibleBooks = $derived(books.slice((currentPage - 1) * booksPerPage, currentPage * booksPerPage));
</script>
<div class="book-grid">
{#each visibleBooks as book (`${book.title}-${book.author}`)}
<BookCard {book} />
{/each}
</div>
<Pagination bind:currentPage {totalPages} />
<style>
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
let { book }: { book: Book } = $props();
let coverFailed = $state(false);
const hasOwnRating = $derived(book.rating !== undefined);
</script>
<a class="book-card" href={book.url} target="_blank" rel="noopener noreferrer" title="{book.title} by {book.authors.join(', ')}">
<div class="cover">
{#if book.cover && !coverFailed}
<img src={book.cover} alt="" loading="lazy" onerror={() => (coverFailed = true)} />
{:else}
<span class="placeholder">
<Icon icon="mdi:book-open-page-variant-outline" width="32" />
<span class="placeholder-title">{book.title}</span>
</span>
{/if}
{#if hasOwnRating}
<span class="rating"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
{:else if book.averageRating}
<span class="rating community" title="Goodreads average"><Icon icon="mdi:star-outline" width="13" />{book.averageRating}</span>
{/if}
{#if book.description}
<p class="description">{book.description}</p>
{/if}
</div>
<span class="title">{book.title}</span>
<span class="meta">{book.authors.join(', ')}</span>
{#if book.genres.length}
<span class="meta">{book.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
<style>
.book-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.cover {
position: relative;
margin-bottom: 0.15rem;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.book-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.book-card:hover img {
transform: scale(1.05);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
padding: 0.6rem;
color: var(--muted);
text-align: center;
}
.placeholder-title {
font-size: 0.75rem;
}
.rating {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--yellow);
font-size: 0.75rem;
}
.rating.community {
color: var(--muted);
}
.description {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: -webkit-box;
margin: 0;
padding: 0.55rem;
overflow: hidden;
background: color-mix(in srgb, var(--background) 94%, transparent);
border-top: 1px solid var(--accent);
font-size: 0.72rem;
line-height: 1.45;
-webkit-line-clamp: 8;
line-clamp: 8;
-webkit-box-orient: vertical;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.book-card:hover .description,
.book-card:focus-visible .description {
transform: translateY(0);
}
.title {
display: -webkit-box;
overflow: hidden;
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
line-height: 1.3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.72rem;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) {
.cover,
.cover img,
.description {
transition: none;
}
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import BookCard from './BookCard.svelte';
let { title, icon, books }: { title: string; icon: string; books: Book[] } = $props();
const collapsedCount = 12;
let showAll = $state(false);
const shownBooks = $derived(showAll ? books : books.slice(0, collapsedCount));
</script>
<h3 class="subheading"><Icon {icon} width="16" /> {title} <span class="muted">{books.length}</span></h3>
<div class="book-grid">
{#each shownBooks as book (book.goodreadsId)}
<BookCard {book} />
{/each}
</div>
{#if books.length > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${books.length}`}
</button>
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.9rem 0.75rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import Panel from '#lib/components/ui/Panel.svelte';
import BookShelfGrid from './BookShelfGrid.svelte';
let { books }: { books: Book[] } = $props();
const shelfColors = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
const byRating = (first: Book, second: Book) => (second.rating ?? -1) - (first.rating ?? -1);
const read = $derived(books.filter((book) => book.shelf === 'read').sort(byRating));
const reading = $derived(books.filter((book) => book.shelf === 'reading'));
const wantToRead = $derived(books.filter((book) => book.shelf === 'want-to-read'));
const shelfCounts = $derived([
{ label: 'Read', count: read.length, color: shelfColors.read },
{ label: 'Reading', count: reading.length, color: shelfColors.reading },
{ label: 'Want to read', count: wantToRead.length, color: shelfColors['want-to-read'] }
]);
const shownCounts = $derived(shelfCounts.filter((shelf) => shelf.count > 0));
const pagesRead = $derived(read.reduce((total, book) => total + (book.pages ?? 0), 0));
const authorCount = $derived(new Set(read.flatMap((book) => book.authors)).size);
const topGenres = $derived.by(() => {
const counts = new Map<string, number>();
read.flatMap((book) => book.genres).forEach((genre) => counts.set(genre, (counts.get(genre) ?? 0) + 1));
return [...counts.entries()].sort((first, second) => second[1] - first[1]).slice(0, 3).map(([genre]) => genre);
});
const rows = $derived([
['Pages read', pagesRead.toLocaleString('en-US')],
['Authors', `${authorCount}`],
...(topGenres.length ? [['Top genres', topGenres.join(', ')]] : [])
]);
</script>
<div class="overview" class:single={!reading.length}>
<Panel title="bookshelf" icon="mdi:bookshelf">
<div class="fetch">
{#each shelfCounts as shelf (shelf.label)}
<div><span class="label">{shelf.label}</span>: <span style:color="var(--{shelf.color})">{shelf.count}</span></div>
{/each}
{#each rows as [label, value] (label)}
<div><span class="label">{label}</span>: {value}</div>
{/each}
{#if shownCounts.length}
<div class="breakdown" role="img" aria-label="Books by shelf">
{#each shownCounts as shelf (shelf.label)}
<span class="block" style:flex-grow={shelf.count} style:background="var(--{shelf.color})" title="{shelf.label}: {shelf.count}"></span>
{/each}
</div>
{/if}
</div>
</Panel>
{#if reading.length}
<Panel title="Currently reading" icon="mdi:book-open-variant">
<div class="reading">
{#each reading as book (book.goodreadsId)}
<a class="reading-row" href={book.url} target="_blank" rel="noopener noreferrer">
{#if book.cover}
<img src={book.cover} alt={book.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:book-outline" width="22" /></span>
{/if}
<span class="reading-text">
<span class="title">{book.title}</span>
<span class="muted detail">{book.authors.join(', ')}</span>
<span class="muted detail">
{[book.pages ? `${book.pages} pages` : '', book.published?.slice(0, 4) ?? ''].filter(Boolean).join(' · ')}
</span>
</span>
</a>
{/each}
</div>
</Panel>
{/if}
</div>
{#if read.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={read} />
{/if}
{#if wantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={wantToRead} />
{/if}
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.overview.single {
grid-template-columns: minmax(0, 1fr);
}
.fetch {
line-height: 1.5;
}
.label {
color: var(--blue);
font-weight: 700;
}
.breakdown {
display: flex;
margin-top: 0.75rem;
}
.block {
flex-basis: 0;
min-width: 0.5rem;
height: 1rem;
}
.reading {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reading-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem;
border: 1px solid transparent;
}
.reading-row:hover {
border-color: var(--accent);
}
.reading-row img,
.cover-fallback {
flex-shrink: 0;
width: 52px;
height: 78px;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.reading-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.title,
.detail {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
font-size: 0.9rem;
}
.detail {
font-size: 0.75rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+3
View File
@@ -11,9 +11,11 @@ import hackathonsData from '#content/projects/hackathons.json';
import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json';
import booksData from '#content/books.json';
import type {
AboutContent,
BlogSettings,
Book,
DesktopContent,
Experience,
Leadership,
@@ -37,6 +39,7 @@ export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
export const books: Book[] = booksData as Book[];
function featuredFirst(projects: Project[]): Project[] {
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
+13 -6
View File
@@ -139,15 +139,23 @@ export interface RatedTitle {
url: string;
}
export type BookStatus = 'read' | 'reading';
export type BookShelf = 'read' | 'reading' | 'want-to-read';
export interface Book {
goodreadsId: string;
url: string;
title: string;
author: string;
authors: string[];
cover: string | null;
description: string | null;
pages: number | null;
published: string | null;
genres: string[];
averageRating: number | null;
ratingCount: number | null;
shelf: BookShelf;
rating?: number;
isbn?: string;
url?: string;
status?: BookStatus;
added?: string;
}
export interface LabeledValue {
@@ -172,7 +180,6 @@ export interface AboutContent {
games: Game[];
shows: RatedTitle[];
movies: RatedTitle[];
books?: Book[];
}
export type GameName = 'snake' | 'breakout';
+3 -291
View File
@@ -1,3 +1,5 @@
import themesData from '#content/themes.json';
export interface ThemePalette {
accent: string;
selection: string;
@@ -24,297 +26,7 @@ export interface Theme {
colors: ThemePalette;
}
export const themes: Theme[] = [
{
id: 'cerberus',
name: 'Cerberus',
mode: 'dark',
wallpaper: '/wallpapers/astronaut2.jpg',
activeBorder: ['#818cf8', '#e879f9'],
colors: {
accent: '#818cf8',
selection: '#404040',
muted: '#a3a3a3',
background: '#171717',
dark_background: '#0f0f0f',
lighter_background: '#262626',
foreground: '#f5f5f5',
red: '#ef4444',
yellow: '#fbbf24',
orange: '#fb923c',
green: '#2dd4bf',
cyan: '#67e8f9',
blue: '#818cf8',
magenta: '#e879f9'
}
},
{
id: 'flats',
name: 'Flats',
mode: 'dark',
wallpaper: '/wallpapers/pastel-car.jpg',
colors: {
accent: '#5fa3e8',
selection: '#1e2228',
muted: '#78828c',
background: '#0f1012',
dark_background: '#0a0b0c',
lighter_background: '#191c20',
foreground: '#cbd2d9',
red: '#d53838',
yellow: '#d58638',
orange: '#d56338',
green: '#38d586',
cyan: '#38d4d5',
blue: '#3886d5',
magenta: '#d53886'
}
},
{
id: 'tokyo-night',
name: 'Tokyo Night',
mode: 'dark',
wallpaper: '/wallpapers/sunset-lake.png',
colors: {
accent: '#7aa2f7',
selection: '#292e42',
muted: '#565f89',
background: '#1a1b26',
dark_background: '#13141c',
lighter_background: '#24283b',
foreground: '#a9b1d6',
red: '#f7768e',
yellow: '#e0af68',
orange: '#eb927b',
green: '#9ece6a',
cyan: '#449dab',
blue: '#7aa2f7',
magenta: '#ad8ee6'
}
},
{
id: 'night-cat',
name: 'Night Cat',
mode: 'dark',
wallpaper: '/wallpapers/cat.jpg',
activeBorder: ['#ebe68f', '#2c5a78'],
colors: {
accent: '#ebe68f',
selection: '#24495f',
muted: '#557386',
background: '#0a161e',
dark_background: '#060e14',
lighter_background: '#13252f',
foreground: '#c8d6df',
red: '#e06c75',
yellow: '#ebe68f',
orange: '#e5a46e',
green: '#8ccf9e',
cyan: '#6fc3d4',
blue: '#5a9cc4',
magenta: '#b392d4'
}
},
{
id: 'catppuccin',
name: 'Catppuccin',
mode: 'dark',
wallpaper: '/wallpapers/totoro.png',
colors: {
accent: '#89b4fa',
selection: '#45475a',
muted: '#6c7086',
background: '#1e1e2e',
dark_background: '#161622',
lighter_background: '#313244',
foreground: '#cdd6f4',
red: '#f38ba8',
yellow: '#f9e2af',
orange: '#fab387',
green: '#a6e3a1',
cyan: '#94e2d5',
blue: '#89b4fa',
magenta: '#cba6f7'
}
},
{
id: 'arch',
name: 'Arch Linux',
mode: 'dark',
wallpaper: '/wallpapers/pacman-black.png',
activeBorder: ['#33ccff', '#00ff99'],
colors: {
accent: '#1793d1',
selection: '#21262d',
muted: '#8b949e',
background: '#0d1117',
dark_background: '#090c10',
lighter_background: '#161b22',
foreground: '#c9d1d9',
red: '#ff7b72',
yellow: '#d29922',
orange: '#ffa657',
green: '#3fb950',
cyan: '#39c5cf',
blue: '#58a6ff',
magenta: '#bc8cff'
}
},
{
id: 'gruvbox',
name: 'Gruvbox',
mode: 'dark',
wallpaper: '/wallpapers/awesome.png',
colors: {
accent: '#7daea3',
selection: '#504945',
muted: '#928374',
background: '#282828',
dark_background: '#1e1e1e',
lighter_background: '#3c3836',
foreground: '#d4be98',
red: '#ea6962',
yellow: '#d8a657',
orange: '#e1875c',
green: '#a9b665',
cyan: '#89b482',
blue: '#7daea3',
magenta: '#d3869b'
}
},
{
id: 'nord',
name: 'Nord',
mode: 'dark',
wallpaper: '/wallpapers/nord_minimal_cat.png',
colors: {
accent: '#81a1c1',
selection: '#434c5e',
muted: '#7b88a1',
background: '#2e3440',
dark_background: '#222730',
lighter_background: '#3b4252',
foreground: '#d8dee9',
red: '#bf616a',
yellow: '#ebcb8b',
orange: '#d5967a',
green: '#a3be8c',
cyan: '#88c0d0',
blue: '#81a1c1',
magenta: '#b48ead'
}
},
{
id: 'kanagawa',
name: 'Kanagawa',
mode: 'dark',
wallpaper: '/wallpapers/pixel-earth.png',
colors: {
accent: '#dcd7ba',
selection: '#363646',
muted: '#727169',
background: '#1f1f28',
dark_background: '#17171e',
lighter_background: '#2a2a37',
foreground: '#dcd7ba',
red: '#c34043',
yellow: '#c0a36e',
orange: '#c17158',
green: '#76946a',
cyan: '#6a9589',
blue: '#7e9cd8',
magenta: '#957fb8'
}
},
{
id: 'wintry',
name: 'Wintry',
mode: 'dark',
wallpaper: '/wallpapers/perfect-blue.jpg',
colors: {
accent: '#7dd3fc',
selection: '#334155',
muted: '#94a3b8',
background: '#0f172a',
dark_background: '#0a1020',
lighter_background: '#1e293b',
foreground: '#f1f5f9',
red: '#f43f5e',
yellow: '#fde047',
orange: '#fb923c',
green: '#4ade80',
cyan: '#67e8f9',
blue: '#60a5fa',
magenta: '#c084fc'
}
},
{
id: 'rose',
name: 'Rose',
mode: 'dark',
wallpaper: '/wallpapers/cherry_blossoms.jpg',
colors: {
accent: '#fb7185',
selection: '#5f484f',
muted: '#c48b95',
background: '#1f181b',
dark_background: '#171214',
lighter_background: '#2e2327',
foreground: '#ffe4e6',
red: '#f43f5e',
yellow: '#fde047',
orange: '#fb923c',
green: '#34d399',
cyan: '#67e8f9',
blue: '#60a5fa',
magenta: '#e879f9'
}
},
{
id: 'catppuccin-latte',
name: 'Catppuccin Latte',
mode: 'light',
wallpaper: '/wallpapers/yosemite-color-block.jpg',
colors: {
accent: '#1e66f5',
selection: '#ccd0da',
muted: '#8c8fa1',
background: '#eff1f5',
dark_background: '#e3e4e8',
lighter_background: '#dce0e8',
foreground: '#4c4f69',
red: '#d20f39',
yellow: '#df8e1d',
orange: '#d84e2b',
green: '#40a02b',
cyan: '#179299',
blue: '#1e66f5',
magenta: '#ea76cb'
}
},
{
id: 'rose-pine',
name: 'Rose Pine Dawn',
mode: 'light',
wallpaper: '/wallpapers/material-pastel.jpg',
colors: {
accent: '#56949f',
selection: '#dfdad9',
muted: '#9893a5',
background: '#faf4ed',
dark_background: '#ede7e1',
lighter_background: '#f2e9e1',
foreground: '#575279',
red: '#b4637a',
yellow: '#ea9d34',
orange: '#cf8057',
green: '#286983',
cyan: '#d7827e',
blue: '#56949f',
magenta: '#907aa9'
}
}
];
export const themes: Theme[] = themesData as Theme[];
export function findTheme(id: string | null | undefined): Theme | undefined {
return themes.find((theme) => theme.id === id);
+3 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { about, profile } from '#lib/content';
import { about, books, profile } from '#lib/content';
import PageHeader from '#lib/components/ui/PageHeader.svelte';
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
import SectionNav from '#lib/components/ui/SectionNav.svelte';
@@ -8,8 +8,7 @@
import AniListSection from '#lib/components/about/AniListSection.svelte';
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
import GameCard from '#lib/components/about/GameCard.svelte';
import BookGrid from '#lib/components/about/BookGrid.svelte';
import { sortBooks } from '#lib/books/books';
import BooksSection from '#lib/components/about/books/BooksSection.svelte';
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
import type { PageProps } from './$types';
@@ -19,7 +18,6 @@
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
const shows = [...about.shows].sort(byRating);
const movies = [...about.movies].sort(byRating);
const books = sortBooks(about.books ?? []);
const mediaGroups = [
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
@@ -76,7 +74,7 @@
{#if books.length}
<Section title="Books" id="books">
<BookGrid {books} />
<BooksSection {books} />
</Section>
{/if}
+110
View File
@@ -0,0 +1,110 @@
import sys
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane
import portfolio
from tui.books_pane import BooksPane
from tui.media_pane import MediaPane
from tui.music_pane import MusicPane
from tui.profile_pane import ProfilePane
from tui.projects_pane import ProjectsPane
from tui.record_pane import RecordListPane
from tui.themes import default_theme_id, load_site_themes
from tui.themes_pane import ThemesPane
TABS = [
("music", "Music"),
("shows", "Shows"),
("movies", "Movies"),
("books", "Books"),
("profile", "Profile"),
("experience", "Experience"),
("leadership", "Leadership"),
("projects", "Projects"),
("themes", "Themes"),
]
class ContentApp(App):
TITLE = "sirblob@engineering"
SUB_TITLE = "~/content"
CSS_PATH = "tui/app.tcss"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("t", "next_theme", "Theme"),
*[Binding(str(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
]
def __init__(self):
super().__init__()
self.site_theme_ids: list[str] = []
self.reload_site_themes(default_theme_id())
def reload_site_themes(self, show_theme_id: str | None = None):
self.site_theme_ids = []
for theme in load_site_themes():
self.register_theme(theme)
self.site_theme_ids.append(theme.name)
if not self.site_theme_ids:
return
target = show_theme_id if show_theme_id in self.site_theme_ids else self.site_theme_ids[0]
if target == self.theme:
fallback = next(theme for theme in self.available_themes if theme != target)
self.theme = fallback
self.theme = target
def action_next_theme(self):
if not self.site_theme_ids:
return
current = self.site_theme_ids.index(self.theme) if self.theme in self.site_theme_ids else -1
self.theme = self.site_theme_ids[(current + 1) % len(self.site_theme_ids)]
self.notify(f"Theme: {self.theme}")
def compose(self) -> ComposeResult:
yield Header()
with TabbedContent(initial="music"):
with TabPane("1 Music", id="music"):
yield MusicPane()
with TabPane("2 Shows", id="shows"):
yield MediaPane("show")
with TabPane("3 Movies", id="movies"):
yield MediaPane("movie")
with TabPane("4 Books", id="books"):
yield BooksPane()
with TabPane("5 Profile", id="profile"):
yield ProfilePane()
with TabPane("6 Experience", id="experience"):
yield RecordListPane(portfolio.EXPERIENCE)
with TabPane("7 Leadership", id="leadership"):
yield RecordListPane(portfolio.LEADERSHIP)
with TabPane("8 Projects", id="projects"):
yield ProjectsPane()
with TabPane("9 Themes", id="themes"):
yield ThemesPane()
yield Footer()
def on_mount(self):
self.focus_active_table()
def action_show_tab(self, tab_id: str):
self.query_one(TabbedContent).active = tab_id
@on(TabbedContent.TabActivated)
def tab_changed(self):
self.focus_active_table()
def focus_active_table(self):
tabs = self.query_one(TabbedContent)
tabs.get_pane(tabs.active).query_one(DataTable).focus()
def main() -> int:
ContentApp().run()
return 0
if __name__ == "__main__":
sys.exit(main())
+136
View File
@@ -0,0 +1,136 @@
import datetime
import goodreads
import jsonfile
BOOKS_FILE = "books.json"
SHELVES = ["read", "reading", "want-to-read"]
DESCRIPTION_MAX_LENGTH = 600
GENRE_LIMIT = 5
def check_rating(rating: int) -> int:
if not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def check_shelf(shelf: str) -> str:
if shelf not in SHELVES:
raise ValueError(f"Shelf must be one of {', '.join(SHELVES)}")
return shelf
def load_books() -> list[dict]:
if not jsonfile.content_path(BOOKS_FILE).exists():
return []
return jsonfile.load(BOOKS_FILE)
def save_books(books: list[dict]) -> None:
jsonfile.save(BOOKS_FILE, books)
def find_index(books: list[dict], text: str) -> int | None:
identifier = goodreads.book_id(text) or (text if text.isdigit() else None)
for index, book in enumerate(books):
if identifier and book.get("goodreadsId") == identifier:
return index
if book.get("title", "").lower() == text.lower():
return index
return None
def short_description(text: str | None) -> str | None:
if not text or len(text) <= DESCRIPTION_MAX_LENGTH:
return text
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
def stored_fields(scraped: dict) -> dict:
return {
"goodreadsId": scraped["goodreadsId"],
"url": scraped["url"],
"title": scraped["title"],
"authors": scraped["authors"],
"cover": scraped["cover"],
"description": short_description(scraped["description"]),
"pages": scraped["pages"],
"published": scraped["published"],
"genres": scraped["genres"][:GENRE_LIMIT],
"averageRating": scraped["averageRating"],
"ratingCount": scraped["ratingCount"],
}
def update_book(text: str, change) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
raise ValueError(f"{text} is not listed")
books[index] = change(books[index])
save_books(books)
return books[index]
def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict:
check_shelf(shelf)
if rating is not None:
check_rating(rating)
identifier = goodreads.book_id(url)
if not identifier:
raise ValueError(f"{url} is not a Goodreads book link")
if find_index(load_books(), identifier) is not None:
raise ValueError(f"{identifier} is already listed")
scraped = goodreads.scrape_book(url)
if not scraped["title"]:
raise ValueError("Could not read the book from Goodreads")
book = stored_fields(scraped)
book["shelf"] = shelf
if rating is not None:
book["rating"] = rating
book["added"] = datetime.date.today().isoformat()
books = load_books()
books.append(book)
save_books(books)
return book
def remove_book(text: str) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
raise ValueError(f"{text} is not listed")
removed = books.pop(index)
save_books(books)
return removed
def rate_book(text: str, rating: int | None) -> dict:
if rating is not None:
check_rating(rating)
def change(book: dict) -> dict:
updated = {key: value for key, value in book.items() if key != "rating"}
if rating is not None:
updated["rating"] = rating
return updated
return update_book(text, change)
def move_book(text: str, shelf: str) -> dict:
check_shelf(shelf)
return update_book(text, lambda book: {**book, "shelf": shelf})
def refresh_book(text: str) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
raise ValueError(f"{text} is not listed")
scraped = goodreads.scrape_book(books[index]["url"])
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
-27
View File
@@ -1,27 +0,0 @@
import argparse
import sys
import media
import music
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="content", description="Edit the website content in content/")
subparsers = parser.add_subparsers(dest="section", required=True)
music.register(subparsers)
media.register(subparsers)
return parser
def main() -> int:
arguments = build_parser().parse_args()
try:
arguments.handler(arguments)
except (OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+147
View File
@@ -0,0 +1,147 @@
from dataclasses import dataclass, field
SEPARATOR = "|"
FIELD_HINTS = {
"lines": "one per line",
"tags": "comma separated",
"images": "one image path per line",
"link_map": "one per line: name url",
"link_list": "one per line: label | url",
"awards": "one per line: track | place",
"socials": "one per line: name | icon | url",
"skill_map": "one per line: Category: skill, skill",
}
MULTILINE_KINDS = {"longtext", "lines", "images", "link_map", "link_list", "awards", "socials", "skill_map"}
@dataclass
class FieldSpec:
path: str
label: str
kind: str = "text"
required: bool = False
options: list[str] = field(default_factory=list)
@property
def hint(self) -> str:
return FIELD_HINTS.get(self.kind, "")
def get_path(record: dict, path: str):
value = record
for part in path.split("."):
if not isinstance(value, dict):
return None
value = value.get(part)
return value
def is_empty(value) -> bool:
return value is None or value is False or value == "" or value == [] or value == {}
def set_path(record: dict, path: str, value) -> None:
parts = path.split(".")
parents = [record]
for part in parts[:-1]:
child = parents[-1].get(part)
if not isinstance(child, dict):
child = {}
parents[-1][part] = child
parents.append(child)
if is_empty(value):
parents[-1].pop(parts[-1], None)
else:
parents[-1][parts[-1]] = value
for depth in range(len(parts) - 1, 0, -1):
if not parents[depth]:
parents[depth - 1].pop(parts[depth - 1], None)
def split_parts(line: str, count: int) -> list[str]:
parts = [part.strip() for part in line.split(SEPARATOR, count - 1)]
return parts + [""] * (count - len(parts))
def non_empty_lines(text: str) -> list[str]:
return [line.strip() for line in text.splitlines() if line.strip()]
def to_form_text(kind: str, value) -> str:
if value is None:
return ""
if kind in ("lines", "images"):
return "\n".join(value)
if kind == "tags":
return ", ".join(value)
if kind == "link_map":
return "\n".join(f"{name} {url}" for name, url in value.items())
if kind == "link_list":
return "\n".join(f"{link.get('label', '')} {SEPARATOR} {link.get('url', '')}" for link in value)
if kind == "awards":
return "\n".join(f"{award.get('track', '')} {SEPARATOR} {award.get('place', '')}" for award in value)
if kind == "socials":
return "\n".join(f"{social.get('name', '')} {SEPARATOR} {social.get('icon', '')} {SEPARATOR} {social.get('url', '')}" for social in value)
if kind == "skill_map":
return "\n".join(f"{category}: {', '.join(skills)}" for category, skills in value.items())
return str(value)
def from_form_text(kind: str, text: str):
text = text.strip()
if kind in ("lines", "images"):
return non_empty_lines(text)
if kind == "tags":
return [tag.strip() for tag in text.split(",") if tag.strip()]
if kind == "link_map":
links = {}
for line in non_empty_lines(text):
name, _, url = line.partition(" ")
if not url.strip():
raise ValueError(f"Link line needs a name and a url: {line}")
links[name.strip()] = url.strip()
return links
if kind == "link_list":
links = []
for line in non_empty_lines(text):
label, url = split_parts(line, 2)
if not url:
raise ValueError(f"Link line needs label | url: {line}")
links.append({"label": label, "url": url})
return links
if kind == "awards":
awards = []
for line in non_empty_lines(text):
track, place = split_parts(line, 2)
awards.append({"track": track, "place": place} if place else {"track": track})
return awards
if kind == "socials":
socials = []
for line in non_empty_lines(text):
name, icon, url = split_parts(line, 3)
if not url:
raise ValueError(f"Social line needs name | icon | url: {line}")
socials.append({"name": name, "icon": icon, "url": url})
return socials
if kind == "skill_map":
skills = {}
for line in non_empty_lines(text):
category, _, values = line.partition(":")
if not values.strip():
raise ValueError(f"Skill line needs Category: skill, skill: {line}")
skills[category.strip()] = [skill.strip() for skill in values.split(",") if skill.strip()]
return skills
return text
def summary_text(value) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "yes" if value else ""
if isinstance(value, list):
return ", ".join(str(item) for item in value if not isinstance(item, dict))
return str(value)
+244
View File
@@ -0,0 +1,244 @@
import argparse
import datetime
import html
import json
import re
import sys
import urllib.request
from pathlib import Path
BOOK_URL_PATTERN = re.compile(r"goodreads\.com/(?:\w+/)?book/show/(\d+)")
JSON_LD_PATTERN = re.compile(r"<script[^>]*type=[\"']application/ld\+json[\"'][^>]*>(.*?)</script>", re.S | re.I)
NEXT_DATA_PATTERN = re.compile(r"<script[^>]*id=[\"']__NEXT_DATA__[\"'][^>]*>(.*?)</script>", re.S | re.I)
META_TAG_PATTERN = re.compile(r"<meta\s[^>]*>", re.S | re.I)
ATTRIBUTE_PATTERN = re.compile(r"([\w:-]+)=\"([^\"]*)\"|([\w:-]+)='([^']*)'")
PAGE_TITLE_PATTERN = re.compile(r"<title[^>]*>(.*?)</title>", re.S | re.I)
TAG_PATTERN = re.compile(r"<[^>]+>")
BLOCKED_PAGE_HINTS = ["captcha", "awswaf", "robot check", "access denied", "are you a human", "unusual traffic"]
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
}
class PageResult:
def __init__(self, status: int, final_url: str, text: str):
self.status = status
self.final_url = final_url
self.text = text
def book_id(url: str) -> str | None:
match = BOOK_URL_PATTERN.search(url)
return match.group(1) if match else None
def download_page(url: str) -> PageResult:
request = urllib.request.Request(url, headers=REQUEST_HEADERS)
with urllib.request.urlopen(request, timeout=15) as response:
return PageResult(response.status, response.geturl(), response.read().decode("utf-8", errors="replace"))
def page_title(page: str) -> str:
match = PAGE_TITLE_PATTERN.search(page)
return html.unescape(match.group(1)).strip() if match else ""
def blocked_reason(page: str) -> str | None:
lowered = page.lower()
hint = next((hint for hint in BLOCKED_PAGE_HINTS if hint in lowered), None)
has_book_data = JSON_LD_PATTERN.search(page) or NEXT_DATA_PATTERN.search(page)
if hint and not has_book_data:
return f"the page looks like a bot check (found '{hint}')"
return None
def json_ld_book(page: str) -> dict:
for block in JSON_LD_PATTERN.findall(page):
try:
data = json.loads(block)
except ValueError:
continue
candidates = data if isinstance(data, list) else data.get("@graph", [data]) if isinstance(data, dict) else []
for candidate in candidates:
if isinstance(candidate, dict) and candidate.get("@type") == "Book":
return candidate
return {}
def meta_tags(page: str) -> dict:
tags = {}
for tag in META_TAG_PATTERN.findall(page):
attributes = {}
for double_name, double_value, single_name, single_value in ATTRIBUTE_PATTERN.findall(tag):
attributes[(double_name or single_name).lower()] = double_value or single_value
name = attributes.get("property") or attributes.get("name")
if name and "content" in attributes:
tags[name] = html.unescape(attributes["content"])
return tags
def plain_text(text: str | None) -> str | None:
if not text:
return None
with_breaks = re.sub(r"<br\s*/?>", "\n", text)
return html.unescape(TAG_PATTERN.sub("", with_breaks)).strip() or None
def apollo_state(page: str) -> dict:
match = NEXT_DATA_PATTERN.search(page)
if not match:
return {}
try:
return json.loads(match.group(1))["props"]["pageProps"]["apolloState"]
except (ValueError, KeyError, TypeError):
return {}
def apollo_book(state: dict, identifier: str) -> tuple[dict, dict]:
books = [value for value in state.values() if isinstance(value, dict) and value.get("__typename") == "Book"]
book = next((value for value in books if str(value.get("legacyId")) == identifier), books[0] if books else {})
work_reference = (book.get("work") or {}).get("__ref")
work = state.get(work_reference, {}) if work_reference else {}
return book, work
def contributor_name(state: dict, edge: dict) -> str | None:
reference = (edge.get("node") or {}).get("__ref")
return (state.get(reference) or {}).get("name") if reference else None
def book_authors(state: dict, book: dict) -> list[str]:
edges = [book.get("primaryContributorEdge") or {}]
edges += [edge for edge in book.get("secondaryContributorEdges") or [] if edge.get("role") == "Author"]
names = [contributor_name(state, edge) for edge in edges]
return [html.unescape(name) for name in names if name]
def description_of(book: dict) -> str | None:
for key, value in book.items():
if key.startswith("description") and isinstance(value, str):
return plain_text(value)
return None
def publication_date(details: dict) -> str | None:
milliseconds = details.get("publicationTime")
if not isinstance(milliseconds, (int, float)):
return None
return datetime.datetime.fromtimestamp(milliseconds / 1000, tz=datetime.timezone.utc).date().isoformat()
def parse_book(page: str, identifier: str) -> dict:
linked_data = json_ld_book(page)
meta = meta_tags(page)
state = apollo_state(page)
book, work = apollo_book(state, identifier)
details = book.get("details") or {}
work_stats = work.get("stats") or {}
rating = linked_data.get("aggregateRating") or {}
linked_authors = linked_data.get("author") or []
if isinstance(linked_authors, dict):
linked_authors = [linked_authors]
authors = book_authors(state, book) or [html.unescape(author["name"]) for author in linked_authors if author.get("name")]
return {
"goodreadsId": identifier,
"url": f"https://www.goodreads.com/book/show/{identifier}",
"title": html.unescape(linked_data.get("name") or book.get("title") or meta.get("og:title") or ""),
"authors": authors,
"cover": book.get("imageUrl") or linked_data.get("image") or meta.get("og:image"),
"description": description_of(book) or meta.get("og:description") or meta.get("description"),
"pages": linked_data.get("numberOfPages") or details.get("numPages"),
"format": linked_data.get("bookFormat") or details.get("format"),
"published": publication_date(details),
"publisher": details.get("publisher"),
"isbn": linked_data.get("isbn") or details.get("isbn13") or details.get("isbn"),
"language": linked_data.get("inLanguage") or (details.get("language") or {}).get("name"),
"genres": [edge["genre"]["name"] for edge in book.get("bookGenres") or [] if (edge.get("genre") or {}).get("name")],
"averageRating": rating.get("ratingValue") or work_stats.get("averageRating"),
"ratingCount": rating.get("ratingCount") or work_stats.get("ratingsCount"),
"reviewCount": rating.get("reviewCount") or work_stats.get("textReviewsCount"),
}
def scrape_book(url: str) -> dict:
identifier = book_id(url)
if not identifier:
raise ValueError(f"not a Goodreads book url: {url}")
page = download_page(url)
reason = blocked_reason(page.text)
if reason:
raise ValueError(reason)
return parse_book(page.text, identifier)
def print_diagnostics(url: str, save_path: Path | None):
page = download_page(url)
print(f"Status {page.status}")
print(f"Final url {page.final_url}")
print(f"Size {len(page.text):,} characters")
print(f"Title {page_title(page.text) or '-'}")
print(f"JSON-LD {len(JSON_LD_PATTERN.findall(page.text))} blocks")
print(f"Next data {'found' if NEXT_DATA_PATTERN.search(page.text) else 'missing'}")
print(f"Meta tags {len(meta_tags(page.text))}")
print(f"Blocked {blocked_reason(page.text) or 'no sign of a bot check'}")
if save_path:
save_path.write_text(page.text, encoding="utf-8")
print(f"Saved html to {save_path}")
def print_summary(book: dict):
print(f"{book['title'] or 'unknown title'} by {', '.join(book['authors']) or 'unknown author'}")
print("-" * 60)
for label, key in [
("Cover", "cover"),
("Pages", "pages"),
("Format", "format"),
("Published", "published"),
("Publisher", "publisher"),
("ISBN", "isbn"),
("Language", "language"),
("Rating", "averageRating"),
("Ratings", "ratingCount"),
("Reviews", "reviewCount"),
]:
print(f"{label:<10} {book[key] if book[key] is not None else '-'}")
print(f"{'Genres':<10} {', '.join(book['genres']) or '-'}")
if book["description"]:
print(f"\n{book['description'][:400]}{'...' if len(book['description']) > 400 else ''}")
missing = [key for key, value in book.items() if value in (None, "", [])]
if missing:
print(f"\nNot found on the page: {', '.join(missing)}")
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape book info from a Goodreads book page")
parser.add_argument("urls", nargs="+", help="Goodreads book urls, for example https://www.goodreads.com/book/show/170448.Animal_Farm")
parser.add_argument("--json", action="store_true", help="print the raw result as JSON")
parser.add_argument("--debug", action="store_true", help="show what the downloaded page contains instead of parsing it")
parser.add_argument("--save-html", type=Path, help="with --debug, save the downloaded page to this file")
arguments = parser.parse_args()
exit_code = 0
for url in arguments.urls:
try:
if arguments.debug:
print_diagnostics(url, arguments.save_html)
continue
book = scrape_book(url)
except (OSError, ValueError) as error:
print(f"Error for {url}: {error}", file=sys.stderr)
exit_code = 1
continue
if arguments.json:
print(json.dumps(book, indent="\t", ensure_ascii=False))
else:
print_summary(book)
print()
return exit_code
if __name__ == "__main__":
sys.exit(main())
+36 -75
View File
@@ -4,15 +4,26 @@ import jsonfile
ABOUT_FILE = "about.json"
MEDIA_KINDS = {
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"}
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
}
def check_rating(rating: int) -> int:
if not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def load_items(kind: str) -> tuple[dict, list[dict]]:
data = jsonfile.load(ABOUT_FILE)
return data, data.setdefault(MEDIA_KINDS[kind]["key"], [])
def list_items(kind: str) -> list[dict]:
_, items = load_items(kind)
return sorted(items, key=lambda item: -item.get("rating", 0))
def find_index(items: list[dict], text: str) -> int | None:
identifier = imdb.imdb_id(text)
for index, item in enumerate(items):
@@ -23,93 +34,43 @@ def find_index(items: list[dict], text: str) -> int | None:
return None
def valid_rating(value: str) -> int:
rating = int(value)
if not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def list_items(arguments) -> None:
_, items = load_items(arguments.kind)
if not items:
print(f"No {MEDIA_KINDS[arguments.kind]['label']}s yet.")
return
for item in sorted(items, key=lambda entry: -entry.get("rating", 0)):
print(f"{item.get('rating', '?'):>3}/10 {item.get('name')} {item.get('url')}")
def resolve_name(arguments, identifier: str) -> str | None:
if arguments.name:
return arguments.name
def look_up_name(kind: str, identifier: str) -> str:
details = imdb.lookup(identifier)
if not details:
print("Could not look up the title. Pass --name, or set OMDB_API_KEY in .env.")
return None
expected_type = MEDIA_KINDS[arguments.kind]["omdb_type"]
if details.get("Type") != expected_type:
print(f"Warning: OMDb lists {details.get('Title')} as a {details.get('Type')}, not a {expected_type}.")
return details.get("Title")
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
return details.get("Title") or identifier
def add_item(arguments) -> None:
identifier = imdb.imdb_id(arguments.title)
def add_item(kind: str, text: str, rating: int, name: str | None = None) -> dict:
identifier = imdb.imdb_id(text)
if not identifier:
print(f"{arguments.title} is not an IMDb link or ID.")
return
data, items = load_items(arguments.kind)
raise ValueError(f"{text} is not an IMDb link or ID")
check_rating(rating)
data, items = load_items(kind)
if find_index(items, identifier) is not None:
print(f"{identifier} is already listed. Use rate to change its rating.")
return
name = resolve_name(arguments, identifier)
if not name:
return
items.append({"name": name, "rating": arguments.rating, "url": imdb.title_url(identifier)})
raise ValueError(f"{identifier} is already listed")
item = {"name": name or look_up_name(kind, identifier), "rating": rating, "url": imdb.title_url(identifier)}
items.append(item)
jsonfile.save(ABOUT_FILE, data)
print(f"Added {name} ({arguments.rating}/10)")
return item
def remove_item(arguments) -> None:
data, items = load_items(arguments.kind)
index = find_index(items, arguments.title)
def remove_item(kind: str, text: str) -> dict:
data, items = load_items(kind)
index = find_index(items, text)
if index is None:
print(f"{arguments.title} is not listed.")
return
raise ValueError(f"{text} is not listed")
removed = items.pop(index)
jsonfile.save(ABOUT_FILE, data)
print(f"Removed {removed.get('name')}")
return removed
def rate_item(arguments) -> None:
data, items = load_items(arguments.kind)
index = find_index(items, arguments.title)
def rate_item(kind: str, text: str, rating: int) -> dict:
check_rating(rating)
data, items = load_items(kind)
index = find_index(items, text)
if index is None:
print(f"{arguments.title} is not listed.")
return
items[index]["rating"] = arguments.rating
raise ValueError(f"{text} is not listed")
items[index]["rating"] = rating
jsonfile.save(ABOUT_FILE, data)
print(f"Rated {items[index].get('name')} {arguments.rating}/10")
def register(subparsers) -> None:
for kind, settings in MEDIA_KINDS.items():
parser = subparsers.add_parser(kind, help=f"Manage the {settings['label']}s on the about page")
parser.set_defaults(kind=kind)
actions = parser.add_subparsers(dest="action", required=True)
actions.add_parser("list", help=f"List {settings['label']}s").set_defaults(handler=list_items)
add_parser = actions.add_parser("add", help=f"Add a {settings['label']} by IMDb link or ID")
add_parser.add_argument("title", help="IMDb link or ID, like tt0816692")
add_parser.add_argument("--rating", type=valid_rating, required=True, help="Your rating from 0 to 10")
add_parser.add_argument("--name", help="Display name, looked up on OMDb when left out")
add_parser.set_defaults(handler=add_item)
remove_parser = actions.add_parser("remove", help=f"Remove a {settings['label']}")
remove_parser.add_argument("title", help="IMDb link, ID or name")
remove_parser.set_defaults(handler=remove_item)
rate_parser = actions.add_parser("rate", help=f"Change a {settings['label']}'s rating")
rate_parser.add_argument("title", help="IMDb link, ID or name")
rate_parser.add_argument("rating", type=valid_rating, help="New rating from 0 to 10")
rate_parser.set_defaults(handler=rate_item)
return items[index]
+30 -50
View File
@@ -4,68 +4,48 @@ import youtube
MUSIC_FILE = "music.json"
def load_videos() -> tuple[dict, list[str]]:
def load_playlist() -> tuple[dict, list[str]]:
data = jsonfile.load(MUSIC_FILE)
return data, data.setdefault("videos", [])
def list_songs() -> list[str]:
_, videos = load_playlist()
return [youtube.video_id(video) or video for video in videos]
def find_index(videos: list[str], identifier: str) -> int | None:
return next((index for index, video in enumerate(videos) if youtube.video_id(video) == identifier), None)
def list_songs(arguments) -> None:
_, videos = load_videos()
if not videos:
print("The playlist is empty.")
return
for number, video in enumerate(videos, start=1):
identifier = youtube.video_id(video) or "?"
title = youtube.video_title(identifier) if arguments.titles else None
print(f"{number:>3}. {identifier} {title or video}")
def add_song(text: str) -> str:
identifier = youtube.video_id(text)
if not identifier:
raise ValueError(f"{text} is not a YouTube link or video ID")
data, videos = load_playlist()
if find_index(videos, identifier) is not None:
raise ValueError(f"{identifier} is already in the playlist")
videos.append(youtube.watch_url(identifier))
jsonfile.save(MUSIC_FILE, data)
return identifier
def add_songs(arguments) -> None:
data, videos = load_videos()
added = 0
for text in arguments.songs:
identifier = youtube.video_id(text)
if not identifier:
print(f"Skipped {text}: not a YouTube link or video ID.")
continue
if find_index(videos, identifier) is not None:
print(f"Skipped {identifier}: already in the playlist.")
continue
videos.append(youtube.watch_url(identifier))
added += 1
print(f"Added {youtube.video_title(identifier) or identifier}")
if added:
jsonfile.save(MUSIC_FILE, data)
def remove_song(arguments) -> None:
data, videos = load_videos()
identifier = youtube.video_id(arguments.song)
index = find_index(videos, identifier) if identifier else None
def remove_song(identifier: str) -> None:
data, videos = load_playlist()
index = find_index(videos, identifier)
if index is None:
print(f"{arguments.song} is not in the playlist.")
return
raise ValueError(f"{identifier} is not in the playlist")
videos.pop(index)
jsonfile.save(MUSIC_FILE, data)
print(f"Removed {identifier}")
def register(subparsers) -> None:
parser = subparsers.add_parser("music", help="Manage the music playlist")
actions = parser.add_subparsers(dest="action", required=True)
list_parser = actions.add_parser("list", help="List songs in the playlist")
list_parser.add_argument("--titles", action="store_true", help="Look up song titles on YouTube")
list_parser.set_defaults(handler=list_songs)
add_parser = actions.add_parser("add", help="Add songs by YouTube link or video ID")
add_parser.add_argument("songs", nargs="+", help="YouTube links or video IDs")
add_parser.set_defaults(handler=add_songs)
remove_parser = actions.add_parser("remove", help="Remove a song by YouTube link or video ID")
remove_parser.add_argument("song", help="YouTube link or video ID")
remove_parser.set_defaults(handler=remove_song)
def move_song(identifier: str, step: int) -> None:
data, videos = load_playlist()
index = find_index(videos, identifier)
if index is None:
raise ValueError(f"{identifier} is not in the playlist")
target = index + step
if not 0 <= target < len(videos):
return
videos[index], videos[target] = videos[target], videos[index]
jsonfile.save(MUSIC_FILE, data)
+204
View File
@@ -0,0 +1,204 @@
from dataclasses import dataclass, field
import jsonfile
from fields import FieldSpec, get_path
@dataclass
class Collection:
id: str
title: str
file: str
fields: list[FieldSpec]
columns: list[tuple[str, str]] = field(default_factory=list)
single: bool = False
@property
def title_field(self) -> FieldSpec:
return self.fields[0]
PROJECT_BASICS = [
FieldSpec("name", "Name", required=True),
FieldSpec("description", "Description", "longtext"),
FieldSpec("featured", "Featured", "flag"),
FieldSpec("tags", "Tags", "tags"),
]
PROJECT_COLUMNS = [("Name", "name"), ("Featured", "featured"), ("Tags", "tags")]
PROFILE = Collection(
"profile",
"Profile",
"profile.json",
[
FieldSpec("name", "Full name", required=True),
FieldSpec("displayName", "Display name"),
FieldSpec("title", "Title"),
FieldSpec("tagline", "Tagline", "longtext"),
FieldSpec("bio", "Bio", "longtext"),
FieldSpec("email", "Email"),
FieldSpec("photo", "Portfolio photo", "image"),
FieldSpec("avatar", "Avatar", "image"),
FieldSpec("education.school", "School"),
FieldSpec("education.degree", "Degree"),
FieldSpec("education.graduation", "Graduation"),
FieldSpec("interests", "Interests", "tags"),
FieldSpec("skills", "Skills", "skill_map"),
FieldSpec("socials", "Socials", "socials"),
FieldSpec("donateUrl", "Donate url"),
FieldSpec("giteaUrl", "Gitea url"),
],
single=True,
)
EXPERIENCE = Collection(
"experience",
"Experience",
"experience.json",
[
FieldSpec("role", "Role", required=True),
FieldSpec("organization", "Organization"),
FieldSpec("school", "School"),
FieldSpec("period", "Period"),
FieldSpec("employmentType", "Employment type"),
FieldSpec("highlights", "Highlights", "lines"),
FieldSpec("tech", "Tech", "tags"),
FieldSpec("awards", "Awards", "lines"),
FieldSpec("links", "Links", "link_list"),
],
[("Role", "role"), ("Organization", "organization"), ("Period", "period")],
)
LEADERSHIP = Collection(
"leadership",
"Leadership",
"leadership.json",
[
FieldSpec("role", "Role", required=True),
FieldSpec("organization", "Organization"),
FieldSpec("url", "Url"),
],
[("Role", "role"), ("Organization", "organization")],
)
PROJECT_COLLECTIONS = [
Collection(
"opensource",
"Open Source",
"projects/opensource.json",
[*PROJECT_BASICS, FieldSpec("version", "Version"), FieldSpec("period", "Period"), FieldSpec("image", "Image", "image"), FieldSpec("links", "Links", "link_map")],
PROJECT_COLUMNS,
),
Collection(
"packages",
"Packages",
"projects/packages.json",
[*PROJECT_BASICS, FieldSpec("deprecated", "Deprecated", "flag"), FieldSpec("links", "Links", "link_map")],
PROJECT_COLUMNS,
),
Collection(
"hackathons",
"Hackathons",
"projects/hackathons.json",
[
*PROJECT_BASICS,
FieldSpec("image", "Image", "image"),
FieldSpec("event.name", "Event"),
FieldSpec("event.university", "University"),
FieldSpec("event.location", "Location"),
FieldSpec("event.year", "Year"),
FieldSpec("awards", "Awards", "awards"),
FieldSpec("links", "Links", "link_map"),
],
[("Name", "name"), ("Event", "event.name"), ("Featured", "featured")],
),
Collection(
"research",
"Research groups",
"projects/research.json",
[
FieldSpec("name", "Name", required=True),
FieldSpec("shortName", "Short name"),
FieldSpec("institution", "Institution"),
FieldSpec("role", "Role"),
FieldSpec("period", "Period"),
FieldSpec("description", "Description", "longtext"),
FieldSpec("lab.name", "Lab name"),
FieldSpec("lab.url", "Lab url"),
FieldSpec("focus", "Focus", "tags"),
FieldSpec("links", "Links", "link_list"),
],
[("Name", "name"), ("Role", "role"), ("Period", "period")],
),
Collection(
"organizations",
"Organizations",
"projects/organizations.json",
[
*PROJECT_BASICS,
FieldSpec("organization", "Roles"),
FieldSpec("awards", "Awards", "awards"),
FieldSpec("gallery", "Gallery", "images"),
FieldSpec("links", "Links", "link_map"),
FieldSpec("socials", "Socials", "socials"),
],
PROJECT_COLUMNS,
),
]
def find_collection(collection_id: str) -> Collection:
for collection in [PROFILE, EXPERIENCE, LEADERSHIP, *PROJECT_COLLECTIONS]:
if collection.id == collection_id:
return collection
raise ValueError(f"Unknown collection {collection_id}")
def load_records(collection: Collection):
return jsonfile.load(collection.file)
def save_records(collection: Collection, records) -> None:
jsonfile.save(collection.file, records)
def record_title(collection: Collection, record: dict) -> str:
return str(get_path(record, collection.title_field.path) or "untitled")
def check_required(collection: Collection, record: dict) -> None:
for spec in collection.fields:
if spec.required and not get_path(record, spec.path):
raise ValueError(f"{spec.label} is required")
def save_record(collection: Collection, index: int | None, record: dict) -> int:
check_required(collection, record)
if collection.single:
save_records(collection, record)
return 0
records = load_records(collection)
if index is None:
records.append(record)
index = len(records) - 1
else:
records[index] = record
save_records(collection, records)
return index
def remove_record(collection: Collection, index: int) -> dict:
records = load_records(collection)
removed = records.pop(index)
save_records(collection, records)
return removed
def move_record(collection: Collection, index: int, step: int) -> int:
records = load_records(collection)
target = index + step
if not 0 <= target < len(records):
return index
records[index], records[target] = records[target], records[index]
save_records(collection, records)
return target
+1
View File
@@ -0,0 +1 @@
textual>=8.2,<9
+87
View File
@@ -0,0 +1,87 @@
import re
import jsonfile
THEMES_FILE = "themes.json"
DESKTOP_FILE = "desktop.json"
THEME_MODES = ["dark", "light"]
COLOR_KEYS = [
"accent",
"selection",
"muted",
"background",
"dark_background",
"lighter_background",
"foreground",
"red",
"yellow",
"orange",
"green",
"cyan",
"blue",
"magenta",
]
HEX_COLOR_PATTERN = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def load_themes() -> list[dict]:
return jsonfile.load(THEMES_FILE)
def save_themes(themes: list[dict]) -> None:
jsonfile.save(THEMES_FILE, themes)
def find_theme(themes: list[dict], theme_id: str) -> dict | None:
return next((theme for theme in themes if theme.get("id") == theme_id), None)
def default_theme_id() -> str | None:
try:
return jsonfile.load(DESKTOP_FILE).get("defaultTheme")
except (OSError, ValueError):
return None
def set_default_theme(theme_id: str) -> None:
desktop = jsonfile.load(DESKTOP_FILE)
desktop["defaultTheme"] = theme_id
jsonfile.save(DESKTOP_FILE, desktop)
def check_theme(theme: dict) -> dict:
if not ID_PATTERN.match(theme.get("id", "")):
raise ValueError("Theme id must be lowercase letters, numbers and dashes")
if theme.get("mode") not in THEME_MODES:
raise ValueError("Mode must be dark or light")
for key in COLOR_KEYS:
if not HEX_COLOR_PATTERN.match(theme.get("colors", {}).get(key, "")):
raise ValueError(f"{key} must be a hex color like #818cf8")
for color in theme.get("activeBorder") or []:
if not HEX_COLOR_PATTERN.match(color):
raise ValueError("Active border colors must be hex colors")
return theme
def save_theme(original_id: str | None, theme: dict) -> None:
check_theme(theme)
themes = load_themes()
duplicate = find_theme(themes, theme["id"])
if duplicate and theme["id"] != original_id:
raise ValueError(f"A theme with the id {theme['id']} already exists")
if original_id is None:
themes.append(theme)
else:
index = next(index for index, item in enumerate(themes) if item.get("id") == original_id)
themes[index] = theme
if original_id != theme["id"] and default_theme_id() == original_id:
set_default_theme(theme["id"])
save_themes(themes)
def remove_theme(theme_id: str) -> None:
if default_theme_id() == theme_id:
raise ValueError("That is the default theme. Pick another default first")
themes = [theme for theme in load_themes() if theme.get("id") != theme_id]
save_themes(themes)
+30
View File
@@ -0,0 +1,30 @@
from jsonfile import PROJECT_ROOT
STATIC_DIR = PROJECT_ROOT / "static"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".ico"}
def site_path(file_path) -> str:
return "/" + file_path.relative_to(STATIC_DIR).as_posix()
def list_images() -> list[dict]:
if not STATIC_DIR.exists():
return []
images = []
for file_path in sorted(STATIC_DIR.rglob("*")):
if file_path.is_file() and file_path.suffix.lower() in IMAGE_EXTENSIONS:
images.append({"path": site_path(file_path), "size": file_path.stat().st_size})
return images
def image_exists(path: str) -> bool:
return (STATIC_DIR / path.lstrip("/")).is_file()
def format_size(size: int) -> str:
for unit in ["B", "KB", "MB"]:
if size < 1024:
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
View File
Whitespace-only changes.
+252
View File
@@ -0,0 +1,252 @@
Screen {
background: $background;
}
Header {
background: $panel;
color: $foreground;
}
HeaderTitle {
color: $primary;
text-style: bold;
}
TabbedContent {
height: 1fr;
}
ContentTabs {
background: $background;
}
Tab {
color: $text-muted;
}
Tab.-active {
color: $primary;
text-style: bold;
}
Tab:hover {
color: $foreground;
}
Underline > .underline--bar {
color: $primary;
background: $panel;
}
TabPane {
padding: 0;
}
ContentPane {
height: 1fr;
margin: 1 1 0 1;
background: $surface;
border: solid $border-blurred;
border-title-color: $text-muted;
border-subtitle-color: $text-muted;
}
ContentPane:focus-within {
border: solid $primary;
border-title-color: $primary;
border-subtitle-color: $accent;
}
ContentPane DataTable {
height: 1fr;
background: $surface;
}
DataTable > .datatable--header {
background: $surface;
color: $accent;
text-style: bold;
}
DataTable > .datatable--even-row {
background: $surface;
}
DataTable > .datatable--odd-row {
background: $panel 40%;
}
.split-layout {
height: 1fr;
}
.split-layout DataTable {
width: 3fr;
}
.detail-scroll {
width: 2fr;
padding: 0 1;
background: $surface;
border-left: solid $border-blurred;
}
ContentPane:focus-within .detail-scroll {
border-left: solid $primary;
}
Footer {
background: $panel;
}
FormDialog,
ConfirmDialog {
align: center middle;
background: $background 60%;
}
.dialog {
width: 72;
height: auto;
max-width: 95%;
padding: 1 2;
background: $surface;
border: solid $primary;
}
.dialog-title {
color: $primary;
text-style: bold;
}
.field-label {
margin-top: 1;
color: $text-muted;
}
.dialog Input,
.dialog Select {
width: 100%;
}
.dialog Input {
background: $background;
border: solid $border-blurred;
}
.dialog Input:focus {
border: solid $primary;
}
#dialog-error {
height: auto;
margin-top: 1;
color: $error;
}
.dialog-buttons {
height: auto;
margin-top: 1;
align-horizontal: right;
}
.dialog-buttons Button {
margin-left: 1;
border: none;
min-width: 10;
}
Toast {
background: $panel;
border-left: outer $primary;
}
RecordEditor {
background: $background;
}
.editor-title {
width: 100%;
padding: 0 2;
background: $panel;
color: $primary;
text-style: bold;
}
.editor-form {
margin: 1 2 0 2;
padding: 0 2 1 2;
background: $surface;
border: solid $primary;
}
.editor-form Input,
.editor-form Select {
width: 100%;
background: $background;
border: solid $border-blurred;
}
.editor-form Input:focus,
.editor-form TextArea:focus {
border: solid $primary;
}
.editor-area {
height: 6;
background: $background;
border: solid $border-blurred;
}
.editor-area.longtext {
height: 8;
}
#editor-error {
height: auto;
margin-top: 1;
color: $error;
}
.field-row {
height: auto;
}
.field-row Input {
width: 1fr;
}
.browse-button {
min-width: 10;
margin-left: 1;
border: none;
}
.swatch {
width: 8;
height: 3;
margin-left: 1;
content-align: center middle;
border: solid $border-blurred;
}
.picker {
width: 80;
height: 80%;
}
.picker OptionList {
height: 1fr;
margin-top: 1;
background: $background;
border: solid $border-blurred;
}
.picker OptionList:focus {
border: solid $primary;
}
#category-select {
width: 100%;
margin: 0 0 1 0;
}
+162
View File
@@ -0,0 +1,162 @@
from rich.markup import escape
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import books
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
def book_details(book: dict) -> str:
rows = [
("Shelf", book.get("shelf")),
("Your rating", f"{book['rating']}/10" if book.get("rating") is not None else None),
("Goodreads", f"{book['averageRating']} from {book.get('ratingCount') or 0:,} ratings" if book.get("averageRating") else None),
("Pages", book.get("pages")),
("Published", book.get("published")),
("Genres", ", ".join(book.get("genres") or [])),
("Added", book.get("added")),
]
lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""]
lines += [f"[dim]{label:<12}[/dim]{escape(str(value))}" for label, value in rows if value]
if book.get("description"):
lines += ["", escape(book["description"])]
lines += ["", f"[dim]{escape(book.get('url', ''))}[/dim]"]
return "\n".join(lines)
class BooksPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "rate", "Rate"),
Binding("s", "shelf", "Shelf"),
Binding("d", "delete", "Delete"),
Binding("u", "refresh", "Refresh"),
Binding("U", "refresh_all", "Refresh all"),
Binding("r", "reload", "Reload"),
]
content_file = "books.json"
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("Add a book with [b]a[/b] and a Goodreads link.", id="book-detail")
def on_mount(self):
table = self.table()
table.add_column("Shelf", key="shelf")
table.add_column("Rating", key="rating")
table.add_column("Title", key="title")
table.add_column("Authors", key="authors")
self.reload()
def book_by_id(self, identifier: str | None) -> dict | None:
return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None)
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)}
sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1)))
for book in sorted_books:
rating = f"{book['rating']}/10" if book.get("rating") is not None else "-"
table.add_row(book.get("shelf", ""), rating, book.get("title", ""), ", ".join(book.get("authors") or []), key=book["goodreadsId"])
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def show_details(self, identifier: str | None):
book = self.book_by_id(identifier)
detail = self.query_one("#book-detail", Static)
detail.update(book_details(book) if book else "Add a book with [b]a[/b] and a Goodreads link.")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
def action_add(self):
fields = [
FormField("link", "Goodreads link", placeholder="https://www.goodreads.com/book/show/170448.Animal_Farm", required=True),
FormField("shelf", "Shelf", kind="select", options=books.SHELVES, value="read"),
FormField("rating", "Your rating (0 to 10, optional)", kind="rating"),
]
self.app.push_screen(FormDialog("Add a book", fields, "Add"), self.add_book)
def add_book(self, values: dict | None):
if not values:
return
def added(book: dict):
self.reload(book["goodreadsId"])
self.notify(f"Added {book['title']} to {book['shelf']}")
self.run_in_background("Reading the book from Goodreads...", lambda: books.add_book(values["link"], values["shelf"], values["rating"]), added)
def action_rate(self):
book = self.book_by_id(self.selected_key())
if not book:
return
current = "" if book.get("rating") is None else str(book["rating"])
fields = [FormField("rating", f"Rating for {book['title']} (empty to clear)", kind="rating", value=current)]
def rated(values: dict | None):
if values is not None:
self.run_now(lambda: books.rate_book(book["goodreadsId"], values["rating"]), f"Updated {book['title']}", book["goodreadsId"])
self.app.push_screen(FormDialog("Change rating", fields), rated)
def action_shelf(self):
book = self.book_by_id(self.selected_key())
if not book:
return
fields = [FormField("shelf", f"Shelf for {book['title']}", kind="select", options=books.SHELVES, value=book.get("shelf", "read"))]
def moved(values: dict | None):
if values:
self.run_now(lambda: books.move_book(book["goodreadsId"], values["shelf"]), f"Moved {book['title']} to {values['shelf']}", book["goodreadsId"])
self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved)
def action_delete(self):
book = self.book_by_id(self.selected_key())
if not book:
return
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: books.remove_book(book["goodreadsId"]), f"Removed {book['title']}")
self.app.push_screen(ConfirmDialog(f"Remove {book['title']}?"), confirmed)
def action_refresh(self):
book = self.book_by_id(self.selected_key())
if not book:
return
def refreshed(updated: dict):
self.reload(updated["goodreadsId"])
self.notify(f"Refreshed {updated['title']}")
self.run_in_background(f"Refreshing {book['title']}...", lambda: books.refresh_book(book["goodreadsId"]), refreshed)
def action_refresh_all(self):
self.notify("Refreshing every book from Goodreads...")
self.refresh_every_book()
@work(thread=True, exclusive=True, group="refresh")
def refresh_every_book(self):
failures = 0
all_books = books.load_books()
for book in all_books:
try:
books.refresh_book(book["goodreadsId"])
except (OSError, ValueError) as error:
failures += 1
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books"
self.app.call_from_thread(self.notify, summary)
+65
View File
@@ -0,0 +1,65 @@
from typing import Any, Callable
from textual import work
from textual.containers import Vertical
from textual.widgets import DataTable
class ContentPane(Vertical):
content_file = ""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_content_file(self.content_file)
def set_content_file(self, name: str):
self.content_file = name
self.border_title = f"[b]kitty[/b] ~/content/{name}"
def table(self) -> DataTable:
return self.query_one(DataTable)
def selected_key(self) -> str | None:
table = self.table()
if table.row_count == 0:
return None
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
return row_key.value
def restore_cursor(self, key: str | None):
table = self.table()
if key is None or table.row_count == 0:
return
try:
table.move_cursor(row=table.get_row_index(key))
except Exception:
table.move_cursor(row=0)
def reload(self, focus_key: str | None = None):
raise NotImplementedError
def action_reload(self):
self.reload(self.selected_key())
self.notify("Reloaded from disk")
def run_now(self, task: Callable[[], Any], message: str | Callable[[Any], str], focus_key: str | None = None):
try:
result = task()
except (OSError, ValueError) as error:
self.notify(str(error), title="Error", severity="error")
return
self.reload(focus_key)
self.notify(message(result) if callable(message) else message)
def run_in_background(self, waiting_message: str, task: Callable[[], Any], on_success: Callable[[Any], None]):
self.notify(waiting_message)
self.background_task(task, on_success)
@work(thread=True)
def background_task(self, task: Callable[[], Any], on_success: Callable[[Any], None]):
try:
result = task()
except (OSError, ValueError) as error:
self.app.call_from_thread(self.notify, str(error), title="Error", severity="error")
return
self.app.call_from_thread(on_success, result)
+106
View File
@@ -0,0 +1,106 @@
from dataclasses import dataclass, field
from textual import on
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Select
@dataclass
class FormField:
name: str
label: str
kind: str = "text"
placeholder: str = ""
required: bool = False
value: str = ""
options: list[str] = field(default_factory=list)
class FormDialog(ModalScreen[dict | None]):
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, title: str, fields: list[FormField], submit_label: str = "Save"):
super().__init__()
self.form_title = title
self.fields = fields
self.submit_label = submit_label
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(self.form_title, classes="dialog-title")
for form_field in self.fields:
yield Label(form_field.label, classes="field-label")
yield self.field_widget(form_field)
yield Label("", id="dialog-error")
with Horizontal(classes="dialog-buttons"):
yield Button(self.submit_label, variant="primary", id="submit")
yield Button("Cancel", id="cancel")
def field_widget(self, form_field: FormField):
widget_id = f"field-{form_field.name}"
if form_field.kind == "select":
options = [(option, option) for option in form_field.options]
return Select(options, value=form_field.value or form_field.options[0], allow_blank=False, id=widget_id)
input_type = "integer" if form_field.kind == "rating" else "text"
return Input(value=form_field.value, placeholder=form_field.placeholder, type=input_type, id=widget_id)
def show_error(self, message: str):
self.query_one("#dialog-error", Label).update(message)
def read_value(self, form_field: FormField):
text = str(self.query_one(f"#field-{form_field.name}").value or "").strip()
if form_field.required and not text:
raise ValueError(f"{form_field.label} is required")
if form_field.kind != "rating":
return text or None
if not text:
return None
rating = int(text)
if not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def submit(self):
try:
values = {form_field.name: self.read_value(form_field) for form_field in self.fields}
except ValueError as error:
self.show_error(str(error))
return
self.dismiss(values)
@on(Input.Submitted)
def submit_from_input(self):
self.submit()
@on(Button.Pressed, "#submit")
def submit_from_button(self):
self.submit()
@on(Button.Pressed, "#cancel")
def action_cancel(self):
self.dismiss(None)
class ConfirmDialog(ModalScreen[bool]):
BINDINGS = [("y", "confirm", "Yes"), ("n", "cancel", "No"), ("escape", "cancel", "No")]
def __init__(self, message: str):
super().__init__()
self.message = message
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(self.message, classes="dialog-title")
with Horizontal(classes="dialog-buttons"):
yield Button("Delete", variant="error", id="confirm")
yield Button("Cancel", id="cancel")
@on(Button.Pressed, "#confirm")
def action_confirm(self):
self.dismiss(True)
@on(Button.Pressed, "#cancel")
def action_cancel(self):
self.dismiss(False)
+68
View File
@@ -0,0 +1,68 @@
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Input, Label, OptionList
from textual.widgets.option_list import Option
import static_images
class ImagePicker(ModalScreen[str | None]):
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("down", "focus_list", "List", show=False),
]
def __init__(self, current: str = "", start_filter: str = ""):
super().__init__()
self.current = current
self.start_filter = start_filter
self.images: list[dict] = []
def compose(self) -> ComposeResult:
with Vertical(classes="dialog picker"):
yield Label("Pick an image from static/", classes="dialog-title")
yield Input(value=self.start_filter, placeholder="Type to filter, Enter to pick", id="image-filter")
yield OptionList(id="image-list")
yield Label("", id="image-count", classes="field-label")
def on_mount(self):
self.images = static_images.list_images()
self.show_images(self.start_filter)
def show_images(self, text: str):
query = text.strip().lower()
matches = [image for image in self.images if query in image["path"].lower()]
option_list = self.query_one(OptionList)
option_list.clear_options()
option_list.add_options(
Option(Text.assemble(image["path"], (" " + static_images.format_size(image["size"]), "dim")), id=image["path"])
for image in matches
)
current_index = next((index for index, image in enumerate(matches) if image["path"] == self.current), 0)
if matches:
option_list.highlighted = current_index
self.query_one("#image-count", Label).update(f"{len(matches)} of {len(self.images)} images")
@on(Input.Changed, "#image-filter")
def filter_changed(self, event: Input.Changed):
self.show_images(event.value)
@on(Input.Submitted, "#image-filter")
def pick_highlighted(self):
option_list = self.query_one(OptionList)
if option_list.highlighted is not None and option_list.option_count:
self.dismiss(option_list.get_option_at_index(option_list.highlighted).id)
@on(OptionList.OptionSelected)
def pick_option(self, event: OptionList.OptionSelected):
self.dismiss(event.option.id)
def action_focus_list(self):
self.query_one(OptionList).focus()
def action_cancel(self):
self.dismiss(None)
+95
View File
@@ -0,0 +1,95 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable
import imdb
import media
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
class MediaPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "rate", "Rate"),
Binding("d", "delete", "Delete"),
Binding("r", "reload", "Reload"),
]
content_file = "about.json"
def __init__(self, kind: str, **kwargs):
super().__init__(**kwargs)
self.kind = kind
self.label = media.MEDIA_KINDS[kind]["label"]
self.border_subtitle = media.MEDIA_KINDS[kind]["key"]
def compose(self) -> ComposeResult:
yield DataTable(cursor_type="row", zebra_stripes=True)
def on_mount(self):
table = self.table()
table.add_column("Rating", key="rating")
table.add_column("Title", key="title")
table.add_column("IMDb", key="imdb")
self.reload()
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for item in media.list_items(self.kind):
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier)
self.restore_cursor(focus_key)
def action_add(self):
fields = [
FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True),
FormField("rating", "Your rating (0 to 10)", kind="rating", required=True),
FormField("name", "Name (looked up on OMDb when empty)"),
]
self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item)
def add_item(self, values: dict | None):
if not values:
return
identifier = imdb.imdb_id(values["link"])
def added(item: dict):
self.reload(identifier)
self.notify(f"Added {item['name']} ({item['rating']}/10)")
self.run_in_background(
f"Adding {identifier or values['link']}...",
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"]),
added,
)
def action_rate(self):
identifier = self.selected_key()
if not identifier:
return
current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {})
fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))]
def rated(values: dict | None):
if values:
self.run_now(
lambda: media.rate_item(self.kind, identifier, values["rating"]),
lambda item: f"Rated {item['name']} {item['rating']}/10",
identifier,
)
self.app.push_screen(FormDialog("Change rating", fields), rated)
def action_delete(self):
identifier = self.selected_key()
if not identifier:
return
name = self.table().get_cell(identifier, "title")
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}")
self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed)
+144
View File
@@ -0,0 +1,144 @@
from rich.markup import escape
from textual import on, work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import music
import youtube
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
LOADING = "..."
DESCRIPTION_PREVIEW_LENGTH = 500
EMPTY_DETAILS = "Add a song with [b]a[/b] and a YouTube link."
def song_details(identifier: str, details: dict | None) -> str:
if not details:
return f"[b]{escape(identifier)}[/b]\n\n[dim]Loading details from YouTube...[/dim]"
rows = [
("Artist", details.get("artist")),
("Length", youtube.format_duration(details["durationSeconds"]) if details.get("durationSeconds") else None),
("Views", f"{details['views']:,}" if details.get("views") is not None else None),
("Published", details.get("published")),
("Video ID", identifier),
]
lines = [f"[b]{escape(details.get('title') or 'Title not found')}[/b]", ""]
lines += [f"[dim]{label:<11}[/dim]{escape(str(value))}" for label, value in rows if value]
description = details.get("description")
if description:
preview = description[:DESCRIPTION_PREVIEW_LENGTH] + ("..." if len(description) > DESCRIPTION_PREVIEW_LENGTH else "")
lines += ["", escape(preview)]
lines += ["", f"[dim]{escape(details['url'])}[/dim]", f"[dim]{escape(details['cover'])}[/dim]"]
return "\n".join(lines)
class MusicPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("d", "delete", "Delete"),
Binding("K", "move(-1)", "Move up"),
Binding("J", "move(1)", "Move down"),
Binding("u", "refresh_details", "Refresh"),
Binding("r", "reload", "Reload"),
]
content_file = "music.json"
song_info: dict[str, dict] = {}
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static(EMPTY_DETAILS, id="song-detail")
def on_mount(self):
table = self.table()
table.add_column("#", key="number")
table.add_column("Title", key="title")
table.add_column("Artist", key="artist")
table.add_column("Length", key="length")
self.reload()
def row_cells(self, identifier: str) -> tuple[str, str, str]:
details = self.song_info.get(identifier)
if not details:
return identifier, LOADING, LOADING
return details.get("title") or identifier, details.get("artist") or "-", youtube.format_duration(details.get("durationSeconds"))
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for number, identifier in enumerate(music.list_songs(), start=1):
table.add_row(str(number), *self.row_cells(identifier), key=identifier)
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
self.update_summary()
self.load_details()
def update_summary(self):
songs = music.list_songs()
known_lengths = [self.song_info[song].get("durationSeconds") for song in songs if song in self.song_info]
total = sum(length for length in known_lengths if length)
length_text = youtube.format_duration(total) if known_lengths else LOADING
self.border_subtitle = f"{len(songs)} songs · {length_text}"
def show_details(self, identifier: str | None):
detail = self.query_one("#song-detail", Static)
detail.update(song_details(identifier, self.song_info.get(identifier)) if identifier else EMPTY_DETAILS)
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@work(thread=True, exclusive=True, group="details")
def load_details(self, refresh_ids: tuple[str, ...] = ()):
for identifier in music.list_songs():
if identifier in self.song_info and identifier not in refresh_ids:
continue
self.song_info[identifier] = youtube.video_details(identifier)
self.app.call_from_thread(self.show_song, identifier)
def show_song(self, identifier: str):
table = self.table()
try:
for column, value in zip(["title", "artist", "length"], self.row_cells(identifier)):
table.update_cell(identifier, column, value, update_width=True)
except Exception:
return
self.update_summary()
if self.selected_key() == identifier:
self.show_details(identifier)
def action_refresh_details(self):
identifier = self.selected_key()
if identifier:
self.notify("Refreshing from YouTube...")
self.load_details((identifier,))
def action_add(self):
fields = [FormField("link", "YouTube link or video ID", placeholder="https://music.youtube.com/watch?v=...", required=True)]
self.app.push_screen(FormDialog("Add a song", fields, "Add"), self.add_song)
def add_song(self, values: dict | None):
if values:
self.run_now(lambda: music.add_song(values["link"]), lambda identifier: f"Added {identifier}", youtube.video_id(values["link"]))
def action_delete(self):
identifier = self.selected_key()
if not identifier:
return
title = (self.song_info.get(identifier) or {}).get("title") or identifier
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: music.remove_song(identifier), f"Removed {title}")
self.app.push_screen(ConfirmDialog(f"Remove {title} from the playlist?"), confirmed)
def action_move(self, step: int):
identifier = self.selected_key()
if identifier:
self.run_now(lambda: music.move_song(identifier, step), "Moved", identifier)
+73
View File
@@ -0,0 +1,73 @@
from rich.markup import escape
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import portfolio
from fields import get_path, to_form_text
from tui.content_pane import ContentPane
from tui.record_editor import RecordEditor
PREVIEW_LENGTH = 60
class ProfilePane(ContentPane):
BINDINGS = [
Binding("e", "edit", "Edit"),
Binding("r", "reload", "Reload"),
]
content_file = portfolio.PROFILE.file
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", id="profile-detail")
def on_mount(self):
table = self.table()
table.add_column("Field", key="field")
table.add_column("Value", key="value")
self.reload()
def reload(self, focus_key: str | None = None):
profile = portfolio.load_records(portfolio.PROFILE)
table = self.table()
table.clear()
for spec in portfolio.PROFILE.fields:
text = to_form_text(spec.kind, get_path(profile, spec.path)).replace("\n", " · ")
preview = text if len(text) <= PREVIEW_LENGTH else text[:PREVIEW_LENGTH] + "..."
table.add_row(spec.label, preview, key=spec.path)
self.restore_cursor(focus_key)
self.show_field(self.selected_key())
def show_field(self, path: str | None):
spec = next((item for item in portfolio.PROFILE.fields if item.path == path), None)
detail = self.query_one("#profile-detail", Static)
if not spec:
detail.update("")
return
value = to_form_text(spec.kind, get_path(portfolio.load_records(portfolio.PROFILE), spec.path))
hint = f"\n[dim]{spec.hint}[/dim]" if spec.hint else ""
detail.update(f"[b]{escape(spec.label)}[/b]{hint}\n\n{escape(value) or '[dim]empty[/dim]'}\n\n[dim]Press e or Enter to edit the profile.[/dim]")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_field(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def action_edit(self):
profile = portfolio.load_records(portfolio.PROFILE)
focus_key = self.selected_key()
def saved(result: dict | None):
if result is not None:
self.run_now(lambda: portfolio.save_record(portfolio.PROFILE, None, result), "Saved profile", focus_key)
self.app.push_screen(RecordEditor("Edit profile", portfolio.PROFILE.fields, profile), saved)
+30
View File
@@ -0,0 +1,30 @@
from textual import on
from textual.app import ComposeResult
from textual.widgets import Select
import portfolio
from tui.record_pane import RecordListPane
IMAGE_FOLDERS = {"hackathons": "hacks", "organizations": "organizations", "opensource": "projects"}
class ProjectsPane(RecordListPane):
def __init__(self, **kwargs):
super().__init__(portfolio.PROJECT_COLLECTIONS[0], **kwargs)
def compose_header(self) -> ComposeResult:
options = [(collection.title, collection.id) for collection in portfolio.PROJECT_COLLECTIONS]
yield Select(options, value=self.collection.id, allow_blank=False, id="category-select")
@on(Select.Changed, "#category-select")
def category_changed(self, event: Select.Changed):
if event.value == self.collection.id:
return
self.collection = portfolio.find_collection(str(event.value))
self.set_content_file(self.collection.file)
self.build_columns()
self.reload()
self.table().focus()
def image_folder(self) -> str:
return IMAGE_FOLDERS.get(self.collection.id, "")
+140
View File
@@ -0,0 +1,140 @@
import copy
from typing import Callable
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.color import Color
from textual.containers import Horizontal, VerticalScroll
from textual.screen import Screen
from textual.widgets import Button, Footer, Input, Label, Select, Static, Switch, TextArea
from fields import MULTILINE_KINDS, FieldSpec, from_form_text, get_path, set_path, to_form_text
from tui.image_picker import ImagePicker
class RecordEditor(Screen[dict | None]):
BINDINGS = [
Binding("ctrl+s", "save", "Save"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, title: str, fields: list[FieldSpec], record: dict, validate: Callable[[dict], None] | None = None, image_folder: str = ""):
super().__init__()
self.editor_title = title
self.fields = fields
self.record = copy.deepcopy(record)
self.validate = validate
self.image_folder = image_folder
def compose(self) -> ComposeResult:
yield Label(self.editor_title, classes="editor-title")
with VerticalScroll(classes="editor-form"):
for index, spec in enumerate(self.fields):
hint = f" [dim]({spec.hint})[/dim]" if spec.hint else ""
required = " [b]*[/b]" if spec.required else ""
yield Label(f"{spec.label}{required}{hint}", classes="field-label")
yield from self.field_widgets(index, spec)
yield Label("", id="editor-error")
with Horizontal(classes="dialog-buttons"):
yield Button("Save", variant="primary", id="save")
yield Button("Cancel", id="cancel")
yield Footer()
def field_widgets(self, index: int, spec: FieldSpec):
widget_id = f"field-{index}"
value = get_path(self.record, spec.path)
if spec.kind == "flag":
yield Switch(value=bool(value), id=widget_id)
elif spec.kind == "select":
yield Select([(option, option) for option in spec.options], value=value or spec.options[0], allow_blank=False, id=widget_id)
elif spec.kind in MULTILINE_KINDS:
yield TextArea(to_form_text(spec.kind, value), id=widget_id, soft_wrap=True, classes=f"editor-area {spec.kind}")
if spec.kind == "images":
yield Button("Add image", id=f"browse-{index}", classes="browse-button")
elif spec.kind == "image":
with Horizontal(classes="field-row"):
yield Input(to_form_text(spec.kind, value), id=widget_id)
yield Button("Browse", id=f"browse-{index}", classes="browse-button")
elif spec.kind == "color":
with Horizontal(classes="field-row"):
yield Input(to_form_text(spec.kind, value), id=widget_id, classes="color-input")
yield Static(" ", id=f"swatch-{index}", classes="swatch")
else:
yield Input(to_form_text(spec.kind, value), id=widget_id)
def on_mount(self):
for index, spec in enumerate(self.fields):
if spec.kind == "color":
self.update_swatch(index)
def update_swatch(self, index: int):
text = self.query_one(f"#field-{index}", Input).value.strip()
swatch = self.query_one(f"#swatch-{index}", Static)
try:
swatch.styles.background = Color.parse(text)
swatch.update(" ")
except Exception:
swatch.styles.background = None
swatch.update("?")
@on(Input.Changed, ".color-input")
def color_changed(self, event: Input.Changed):
self.update_swatch(int(event.input.id.removeprefix("field-")))
def read_record(self) -> dict:
record = copy.deepcopy(self.record)
for index, spec in enumerate(self.fields):
widget = self.query_one(f"#field-{index}")
if spec.kind == "flag":
value = widget.value
elif spec.kind == "select":
value = widget.value
elif isinstance(widget, TextArea):
value = from_form_text(spec.kind, widget.text)
else:
value = from_form_text(spec.kind, widget.value)
if spec.required and not value:
raise ValueError(f"{spec.label} is required")
set_path(record, spec.path, value)
return record
def action_save(self):
try:
record = self.read_record()
if self.validate:
self.validate(record)
except ValueError as error:
self.query_one("#editor-error", Label).update(str(error))
self.notify(str(error), title="Not saved", severity="error")
return
self.dismiss(record)
def action_cancel(self):
self.dismiss(None)
@on(Button.Pressed, "#save")
def save_pressed(self):
self.action_save()
@on(Button.Pressed, "#cancel")
def cancel_pressed(self):
self.action_cancel()
@on(Button.Pressed, ".browse-button")
def browse_pressed(self, event: Button.Pressed):
index = int(event.button.id.removeprefix("browse-"))
widget = self.query_one(f"#field-{index}")
current = widget.value if isinstance(widget, Input) else ""
def picked(path: str | None):
if not path:
return
if isinstance(widget, TextArea):
existing = widget.text.rstrip()
widget.load_text(f"{existing}\n{path}" if existing else path)
else:
widget.value = path
widget.focus()
self.app.push_screen(ImagePicker(current, self.image_folder), picked)
+155
View File
@@ -0,0 +1,155 @@
from rich.markup import escape
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import portfolio
from fields import get_path, summary_text, to_form_text
from portfolio import Collection
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog
from tui.record_editor import RecordEditor
def record_details(collection: Collection, record: dict) -> str:
lines = [f"[b]{escape(portfolio.record_title(collection, record))}[/b]", ""]
for spec in collection.fields[1:]:
value = get_path(record, spec.path)
if value in (None, "", [], {}, False):
continue
text = "yes" if value is True else to_form_text(spec.kind, value)
if "\n" in text or len(text) > 60:
lines += [f"[dim]{escape(spec.label)}[/dim]", escape(text), ""]
else:
lines.append(f"[dim]{escape(spec.label):<14}[/dim]{escape(text)}")
return "\n".join(lines)
class RecordListPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "edit", "Edit"),
Binding("d", "delete", "Delete"),
Binding("f", "toggle_featured", "Featured"),
Binding("K", "move(-1)", "Move up"),
Binding("J", "move(1)", "Move down"),
Binding("r", "reload", "Reload"),
]
def __init__(self, collection: Collection, **kwargs):
super().__init__(**kwargs)
self.collection = collection
self.set_content_file(collection.file)
def compose(self) -> ComposeResult:
yield from self.compose_header()
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", classes="record-detail")
def compose_header(self) -> ComposeResult:
yield from ()
def on_mount(self):
self.build_columns()
self.reload()
def build_columns(self):
table = self.table()
table.clear(columns=True)
table.add_column("#", key="number")
for label, path in self.collection.columns:
table.add_column(label, key=path)
def records(self) -> list[dict]:
return portfolio.load_records(self.collection)
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
for index, record in enumerate(self.records()):
cells = [summary_text(get_path(record, path)) for _, path in self.collection.columns]
table.add_row(str(index + 1), *cells, key=str(index))
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def selected_index(self) -> int | None:
key = self.selected_key()
return int(key) if key is not None else None
def show_details(self, key: str | None):
records = self.records()
detail = self.query_one(".record-detail", Static)
if key is None or int(key) >= len(records):
detail.update(f"No {self.collection.title.lower()} yet. Press [b]a[/b] to add one.")
return
detail.update(record_details(self.collection, records[int(key)]))
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def open_editor(self, index: int | None, record: dict):
title = f"Edit {portfolio.record_title(self.collection, record)}" if index is not None else f"New {self.collection.title.lower()} entry"
def saved(result: dict | None):
if result is None:
return
self.run_now(
lambda: portfolio.save_record(self.collection, index, result),
f"Saved {portfolio.record_title(self.collection, result)}",
str(index) if index is not None else str(len(self.records())),
)
self.app.push_screen(RecordEditor(title, self.collection.fields, record, image_folder=self.image_folder()), saved)
def image_folder(self) -> str:
return ""
def action_add(self):
self.open_editor(None, {})
def action_edit(self):
index = self.selected_index()
if index is not None:
self.open_editor(index, self.records()[index])
def action_delete(self):
index = self.selected_index()
if index is None:
return
title = portfolio.record_title(self.collection, self.records()[index])
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: portfolio.remove_record(self.collection, index), f"Removed {title}", str(max(0, index - 1)))
self.app.push_screen(ConfirmDialog(f"Remove {title}?"), confirmed)
def action_move(self, step: int):
index = self.selected_index()
if index is None:
return
target = index + step
self.run_now(lambda: portfolio.move_record(self.collection, index, step), "Moved", str(target))
def action_toggle_featured(self):
index = self.selected_index()
if index is None:
return
if not any(spec.path == "featured" for spec in self.collection.fields):
self.notify(f"{self.collection.title} entries have no featured flag", severity="warning")
return
record = self.records()[index]
record["featured"] = not record.get("featured", False)
if not record["featured"]:
record.pop("featured")
state = "Featured" if record.get("featured") else "Unfeatured"
self.run_now(lambda: portfolio.save_record(self.collection, index, record), f"{state} {portfolio.record_title(self.collection, record)}", str(index))
+57
View File
@@ -0,0 +1,57 @@
from textual.theme import Theme
import site_themes
def to_textual_theme(theme: dict) -> Theme:
colors = theme["colors"]
border_start, border_end = theme.get("activeBorder") or (colors["accent"], colors["accent"])
return Theme(
name=theme["id"],
primary=border_start,
secondary=colors["blue"],
accent=border_end,
foreground=colors["foreground"],
background=colors["dark_background"],
surface=colors["background"],
panel=colors["lighter_background"],
boost=colors["selection"],
warning=colors["yellow"],
error=colors["red"],
success=colors["green"],
dark=theme.get("mode") != "light",
variables={
"text-muted": colors["muted"],
"border": border_start,
"border-blurred": colors["selection"],
"block-cursor-background": colors["accent"],
"block-cursor-foreground": colors["dark_background"],
"block-cursor-text-style": "bold",
"block-cursor-blurred-background": colors["selection"],
"block-hover-background": colors["selection"],
"footer-key-foreground": colors["accent"],
"input-selection-background": f"{colors['accent']} 35%",
"input-cursor-background": colors["foreground"],
"scrollbar": colors["selection"],
"scrollbar-hover": colors["muted"],
"scrollbar-active": colors["accent"],
},
)
def load_site_themes() -> list[Theme]:
try:
themes = site_themes.load_themes()
except (OSError, ValueError):
return []
textual_themes = []
for theme in themes:
try:
textual_themes.append(to_textual_theme(theme))
except (KeyError, ValueError):
continue
return textual_themes
def default_theme_id() -> str | None:
return site_themes.default_theme_id()
+174
View File
@@ -0,0 +1,174 @@
import copy
from rich.markup import escape
from rich.text import Text
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import site_themes
from fields import FieldSpec
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog
from tui.record_editor import RecordEditor
SWATCH_KEYS = ["accent", "red", "yellow", "orange", "green", "cyan", "blue", "magenta"]
THEME_FIELDS = [
FieldSpec("id", "Id", required=True),
FieldSpec("name", "Name", required=True),
FieldSpec("mode", "Mode", "select", options=site_themes.THEME_MODES),
FieldSpec("wallpaper", "Wallpaper", "image"),
FieldSpec("borderStart", "Active border start", "color"),
FieldSpec("borderEnd", "Active border end", "color"),
*[FieldSpec(f"colors.{key}", key.replace("_", " ").capitalize(), "color", required=True) for key in site_themes.COLOR_KEYS],
]
def palette_swatches(theme: dict) -> Text:
colors = theme.get("colors", {})
return Text.assemble(*[("██", colors[key]) for key in SWATCH_KEYS if key in colors])
def to_form_record(theme: dict) -> dict:
record = copy.deepcopy(theme)
border = record.pop("activeBorder", None) or []
if len(border) == 2:
record["borderStart"], record["borderEnd"] = border
return record
def from_form_record(record: dict) -> dict:
theme = copy.deepcopy(record)
start, end = theme.pop("borderStart", None), theme.pop("borderEnd", None)
if start and end:
theme["activeBorder"] = [start, end]
elif start or end:
raise ValueError("Set both active border colors, or leave both empty")
ordered_keys = ["id", "name", "mode", "wallpaper", "activeBorder", "colors"]
return {key: theme[key] for key in ordered_keys if key in theme}
def theme_details(theme: dict, is_default: bool) -> str:
colors = theme.get("colors", {})
lines = [f"[b]{escape(theme.get('name', theme.get('id', '')))}[/b]{' [dim](default)[/dim]' if is_default else ''}", ""]
lines.append(f"[dim]{'Id':<20}[/dim]{escape(theme.get('id', ''))}")
lines.append(f"[dim]{'Mode':<20}[/dim]{escape(theme.get('mode', ''))}")
if theme.get("wallpaper"):
lines.append(f"[dim]{'Wallpaper':<20}[/dim]{escape(theme['wallpaper'])}")
if theme.get("activeBorder"):
start, end = theme["activeBorder"]
lines.append(f"[dim]{'Active border':<20}[/dim][{start}]██[/] {start} [{end}]██[/] {end}")
lines.append("")
for key in site_themes.COLOR_KEYS:
value = colors.get(key, "")
swatch = f"[{value}]██████[/]" if value else " "
lines.append(f"{swatch} [dim]{key.replace('_', ' '):<19}[/dim]{escape(value)}")
return "\n".join(lines)
class ThemesPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Duplicate"),
Binding("e", "edit", "Edit"),
Binding("d", "delete", "Delete"),
Binding("s", "set_default", "Set default"),
Binding("p", "preview", "Preview"),
Binding("r", "reload", "Reload"),
]
content_file = site_themes.THEMES_FILE
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
yield DataTable(cursor_type="row", zebra_stripes=True)
with VerticalScroll(classes="detail-scroll"):
yield Static("", id="theme-detail")
def on_mount(self):
table = self.table()
table.add_column("", key="default")
table.add_column("Id", key="id")
table.add_column("Name", key="name")
table.add_column("Mode", key="mode")
table.add_column("Palette", key="palette")
self.reload()
def reload(self, focus_key: str | None = None):
default_id = site_themes.default_theme_id()
table = self.table()
table.clear()
for theme in site_themes.load_themes():
marker = "*" if theme.get("id") == default_id else ""
table.add_row(marker, theme.get("id", ""), theme.get("name", ""), theme.get("mode", ""), palette_swatches(theme), key=theme.get("id"))
self.restore_cursor(focus_key)
self.show_details(self.selected_key())
def selected_theme(self) -> dict | None:
return site_themes.find_theme(site_themes.load_themes(), self.selected_key() or "")
def show_details(self, theme_id: str | None):
theme = site_themes.find_theme(site_themes.load_themes(), theme_id or "")
detail = self.query_one("#theme-detail", Static)
detail.update(theme_details(theme, theme_id == site_themes.default_theme_id()) if theme else "")
@on(DataTable.RowHighlighted)
def highlight_changed(self, event: DataTable.RowHighlighted):
self.show_details(event.row_key.value if event.row_key else None)
@on(DataTable.RowSelected)
def row_selected(self):
self.action_edit()
def open_editor(self, original_id: str | None, theme: dict, title: str):
def saved(record: dict | None):
if record is None:
return
try:
updated = from_form_record(record)
site_themes.save_theme(original_id, updated)
except (OSError, ValueError) as error:
self.notify(str(error), title="Not saved", severity="error")
return
self.app.reload_site_themes(updated["id"])
self.reload(updated["id"])
self.notify(f"Saved {updated['name']}")
validate = lambda record: site_themes.check_theme(from_form_record(record))
editor = RecordEditor(title, THEME_FIELDS, to_form_record(theme), validate=validate, image_folder="wallpapers")
self.app.push_screen(editor, saved)
def action_edit(self):
theme = self.selected_theme()
if theme:
self.open_editor(theme["id"], theme, f"Edit theme {theme.get('name', theme['id'])}")
def action_add(self):
template = self.selected_theme() or {"mode": "dark", "colors": {}}
theme = copy.deepcopy(template)
theme["id"] = f"{template.get('id', 'theme')}-copy"
theme["name"] = f"{template.get('name', 'New theme')} Copy"
self.open_editor(None, theme, "New theme")
def action_delete(self):
theme = self.selected_theme()
if not theme:
return
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: site_themes.remove_theme(theme["id"]), f"Removed {theme.get('name', theme['id'])}")
self.app.push_screen(ConfirmDialog(f"Remove the {theme.get('name', theme['id'])} theme?"), confirmed)
def action_set_default(self):
theme = self.selected_theme()
if theme:
self.run_now(lambda: site_themes.set_default_theme(theme["id"]), f"{theme.get('name', theme['id'])} is now the default theme", theme["id"])
def action_preview(self):
theme = self.selected_theme()
if theme:
self.app.reload_site_themes(theme["id"])
self.notify(f"Previewing {theme.get('name', theme['id'])}")
+70 -2
View File
@@ -6,6 +6,13 @@ import urllib.request
VIDEO_ID_PATTERN = re.compile(r"^[\w-]{11}$")
YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be"}
PATH_PREFIXES = ("/shorts/", "/embed/", "/live/")
PLAYER_RESPONSE_MARKER = "ytInitialPlayerResponse = "
TOPIC_SUFFIX = " - Topic"
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Accept-Language": "en-US,en;q=0.9",
"Cookie": "CONSENT=YES+1",
}
def video_id(text: str) -> str | None:
@@ -30,10 +37,71 @@ def watch_url(identifier: str) -> str:
return f"https://music.youtube.com/watch?v={identifier}"
def video_title(identifier: str) -> str | None:
def cover_url(identifier: str) -> str:
return f"https://i.ytimg.com/vi/{identifier}/hqdefault.jpg"
def oembed(identifier: str) -> dict:
query = urllib.parse.urlencode({"url": f"https://www.youtube.com/watch?v={identifier}", "format": "json"})
try:
with urllib.request.urlopen(f"https://www.youtube.com/oembed?{query}", timeout=10) as response:
return json.load(response).get("title")
return json.load(response)
except (OSError, ValueError):
return {}
def player_response(identifier: str) -> dict:
request = urllib.request.Request(f"https://www.youtube.com/watch?v={identifier}", headers=REQUEST_HEADERS)
try:
with urllib.request.urlopen(request, timeout=15) as response:
page = response.read().decode("utf-8", errors="replace")
except OSError:
return {}
start = page.find(PLAYER_RESPONSE_MARKER)
if start == -1:
return {}
try:
data, _ = json.JSONDecoder().raw_decode(page, start + len(PLAYER_RESPONSE_MARKER))
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def clean_artist(name: str | None) -> str | None:
if name and name.endswith(TOPIC_SUFFIX):
return name[: -len(TOPIC_SUFFIX)]
return name
def as_number(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def video_details(identifier: str) -> dict:
response = player_response(identifier)
details = response.get("videoDetails") or {}
microformat = (response.get("microformat") or {}).get("playerMicroformatRenderer") or {}
fallback = oembed(identifier) if not details.get("title") else {}
return {
"id": identifier,
"title": details.get("title") or fallback.get("title"),
"artist": clean_artist(details.get("author") or fallback.get("author_name")),
"durationSeconds": as_number(details.get("lengthSeconds")),
"views": as_number(details.get("viewCount")),
"published": (microformat.get("publishDate") or "")[:10] or None,
"description": details.get("shortDescription") or None,
"cover": cover_url(identifier),
"url": watch_url(identifier),
}
def format_duration(seconds: int | None) -> str:
if seconds is None:
return "-"
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"