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