Merge pull request #44 from stevedylandev/feat/init-easel
d88f02e6
feat/init easel
32 file(s) · +1569 −16
feat/init easel
| 19 | 19 | - name: Determine which apps to build |
|
| 20 | 20 | id: filter |
|
| 21 | 21 | run: | |
|
| 22 | - | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks"]' |
|
| 22 | + | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks","easel"]' |
|
| 23 | 23 | ||
| 24 | 24 | changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) |
|
| 25 | 25 | ||
| 29 | 29 | fi |
|
| 30 | 30 | ||
| 31 | 31 | apps=() |
|
| 32 | - | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks; do |
|
| 32 | + | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks easel; do |
|
| 33 | 33 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 34 | 34 | apps+=("\"${app}\"") |
|
| 35 | 35 | fi |
|
| 25 | 25 | - name: Determine which apps to build |
|
| 26 | 26 | id: filter |
|
| 27 | 27 | run: | |
|
| 28 | - | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks"]' |
|
| 28 | + | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","bookmarks","easel"]' |
|
| 29 | 29 | ||
| 30 | 30 | # Map cargo package names to directory names |
|
| 31 | 31 | pkg_to_dir() { |
|
| 61 | 61 | fi |
|
| 62 | 62 | ||
| 63 | 63 | apps=() |
|
| 64 | - | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks; do |
|
| 64 | + | for app in cellar sipp feeds parcels jotts og shrink backup posts library bookmarks easel; do |
|
| 65 | 65 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 66 | 66 | apps+=("\"${app}\"") |
|
| 67 | 67 | fi |
|
| 791 | 791 | ] |
|
| 792 | 792 | ||
| 793 | 793 | [[package]] |
|
| 794 | + | name = "chrono-tz" |
|
| 795 | + | version = "0.10.4" |
|
| 796 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 797 | + | checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" |
|
| 798 | + | dependencies = [ |
|
| 799 | + | "chrono", |
|
| 800 | + | "phf 0.12.1", |
|
| 801 | + | ] |
|
| 802 | + | ||
| 803 | + | [[package]] |
|
| 794 | 804 | name = "cipher" |
|
| 795 | 805 | version = "0.4.4" |
|
| 796 | 806 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1082 | 1092 | checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" |
|
| 1083 | 1093 | dependencies = [ |
|
| 1084 | 1094 | "lab", |
|
| 1085 | - | "phf", |
|
| 1095 | + | "phf 0.11.3", |
|
| 1086 | 1096 | ] |
|
| 1087 | 1097 | ||
| 1088 | 1098 | [[package]] |
|
| 1094 | 1104 | "cssparser-macros", |
|
| 1095 | 1105 | "dtoa-short", |
|
| 1096 | 1106 | "itoa", |
|
| 1097 | - | "phf", |
|
| 1107 | + | "phf 0.11.3", |
|
| 1098 | 1108 | "smallvec", |
|
| 1099 | 1109 | ] |
|
| 1100 | 1110 | ||
| 1295 | 1305 | version = "1.0.5" |
|
| 1296 | 1306 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1297 | 1307 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" |
|
| 1308 | + | ||
| 1309 | + | [[package]] |
|
| 1310 | + | name = "easel" |
|
| 1311 | + | version = "0.1.0" |
|
| 1312 | + | dependencies = [ |
|
| 1313 | + | "andromeda-darkmatter-css", |
|
| 1314 | + | "askama 0.13.1", |
|
| 1315 | + | "axum", |
|
| 1316 | + | "chrono", |
|
| 1317 | + | "chrono-tz", |
|
| 1318 | + | "dotenvy", |
|
| 1319 | + | "mime_guess", |
|
| 1320 | + | "rand 0.8.5", |
|
| 1321 | + | "reqwest 0.12.28", |
|
| 1322 | + | "rusqlite", |
|
| 1323 | + | "rust-embed", |
|
| 1324 | + | "serde", |
|
| 1325 | + | "serde_json", |
|
| 1326 | + | "tokio", |
|
| 1327 | + | "tower-http", |
|
| 1328 | + | "tracing", |
|
| 1329 | + | "tracing-subscriber", |
|
| 1330 | + | "urlencoding", |
|
| 1331 | + | ] |
|
| 1298 | 1332 | ||
| 1299 | 1333 | [[package]] |
|
| 1300 | 1334 | name = "ego-tree" |
|
| 2603 | 2637 | checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" |
|
| 2604 | 2638 | dependencies = [ |
|
| 2605 | 2639 | "log", |
|
| 2606 | - | "phf", |
|
| 2640 | + | "phf 0.11.3", |
|
| 2607 | 2641 | "phf_codegen", |
|
| 2608 | 2642 | "string_cache", |
|
| 2609 | 2643 | "string_cache_codegen", |
|
| 3222 | 3256 | checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" |
|
| 3223 | 3257 | dependencies = [ |
|
| 3224 | 3258 | "phf_macros", |
|
| 3225 | - | "phf_shared", |
|
| 3259 | + | "phf_shared 0.11.3", |
|
| 3260 | + | ] |
|
| 3261 | + | ||
| 3262 | + | [[package]] |
|
| 3263 | + | name = "phf" |
|
| 3264 | + | version = "0.12.1" |
|
| 3265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3266 | + | checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" |
|
| 3267 | + | dependencies = [ |
|
| 3268 | + | "phf_shared 0.12.1", |
|
| 3226 | 3269 | ] |
|
| 3227 | 3270 | ||
| 3228 | 3271 | [[package]] |
|
| 3232 | 3275 | checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" |
|
| 3233 | 3276 | dependencies = [ |
|
| 3234 | 3277 | "phf_generator", |
|
| 3235 | - | "phf_shared", |
|
| 3278 | + | "phf_shared 0.11.3", |
|
| 3236 | 3279 | ] |
|
| 3237 | 3280 | ||
| 3238 | 3281 | [[package]] |
|
| 3241 | 3284 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3242 | 3285 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" |
|
| 3243 | 3286 | dependencies = [ |
|
| 3244 | - | "phf_shared", |
|
| 3287 | + | "phf_shared 0.11.3", |
|
| 3245 | 3288 | "rand 0.8.5", |
|
| 3246 | 3289 | ] |
|
| 3247 | 3290 | ||
| 3252 | 3295 | checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" |
|
| 3253 | 3296 | dependencies = [ |
|
| 3254 | 3297 | "phf_generator", |
|
| 3255 | - | "phf_shared", |
|
| 3298 | + | "phf_shared 0.11.3", |
|
| 3256 | 3299 | "proc-macro2", |
|
| 3257 | 3300 | "quote", |
|
| 3258 | 3301 | "syn 2.0.117", |
|
| 3268 | 3311 | ] |
|
| 3269 | 3312 | ||
| 3270 | 3313 | [[package]] |
|
| 3314 | + | name = "phf_shared" |
|
| 3315 | + | version = "0.12.1" |
|
| 3316 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3317 | + | checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" |
|
| 3318 | + | dependencies = [ |
|
| 3319 | + | "siphasher", |
|
| 3320 | + | ] |
|
| 3321 | + | ||
| 3322 | + | [[package]] |
|
| 3271 | 3323 | name = "pin-project-lite" |
|
| 3272 | 3324 | version = "0.2.17" |
|
| 3273 | 3325 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4213 | 4265 | "fxhash", |
|
| 4214 | 4266 | "log", |
|
| 4215 | 4267 | "new_debug_unreachable", |
|
| 4216 | - | "phf", |
|
| 4268 | + | "phf 0.11.3", |
|
| 4217 | 4269 | "phf_codegen", |
|
| 4218 | 4270 | "precomputed-hash", |
|
| 4219 | 4271 | "servo_arc", |
|
| 4527 | 4579 | dependencies = [ |
|
| 4528 | 4580 | "new_debug_unreachable", |
|
| 4529 | 4581 | "parking_lot", |
|
| 4530 | - | "phf_shared", |
|
| 4582 | + | "phf_shared 0.11.3", |
|
| 4531 | 4583 | "precomputed-hash", |
|
| 4532 | 4584 | "serde", |
|
| 4533 | 4585 | ] |
|
| 4539 | 4591 | checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" |
|
| 4540 | 4592 | dependencies = [ |
|
| 4541 | 4593 | "phf_generator", |
|
| 4542 | - | "phf_shared", |
|
| 4594 | + | "phf_shared 0.11.3", |
|
| 4543 | 4595 | "proc-macro2", |
|
| 4544 | 4596 | "quote", |
|
| 4545 | 4597 | ] |
|
| 4693 | 4745 | dependencies = [ |
|
| 4694 | 4746 | "fnv", |
|
| 4695 | 4747 | "nom 7.1.3", |
|
| 4696 | - | "phf", |
|
| 4748 | + | "phf 0.11.3", |
|
| 4697 | 4749 | "phf_codegen", |
|
| 4698 | 4750 | ] |
|
| 4699 | 4751 | ||
| 4730 | 4782 | "ordered-float", |
|
| 4731 | 4783 | "pest", |
|
| 4732 | 4784 | "pest_derive", |
|
| 4733 | - | "phf", |
|
| 4785 | + | "phf 0.11.3", |
|
| 4734 | 4786 | "sha2 0.10.9", |
|
| 4735 | 4787 | "signal-hook", |
|
| 4736 | 4788 | "siphasher", |
|
| 10 | 10 | "apps/posts", |
|
| 11 | 11 | "apps/library", |
|
| 12 | 12 | "apps/bookmarks", |
|
| 13 | + | "apps/easel", |
|
| 13 | 14 | "crates/auth", |
|
| 14 | 15 | "crates/db", |
|
| 15 | 16 | "crates/darkmatter-css", |
| 1 | + | # Bind / port |
|
| 2 | + | HOST=127.0.0.1 |
|
| 3 | + | PORT=4242 |
|
| 4 | + | ||
| 5 | + | # SQLite file path |
|
| 6 | + | EASEL_DB_PATH=easel.sqlite |
|
| 7 | + | ||
| 8 | + | # IANA timezone for day boundary (e.g. America/Chicago, Europe/London) |
|
| 9 | + | EASEL_TIMEZONE=UTC |
|
| 10 | + | ||
| 11 | + | # Comma-separated AIC classification_title filters (lowercased, e.g. painting,drawing,print) |
|
| 12 | + | EASEL_CLASSIFICATIONS=painting |
|
| 13 | + | ||
| 14 | + | # Phrases excluded via must_not match across title/description/term/subject/category/classification. |
|
| 15 | + | # Comma-separated. Set empty to disable filtering. |
|
| 16 | + | EASEL_EXCLUDE_TERMS=erotic,erotica,shunga |
|
| 17 | + | ||
| 18 | + | # On startup, fill any missing day in the last N days. 0 disables backfill. |
|
| 19 | + | EASEL_BACKFILL_DAYS=0 |
|
| 20 | + | ||
| 21 | + | # Max retries when picking a non-duplicate artwork |
|
| 22 | + | EASEL_MAX_DEDUP_RETRIES=10 |
|
| 23 | + | ||
| 24 | + | # Public base URL (used for absolute links in /feed.xml) |
|
| 25 | + | EASEL_BASE_URL=http://localhost:4242 |
| 1 | + | [package] |
|
| 2 | + | name = "easel" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | description = "A daily painting from the Art Institute of Chicago" |
|
| 6 | + | license = "MIT" |
|
| 7 | + | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | + | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | + | ||
| 10 | + | [dependencies] |
|
| 11 | + | axum = { workspace = true } |
|
| 12 | + | tokio = { workspace = true } |
|
| 13 | + | serde = { workspace = true } |
|
| 14 | + | serde_json = { workspace = true } |
|
| 15 | + | dotenvy = { workspace = true } |
|
| 16 | + | rust-embed = { workspace = true } |
|
| 17 | + | rusqlite = { workspace = true } |
|
| 18 | + | rand = { workspace = true } |
|
| 19 | + | tracing = { workspace = true } |
|
| 20 | + | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 21 | + | andromeda-darkmatter-css = { workspace = true } |
|
| 22 | + | askama = "0.13" |
|
| 23 | + | reqwest = { version = "0.12", features = ["json"] } |
|
| 24 | + | chrono = "0.4" |
|
| 25 | + | chrono-tz = "0.10" |
|
| 26 | + | urlencoding = "2" |
|
| 27 | + | mime_guess = "2" |
|
| 28 | + | tower-http = { workspace = true, features = ["cors"] } |
| 1 | + | # Build from repo root: docker build -t easel -f apps/easel/Dockerfile . |
|
| 2 | + | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | ||
| 5 | + | FROM chef AS planner |
|
| 6 | + | COPY . . |
|
| 7 | + | RUN cargo chef prepare --recipe-path recipe.json |
|
| 8 | + | ||
| 9 | + | FROM chef AS builder |
|
| 10 | + | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 11 | + | COPY --from=planner /app/recipe.json recipe.json |
|
| 12 | + | RUN cargo chef cook --release --recipe-path recipe.json -p easel |
|
| 13 | + | COPY . . |
|
| 14 | + | RUN cargo build --release -p easel |
|
| 15 | + | ||
| 16 | + | FROM debian:bookworm-slim |
|
| 17 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 18 | + | COPY --from=builder /app/target/release/easel /usr/local/bin/easel |
|
| 19 | + | WORKDIR /data |
|
| 20 | + | EXPOSE 3000 |
|
| 21 | + | ENV HOST=0.0.0.0 |
|
| 22 | + | ENV PORT=3000 |
|
| 23 | + | ENV EASEL_DB_PATH=/data/easel.sqlite |
|
| 24 | + | CMD ["easel"] |
| 1 | + | # Easel |
|
| 2 | + | ||
| 3 | + | A daily painting from the [Art Institute of Chicago](https://api.artic.edu/docs/). One public-domain artwork per calendar day, persisted to SQLite. Past days browsable; future days unavailable until populated. |
|
| 4 | + | ||
| 5 | + | ## Run locally |
|
| 6 | + | ||
| 7 | + | ```bash |
|
| 8 | + | cargo run -p easel |
|
| 9 | + | ``` |
|
| 10 | + | ||
| 11 | + | Visit `http://localhost:4242`. |
|
| 12 | + | ||
| 13 | + | ## Configuration |
|
| 14 | + | ||
| 15 | + | | Var | Default | Purpose | |
|
| 16 | + | |---|---|---| |
|
| 17 | + | | `HOST` | `127.0.0.1` | Bind address | |
|
| 18 | + | | `PORT` | `4242` | Listen port | |
|
| 19 | + | | `EASEL_DB_PATH` | `easel.sqlite` | SQLite file | |
|
| 20 | + | | `EASEL_TIMEZONE` | `UTC` | IANA TZ for day boundary | |
|
| 21 | + | | `EASEL_CLASSIFICATIONS` | `painting` | Comma-separated `classification_title` filter | |
|
| 22 | + | | `EASEL_BACKFILL_DAYS` | `0` | On boot, fill missing past N days | |
|
| 23 | + | | `EASEL_MAX_DEDUP_RETRIES` | `10` | Retries when picking a non-duplicate page | |
|
| 24 | + | ||
| 25 | + | ## Routes |
|
| 26 | + | ||
| 27 | + | - `GET /` — today's artwork |
|
| 28 | + | - `GET /day/{YYYY-MM-DD}` — specific past day |
|
| 29 | + | - `GET /archive` — full archive |
|
| 30 | + | - `GET /api/today` — JSON of today |
|
| 31 | + | - `GET /api/day/{YYYY-MM-DD}` — JSON of specific day |
|
| 32 | + | - `GET /api/archive` — JSON list |
|
| 33 | + | ||
| 34 | + | ## Image source |
|
| 35 | + | ||
| 36 | + | Images served from AIC's IIIF endpoint: |
|
| 37 | + | `https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg` |
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/easel/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "${PORT:-4242}:3000" |
|
| 8 | + | environment: |
|
| 9 | + | - EASEL_DB_PATH=/data/easel.sqlite |
|
| 10 | + | - EASEL_TIMEZONE=${EASEL_TIMEZONE:-UTC} |
|
| 11 | + | - EASEL_CLASSIFICATIONS=${EASEL_CLASSIFICATIONS:-painting} |
|
| 12 | + | - EASEL_BACKFILL_DAYS=${EASEL_BACKFILL_DAYS:-0} |
|
| 13 | + | - EASEL_MAX_DEDUP_RETRIES=${EASEL_MAX_DEDUP_RETRIES:-10} |
|
| 14 | + | - HOST=0.0.0.0 |
|
| 15 | + | - PORT=3000 |
|
| 16 | + | volumes: |
|
| 17 | + | - easel-data:/data |
|
| 18 | + | restart: unless-stopped |
|
| 19 | + | ||
| 20 | + | volumes: |
|
| 21 | + | easel-data: |
| 1 | + | use rand::Rng; |
|
| 2 | + | use serde::Deserialize; |
|
| 3 | + | use std::time::Duration; |
|
| 4 | + | ||
| 5 | + | use crate::db::{self, DailyArtwork, Db}; |
|
| 6 | + | ||
| 7 | + | const SEARCH_URL: &str = "https://api.artic.edu/api/v1/artworks/search"; |
|
| 8 | + | const FIELDS: &str = "id,title,artist_display,artist_title,date_display,medium_display,dimensions,place_of_origin,credit_line,description,short_description,image_id"; |
|
| 9 | + | ||
| 10 | + | pub fn build_client() -> reqwest::Client { |
|
| 11 | + | reqwest::Client::builder() |
|
| 12 | + | .timeout(Duration::from_secs(20)) |
|
| 13 | + | .user_agent("andromeda-easel/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 14 | + | .build() |
|
| 15 | + | .expect("Failed to build HTTP client") |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | #[derive(Debug, Deserialize)] |
|
| 19 | + | pub struct RawArtwork { |
|
| 20 | + | pub id: i64, |
|
| 21 | + | pub title: Option<String>, |
|
| 22 | + | pub artist_display: Option<String>, |
|
| 23 | + | pub artist_title: Option<String>, |
|
| 24 | + | pub date_display: Option<String>, |
|
| 25 | + | pub medium_display: Option<String>, |
|
| 26 | + | pub dimensions: Option<String>, |
|
| 27 | + | pub place_of_origin: Option<String>, |
|
| 28 | + | pub credit_line: Option<String>, |
|
| 29 | + | pub description: Option<String>, |
|
| 30 | + | pub short_description: Option<String>, |
|
| 31 | + | pub image_id: Option<String>, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | #[derive(Debug, Deserialize)] |
|
| 35 | + | struct Pagination { |
|
| 36 | + | total: u64, |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | #[derive(Debug, Deserialize)] |
|
| 40 | + | struct SearchResponse<T> { |
|
| 41 | + | pagination: Pagination, |
|
| 42 | + | data: Vec<T>, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | #[derive(Debug, Deserialize)] |
|
| 46 | + | struct IdOnly { |
|
| 47 | + | #[allow(dead_code)] |
|
| 48 | + | id: i64, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | const EXCLUDE_FIELDS: &[&str] = &[ |
|
| 52 | + | "title", |
|
| 53 | + | "description", |
|
| 54 | + | "short_description", |
|
| 55 | + | "term_titles", |
|
| 56 | + | "subject_titles", |
|
| 57 | + | "category_titles", |
|
| 58 | + | "classification_titles", |
|
| 59 | + | ]; |
|
| 60 | + | ||
| 61 | + | fn build_params(classifications: &[String], exclude_terms: &[String]) -> String { |
|
| 62 | + | let terms: Vec<serde_json::Value> = classifications |
|
| 63 | + | .iter() |
|
| 64 | + | .map(|c| serde_json::Value::String(c.to_lowercase())) |
|
| 65 | + | .collect(); |
|
| 66 | + | let must_not: Vec<serde_json::Value> = exclude_terms |
|
| 67 | + | .iter() |
|
| 68 | + | .map(|t| { |
|
| 69 | + | serde_json::json!({ |
|
| 70 | + | "multi_match": { |
|
| 71 | + | "query": t, |
|
| 72 | + | "fields": EXCLUDE_FIELDS, |
|
| 73 | + | "type": "phrase" |
|
| 74 | + | } |
|
| 75 | + | }) |
|
| 76 | + | }) |
|
| 77 | + | .collect(); |
|
| 78 | + | let body = serde_json::json!({ |
|
| 79 | + | "query": { |
|
| 80 | + | "bool": { |
|
| 81 | + | "must": [ |
|
| 82 | + | { "term": { "is_public_domain": true } }, |
|
| 83 | + | { "terms": { "classification_title.keyword": terms } }, |
|
| 84 | + | { "exists": { "field": "image_id" } } |
|
| 85 | + | ], |
|
| 86 | + | "must_not": must_not |
|
| 87 | + | } |
|
| 88 | + | } |
|
| 89 | + | }); |
|
| 90 | + | body.to_string() |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | pub async fn total_matching( |
|
| 94 | + | client: &reqwest::Client, |
|
| 95 | + | classifications: &[String], |
|
| 96 | + | exclude_terms: &[String], |
|
| 97 | + | ) -> Result<u64, String> { |
|
| 98 | + | let params = build_params(classifications, exclude_terms); |
|
| 99 | + | let url = format!( |
|
| 100 | + | "{SEARCH_URL}?params={}&limit=1&fields=id", |
|
| 101 | + | urlencoding::encode(¶ms) |
|
| 102 | + | ); |
|
| 103 | + | let resp = client |
|
| 104 | + | .get(&url) |
|
| 105 | + | .send() |
|
| 106 | + | .await |
|
| 107 | + | .map_err(|e| format!("count fetch failed: {e}"))?; |
|
| 108 | + | if !resp.status().is_success() { |
|
| 109 | + | return Err(format!("count returned status {}", resp.status())); |
|
| 110 | + | } |
|
| 111 | + | let body: SearchResponse<IdOnly> = resp |
|
| 112 | + | .json() |
|
| 113 | + | .await |
|
| 114 | + | .map_err(|e| format!("count parse failed: {e}"))?; |
|
| 115 | + | Ok(body.pagination.total) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | pub async fn fetch_artwork_at( |
|
| 119 | + | client: &reqwest::Client, |
|
| 120 | + | classifications: &[String], |
|
| 121 | + | exclude_terms: &[String], |
|
| 122 | + | page: u64, |
|
| 123 | + | ) -> Result<Option<RawArtwork>, String> { |
|
| 124 | + | let params = build_params(classifications, exclude_terms); |
|
| 125 | + | let url = format!( |
|
| 126 | + | "{SEARCH_URL}?params={}&limit=1&page={page}&fields={FIELDS}", |
|
| 127 | + | urlencoding::encode(¶ms) |
|
| 128 | + | ); |
|
| 129 | + | let resp = client |
|
| 130 | + | .get(&url) |
|
| 131 | + | .send() |
|
| 132 | + | .await |
|
| 133 | + | .map_err(|e| format!("artwork fetch failed: {e}"))?; |
|
| 134 | + | if !resp.status().is_success() { |
|
| 135 | + | return Err(format!("artwork returned status {}", resp.status())); |
|
| 136 | + | } |
|
| 137 | + | let mut body: SearchResponse<RawArtwork> = resp |
|
| 138 | + | .json() |
|
| 139 | + | .await |
|
| 140 | + | .map_err(|e| format!("artwork parse failed: {e}"))?; |
|
| 141 | + | Ok(body.data.pop()) |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | pub async fn pick_unique( |
|
| 145 | + | client: &reqwest::Client, |
|
| 146 | + | db: &Db, |
|
| 147 | + | classifications: &[String], |
|
| 148 | + | exclude_terms: &[String], |
|
| 149 | + | max_retries: u32, |
|
| 150 | + | ) -> Result<RawArtwork, String> { |
|
| 151 | + | let total = total_matching(client, classifications, exclude_terms).await?; |
|
| 152 | + | if total == 0 { |
|
| 153 | + | return Err("AIC search returned zero matches for given classifications".to_string()); |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | for attempt in 0..=max_retries { |
|
| 157 | + | let page = { |
|
| 158 | + | let mut rng = rand::thread_rng(); |
|
| 159 | + | rng.gen_range(1..=total) |
|
| 160 | + | }; |
|
| 161 | + | let art = match fetch_artwork_at(client, classifications, exclude_terms, page).await? { |
|
| 162 | + | Some(a) => a, |
|
| 163 | + | None => continue, |
|
| 164 | + | }; |
|
| 165 | + | if art.image_id.is_none() || art.image_id.as_deref() == Some("") { |
|
| 166 | + | tracing::warn!("artwork {} has no image_id, retrying", art.id); |
|
| 167 | + | continue; |
|
| 168 | + | } |
|
| 169 | + | match db::artwork_id_exists(db, art.id) { |
|
| 170 | + | Ok(true) => { |
|
| 171 | + | tracing::info!( |
|
| 172 | + | "duplicate artwork {} on attempt {}, retrying", |
|
| 173 | + | art.id, |
|
| 174 | + | attempt + 1 |
|
| 175 | + | ); |
|
| 176 | + | continue; |
|
| 177 | + | } |
|
| 178 | + | Ok(false) => return Ok(art), |
|
| 179 | + | Err(e) => return Err(format!("dedup check failed: {e}")), |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + | Err(format!( |
|
| 183 | + | "failed to pick a non-duplicate artwork after {} retries", |
|
| 184 | + | max_retries + 1 |
|
| 185 | + | )) |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | pub fn raw_to_daily(raw: RawArtwork, date: String, fetched_at: String) -> Option<DailyArtwork> { |
|
| 189 | + | let image_id = raw.image_id?; |
|
| 190 | + | if image_id.is_empty() { |
|
| 191 | + | return None; |
|
| 192 | + | } |
|
| 193 | + | Some(DailyArtwork { |
|
| 194 | + | date, |
|
| 195 | + | artwork_id: raw.id, |
|
| 196 | + | title: raw.title.unwrap_or_else(|| "Untitled".to_string()), |
|
| 197 | + | artist_display: raw.artist_display, |
|
| 198 | + | artist_title: raw.artist_title, |
|
| 199 | + | date_display: raw.date_display, |
|
| 200 | + | medium_display: raw.medium_display, |
|
| 201 | + | dimensions: raw.dimensions, |
|
| 202 | + | place_of_origin: raw.place_of_origin, |
|
| 203 | + | credit_line: raw.credit_line, |
|
| 204 | + | description: raw.description, |
|
| 205 | + | short_description: raw.short_description, |
|
| 206 | + | image_id, |
|
| 207 | + | fetched_at, |
|
| 208 | + | }) |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | #[cfg(test)] |
|
| 212 | + | mod tests { |
|
| 213 | + | use super::*; |
|
| 214 | + | ||
| 215 | + | #[test] |
|
| 216 | + | fn build_params_lowercases_classifications() { |
|
| 217 | + | let p = build_params(&["Painting".to_string(), "DRAWING".to_string()], &[]); |
|
| 218 | + | assert!(p.contains("\"painting\"")); |
|
| 219 | + | assert!(p.contains("\"drawing\"")); |
|
| 220 | + | assert!(p.contains("is_public_domain")); |
|
| 221 | + | assert!(p.contains("image_id")); |
|
| 222 | + | } |
|
| 223 | + | } |
| 1 | + | use rusqlite::{params, Connection, OptionalExtension}; |
|
| 2 | + | use serde::{Deserialize, Serialize}; |
|
| 3 | + | use std::fmt; |
|
| 4 | + | use std::sync::{Arc, Mutex}; |
|
| 5 | + | ||
| 6 | + | pub type Db = Arc<Mutex<Connection>>; |
|
| 7 | + | ||
| 8 | + | #[derive(Debug)] |
|
| 9 | + | pub enum DbError { |
|
| 10 | + | Sqlite(rusqlite::Error), |
|
| 11 | + | LockPoisoned, |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | impl fmt::Display for DbError { |
|
| 15 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 16 | + | match self { |
|
| 17 | + | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 18 | + | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 19 | + | } |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | impl std::error::Error for DbError {} |
|
| 24 | + | ||
| 25 | + | impl From<rusqlite::Error> for DbError { |
|
| 26 | + | fn from(e: rusqlite::Error) -> Self { |
|
| 27 | + | DbError::Sqlite(e) |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, Serialize, Deserialize)] |
|
| 32 | + | pub struct DailyArtwork { |
|
| 33 | + | pub date: String, |
|
| 34 | + | pub artwork_id: i64, |
|
| 35 | + | pub title: String, |
|
| 36 | + | pub artist_display: Option<String>, |
|
| 37 | + | pub artist_title: Option<String>, |
|
| 38 | + | pub date_display: Option<String>, |
|
| 39 | + | pub medium_display: Option<String>, |
|
| 40 | + | pub dimensions: Option<String>, |
|
| 41 | + | pub place_of_origin: Option<String>, |
|
| 42 | + | pub credit_line: Option<String>, |
|
| 43 | + | pub description: Option<String>, |
|
| 44 | + | pub short_description: Option<String>, |
|
| 45 | + | pub image_id: String, |
|
| 46 | + | pub fetched_at: String, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | const SCHEMA: &str = " |
|
| 50 | + | CREATE TABLE IF NOT EXISTS daily_artworks ( |
|
| 51 | + | date TEXT PRIMARY KEY, |
|
| 52 | + | artwork_id INTEGER NOT NULL, |
|
| 53 | + | title TEXT NOT NULL, |
|
| 54 | + | artist_display TEXT, |
|
| 55 | + | artist_title TEXT, |
|
| 56 | + | date_display TEXT, |
|
| 57 | + | medium_display TEXT, |
|
| 58 | + | dimensions TEXT, |
|
| 59 | + | place_of_origin TEXT, |
|
| 60 | + | credit_line TEXT, |
|
| 61 | + | description TEXT, |
|
| 62 | + | short_description TEXT, |
|
| 63 | + | image_id TEXT NOT NULL, |
|
| 64 | + | fetched_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 65 | + | ); |
|
| 66 | + | CREATE INDEX IF NOT EXISTS idx_daily_artworks_artwork_id ON daily_artworks(artwork_id); |
|
| 67 | + | "; |
|
| 68 | + | ||
| 69 | + | pub fn init_db(path: &str) -> Db { |
|
| 70 | + | let conn = Connection::open(path).expect("Failed to open easel database"); |
|
| 71 | + | conn.execute_batch(SCHEMA).expect("Failed to apply schema"); |
|
| 72 | + | Arc::new(Mutex::new(conn)) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | const COLS: &str = "date, artwork_id, title, artist_display, artist_title, date_display, medium_display, dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at"; |
|
| 76 | + | ||
| 77 | + | fn from_row(row: &rusqlite::Row) -> rusqlite::Result<DailyArtwork> { |
|
| 78 | + | Ok(DailyArtwork { |
|
| 79 | + | date: row.get(0)?, |
|
| 80 | + | artwork_id: row.get(1)?, |
|
| 81 | + | title: row.get(2)?, |
|
| 82 | + | artist_display: row.get(3)?, |
|
| 83 | + | artist_title: row.get(4)?, |
|
| 84 | + | date_display: row.get(5)?, |
|
| 85 | + | medium_display: row.get(6)?, |
|
| 86 | + | dimensions: row.get(7)?, |
|
| 87 | + | place_of_origin: row.get(8)?, |
|
| 88 | + | credit_line: row.get(9)?, |
|
| 89 | + | description: row.get(10)?, |
|
| 90 | + | short_description: row.get(11)?, |
|
| 91 | + | image_id: row.get(12)?, |
|
| 92 | + | fetched_at: row.get(13)?, |
|
| 93 | + | }) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | pub fn insert_daily(db: &Db, art: &DailyArtwork) -> Result<bool, DbError> { |
|
| 97 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 98 | + | let rows = conn.execute( |
|
| 99 | + | "INSERT OR IGNORE INTO daily_artworks |
|
| 100 | + | (date, artwork_id, title, artist_display, artist_title, date_display, medium_display, |
|
| 101 | + | dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at) |
|
| 102 | + | VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", |
|
| 103 | + | params![ |
|
| 104 | + | art.date, |
|
| 105 | + | art.artwork_id, |
|
| 106 | + | art.title, |
|
| 107 | + | art.artist_display, |
|
| 108 | + | art.artist_title, |
|
| 109 | + | art.date_display, |
|
| 110 | + | art.medium_display, |
|
| 111 | + | art.dimensions, |
|
| 112 | + | art.place_of_origin, |
|
| 113 | + | art.credit_line, |
|
| 114 | + | art.description, |
|
| 115 | + | art.short_description, |
|
| 116 | + | art.image_id, |
|
| 117 | + | art.fetched_at, |
|
| 118 | + | ], |
|
| 119 | + | )?; |
|
| 120 | + | Ok(rows > 0) |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | pub fn get_daily(db: &Db, date: &str) -> Result<Option<DailyArtwork>, DbError> { |
|
| 124 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 125 | + | let row = conn |
|
| 126 | + | .query_row( |
|
| 127 | + | &format!("SELECT {COLS} FROM daily_artworks WHERE date = ?1"), |
|
| 128 | + | params![date], |
|
| 129 | + | from_row, |
|
| 130 | + | ) |
|
| 131 | + | .optional()?; |
|
| 132 | + | Ok(row) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | pub fn list_daily(db: &Db, limit: i64) -> Result<Vec<DailyArtwork>, DbError> { |
|
| 136 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 137 | + | let mut stmt = conn.prepare(&format!( |
|
| 138 | + | "SELECT {COLS} FROM daily_artworks ORDER BY date DESC LIMIT ?1" |
|
| 139 | + | ))?; |
|
| 140 | + | let rows = stmt |
|
| 141 | + | .query_map(params![limit], from_row)? |
|
| 142 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 143 | + | Ok(rows) |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | pub fn artwork_id_exists(db: &Db, artwork_id: i64) -> Result<bool, DbError> { |
|
| 147 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 148 | + | let count: i64 = conn.query_row( |
|
| 149 | + | "SELECT COUNT(*) FROM daily_artworks WHERE artwork_id = ?1", |
|
| 150 | + | params![artwork_id], |
|
| 151 | + | |row| row.get(0), |
|
| 152 | + | )?; |
|
| 153 | + | Ok(count > 0) |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | pub fn missing_dates(db: &Db, dates: &[String]) -> Result<Vec<String>, DbError> { |
|
| 157 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 158 | + | let mut missing = Vec::new(); |
|
| 159 | + | for d in dates { |
|
| 160 | + | let exists: i64 = conn.query_row( |
|
| 161 | + | "SELECT COUNT(*) FROM daily_artworks WHERE date = ?1", |
|
| 162 | + | params![d], |
|
| 163 | + | |row| row.get(0), |
|
| 164 | + | )?; |
|
| 165 | + | if exists == 0 { |
|
| 166 | + | missing.push(d.clone()); |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | Ok(missing) |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | #[cfg(test)] |
|
| 173 | + | mod tests { |
|
| 174 | + | use super::*; |
|
| 175 | + | ||
| 176 | + | fn test_db() -> Db { |
|
| 177 | + | let conn = Connection::open_in_memory().unwrap(); |
|
| 178 | + | conn.execute_batch(SCHEMA).unwrap(); |
|
| 179 | + | Arc::new(Mutex::new(conn)) |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | fn sample(date: &str, artwork_id: i64) -> DailyArtwork { |
|
| 183 | + | DailyArtwork { |
|
| 184 | + | date: date.to_string(), |
|
| 185 | + | artwork_id, |
|
| 186 | + | title: "Test".to_string(), |
|
| 187 | + | artist_display: Some("An Artist".to_string()), |
|
| 188 | + | artist_title: None, |
|
| 189 | + | date_display: None, |
|
| 190 | + | medium_display: None, |
|
| 191 | + | dimensions: None, |
|
| 192 | + | place_of_origin: None, |
|
| 193 | + | credit_line: None, |
|
| 194 | + | description: None, |
|
| 195 | + | short_description: None, |
|
| 196 | + | image_id: "abc-123".to_string(), |
|
| 197 | + | fetched_at: "2024-01-01T00:00:00Z".to_string(), |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | #[test] |
|
| 202 | + | fn insert_and_get() { |
|
| 203 | + | let db = test_db(); |
|
| 204 | + | assert!(insert_daily(&db, &sample("2024-01-01", 1)).unwrap()); |
|
| 205 | + | let got = get_daily(&db, "2024-01-01").unwrap().unwrap(); |
|
| 206 | + | assert_eq!(got.artwork_id, 1); |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | #[test] |
|
| 210 | + | fn duplicate_date_ignored() { |
|
| 211 | + | let db = test_db(); |
|
| 212 | + | assert!(insert_daily(&db, &sample("2024-01-01", 1)).unwrap()); |
|
| 213 | + | assert!(!insert_daily(&db, &sample("2024-01-01", 2)).unwrap()); |
|
| 214 | + | assert_eq!(get_daily(&db, "2024-01-01").unwrap().unwrap().artwork_id, 1); |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | #[test] |
|
| 218 | + | fn artwork_id_exists_works() { |
|
| 219 | + | let db = test_db(); |
|
| 220 | + | insert_daily(&db, &sample("2024-01-01", 42)).unwrap(); |
|
| 221 | + | assert!(artwork_id_exists(&db, 42).unwrap()); |
|
| 222 | + | assert!(!artwork_id_exists(&db, 99).unwrap()); |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | #[test] |
|
| 226 | + | fn missing_dates_filter() { |
|
| 227 | + | let db = test_db(); |
|
| 228 | + | insert_daily(&db, &sample("2024-01-01", 1)).unwrap(); |
|
| 229 | + | let dates = vec![ |
|
| 230 | + | "2024-01-01".to_string(), |
|
| 231 | + | "2024-01-02".to_string(), |
|
| 232 | + | "2024-01-03".to_string(), |
|
| 233 | + | ]; |
|
| 234 | + | let missing = missing_dates(&db, &dates).unwrap(); |
|
| 235 | + | assert_eq!(missing, vec!["2024-01-02", "2024-01-03"]); |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | #[test] |
|
| 239 | + | fn list_daily_desc() { |
|
| 240 | + | let db = test_db(); |
|
| 241 | + | insert_daily(&db, &sample("2024-01-01", 1)).unwrap(); |
|
| 242 | + | insert_daily(&db, &sample("2024-01-03", 3)).unwrap(); |
|
| 243 | + | insert_daily(&db, &sample("2024-01-02", 2)).unwrap(); |
|
| 244 | + | let list = list_daily(&db, 10).unwrap(); |
|
| 245 | + | assert_eq!(list.len(), 3); |
|
| 246 | + | assert_eq!(list[0].date, "2024-01-03"); |
|
| 247 | + | assert_eq!(list[2].date, "2024-01-01"); |
|
| 248 | + | } |
|
| 249 | + | } |
| 1 | + | mod aic; |
|
| 2 | + | mod db; |
|
| 3 | + | mod scheduler; |
|
| 4 | + | mod server; |
|
| 5 | + | ||
| 6 | + | #[tokio::main] |
|
| 7 | + | async fn main() { |
|
| 8 | + | dotenvy::dotenv().ok(); |
|
| 9 | + | tracing_subscriber::fmt() |
|
| 10 | + | .with_env_filter( |
|
| 11 | + | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 12 | + | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,easel=info")), |
|
| 13 | + | ) |
|
| 14 | + | .init(); |
|
| 15 | + | server::run().await; |
|
| 16 | + | } |
| 1 | + | use std::sync::Arc; |
|
| 2 | + | use std::time::Duration; |
|
| 3 | + | ||
| 4 | + | use chrono::{Duration as ChronoDuration, NaiveDate, TimeZone, Utc}; |
|
| 5 | + | use chrono_tz::Tz; |
|
| 6 | + | ||
| 7 | + | use crate::aic; |
|
| 8 | + | use crate::db::{self, DailyArtwork}; |
|
| 9 | + | use crate::server::AppState; |
|
| 10 | + | ||
| 11 | + | pub async fn run(state: Arc<AppState>) { |
|
| 12 | + | if let Err(e) = ensure_day(&state, &today_in_tz(&state.tz)).await { |
|
| 13 | + | tracing::warn!("startup ensure_day failed: {e}"); |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | if state.backfill_days > 0 { |
|
| 17 | + | let dates = past_n_dates(&state.tz, state.backfill_days); |
|
| 18 | + | match db::missing_dates(&state.db, &dates) { |
|
| 19 | + | Ok(missing) => { |
|
| 20 | + | tracing::info!( |
|
| 21 | + | "backfill: {} of last {} days missing", |
|
| 22 | + | missing.len(), |
|
| 23 | + | state.backfill_days |
|
| 24 | + | ); |
|
| 25 | + | for d in missing { |
|
| 26 | + | if let Err(e) = ensure_day(&state, &d).await { |
|
| 27 | + | tracing::warn!("backfill {d} failed: {e}"); |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | Err(e) => tracing::error!("backfill missing_dates failed: {e}"), |
|
| 32 | + | } |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | loop { |
|
| 36 | + | let dur = duration_until_next_midnight(&state.tz); |
|
| 37 | + | tracing::info!( |
|
| 38 | + | "scheduler sleeping for {}s until next midnight in {}", |
|
| 39 | + | dur.as_secs(), |
|
| 40 | + | state.tz.name() |
|
| 41 | + | ); |
|
| 42 | + | tokio::time::sleep(dur).await; |
|
| 43 | + | let date = today_in_tz(&state.tz); |
|
| 44 | + | if let Err(e) = ensure_day(&state, &date).await { |
|
| 45 | + | tracing::warn!("scheduled ensure_day {date} failed: {e}"); |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | pub async fn ensure_day(state: &AppState, date: &str) -> Result<(), String> { |
|
| 51 | + | if db::get_daily(&state.db, date) |
|
| 52 | + | .map_err(|e| e.to_string())? |
|
| 53 | + | .is_some() |
|
| 54 | + | { |
|
| 55 | + | return Ok(()); |
|
| 56 | + | } |
|
| 57 | + | let raw = aic::pick_unique( |
|
| 58 | + | &state.http, |
|
| 59 | + | &state.db, |
|
| 60 | + | &state.classifications, |
|
| 61 | + | &state.exclude_terms, |
|
| 62 | + | state.max_dedup_retries, |
|
| 63 | + | ) |
|
| 64 | + | .await?; |
|
| 65 | + | let now = Utc::now().to_rfc3339(); |
|
| 66 | + | let daily: DailyArtwork = aic::raw_to_daily(raw, date.to_string(), now) |
|
| 67 | + | .ok_or_else(|| "missing image_id on selected artwork".to_string())?; |
|
| 68 | + | db::insert_daily(&state.db, &daily).map_err(|e| e.to_string())?; |
|
| 69 | + | tracing::info!( |
|
| 70 | + | "stored artwork {} for {} (image_id={})", |
|
| 71 | + | daily.artwork_id, |
|
| 72 | + | date, |
|
| 73 | + | daily.image_id |
|
| 74 | + | ); |
|
| 75 | + | Ok(()) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | pub fn today_in_tz(tz: &Tz) -> String { |
|
| 79 | + | Utc::now() |
|
| 80 | + | .with_timezone(tz) |
|
| 81 | + | .date_naive() |
|
| 82 | + | .format("%Y-%m-%d") |
|
| 83 | + | .to_string() |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | pub fn past_n_dates(tz: &Tz, n: u32) -> Vec<String> { |
|
| 87 | + | let today = Utc::now().with_timezone(tz).date_naive(); |
|
| 88 | + | (1..=n as i64) |
|
| 89 | + | .filter_map(|i| today.checked_sub_signed(ChronoDuration::days(i))) |
|
| 90 | + | .map(|d| d.format("%Y-%m-%d").to_string()) |
|
| 91 | + | .collect() |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | pub fn parse_date(s: &str) -> Option<NaiveDate> { |
|
| 95 | + | NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | fn duration_until_next_midnight(tz: &Tz) -> Duration { |
|
| 99 | + | let now = Utc::now().with_timezone(tz); |
|
| 100 | + | let next_day = now.date_naive() + ChronoDuration::days(1); |
|
| 101 | + | let next_midnight = tz |
|
| 102 | + | .from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time")) |
|
| 103 | + | .single() |
|
| 104 | + | .or_else(|| { |
|
| 105 | + | tz.from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time")) |
|
| 106 | + | .earliest() |
|
| 107 | + | }) |
|
| 108 | + | .unwrap_or_else(|| now + ChronoDuration::days(1)); |
|
| 109 | + | let delta = next_midnight.signed_duration_since(now); |
|
| 110 | + | delta |
|
| 111 | + | .to_std() |
|
| 112 | + | .unwrap_or_else(|_| Duration::from_secs(60 * 60)) |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | #[cfg(test)] |
|
| 116 | + | mod tests { |
|
| 117 | + | use super::*; |
|
| 118 | + | ||
| 119 | + | #[test] |
|
| 120 | + | fn past_n_dates_count() { |
|
| 121 | + | let tz: Tz = "UTC".parse().unwrap(); |
|
| 122 | + | let dates = past_n_dates(&tz, 5); |
|
| 123 | + | assert_eq!(dates.len(), 5); |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | #[test] |
|
| 127 | + | fn past_n_dates_excludes_today() { |
|
| 128 | + | let tz: Tz = "UTC".parse().unwrap(); |
|
| 129 | + | let today = today_in_tz(&tz); |
|
| 130 | + | let dates = past_n_dates(&tz, 3); |
|
| 131 | + | assert!(!dates.contains(&today)); |
|
| 132 | + | } |
|
| 133 | + | ||
| 134 | + | #[test] |
|
| 135 | + | fn parse_date_valid_invalid() { |
|
| 136 | + | assert!(parse_date("2024-05-01").is_some()); |
|
| 137 | + | assert!(parse_date("2024-13-01").is_none()); |
|
| 138 | + | assert!(parse_date("notadate").is_none()); |
|
| 139 | + | } |
|
| 140 | + | } |
| 1 | + | use std::sync::Arc; |
|
| 2 | + | ||
| 3 | + | use askama::Template; |
|
| 4 | + | use axum::{ |
|
| 5 | + | extract::{Path, State}, |
|
| 6 | + | http::{header, Method, StatusCode}, |
|
| 7 | + | response::{Html, IntoResponse, Json, Response}, |
|
| 8 | + | routing::get, |
|
| 9 | + | Router, |
|
| 10 | + | }; |
|
| 11 | + | use chrono::Utc; |
|
| 12 | + | use rust_embed::Embed; |
|
| 13 | + | use serde::Serialize; |
|
| 14 | + | use tower_http::cors::{Any, CorsLayer}; |
|
| 15 | + | ||
| 16 | + | use crate::db::{self, DailyArtwork, Db}; |
|
| 17 | + | use crate::scheduler; |
|
| 18 | + | ||
| 19 | + | #[derive(Embed)] |
|
| 20 | + | #[folder = "static/"] |
|
| 21 | + | struct Static; |
|
| 22 | + | ||
| 23 | + | pub struct AppState { |
|
| 24 | + | pub db: Db, |
|
| 25 | + | pub http: reqwest::Client, |
|
| 26 | + | pub tz: chrono_tz::Tz, |
|
| 27 | + | pub classifications: Vec<String>, |
|
| 28 | + | pub exclude_terms: Vec<String>, |
|
| 29 | + | pub backfill_days: u32, |
|
| 30 | + | pub max_dedup_retries: u32, |
|
| 31 | + | pub base_url: String, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | #[derive(Template)] |
|
| 35 | + | #[template(path = "index.html")] |
|
| 36 | + | struct IndexTemplate { |
|
| 37 | + | today_date: String, |
|
| 38 | + | artwork: Option<ArtworkView>, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | #[derive(Template)] |
|
| 42 | + | #[template(path = "day.html")] |
|
| 43 | + | struct DayTemplate { |
|
| 44 | + | date: String, |
|
| 45 | + | artwork: ArtworkView, |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[derive(Template)] |
|
| 49 | + | #[template(path = "archive.html")] |
|
| 50 | + | struct ArchiveTemplate { |
|
| 51 | + | archive: Vec<ArchiveRow>, |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | #[derive(Template)] |
|
| 55 | + | #[template(path = "error.html")] |
|
| 56 | + | struct ErrorTemplate { |
|
| 57 | + | title: String, |
|
| 58 | + | message: String, |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | struct ArtworkView { |
|
| 62 | + | date: String, |
|
| 63 | + | title: String, |
|
| 64 | + | artist_display: String, |
|
| 65 | + | date_display: String, |
|
| 66 | + | medium_display: String, |
|
| 67 | + | dimensions: String, |
|
| 68 | + | place_of_origin: String, |
|
| 69 | + | credit_line: String, |
|
| 70 | + | description: String, |
|
| 71 | + | short_description: String, |
|
| 72 | + | image_url: String, |
|
| 73 | + | source_url: String, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | struct ArchiveRow { |
|
| 77 | + | date: String, |
|
| 78 | + | title: String, |
|
| 79 | + | artist: String, |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | fn iiif_url(image_id: &str) -> String { |
|
| 83 | + | format!("https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg") |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | fn source_url(artwork_id: i64) -> String { |
|
| 87 | + | format!("https://www.artic.edu/artworks/{artwork_id}") |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | fn to_view(a: DailyArtwork) -> ArtworkView { |
|
| 91 | + | ArtworkView { |
|
| 92 | + | date: a.date, |
|
| 93 | + | title: a.title, |
|
| 94 | + | artist_display: a.artist_display.unwrap_or_default(), |
|
| 95 | + | date_display: a.date_display.unwrap_or_default(), |
|
| 96 | + | medium_display: a.medium_display.unwrap_or_default(), |
|
| 97 | + | dimensions: a.dimensions.unwrap_or_default(), |
|
| 98 | + | place_of_origin: a.place_of_origin.unwrap_or_default(), |
|
| 99 | + | credit_line: a.credit_line.unwrap_or_default(), |
|
| 100 | + | description: a.description.unwrap_or_default(), |
|
| 101 | + | short_description: a.short_description.unwrap_or_default(), |
|
| 102 | + | image_url: iiif_url(&a.image_id), |
|
| 103 | + | source_url: source_url(a.artwork_id), |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | fn to_archive_row(a: &DailyArtwork) -> ArchiveRow { |
|
| 108 | + | ArchiveRow { |
|
| 109 | + | date: a.date.clone(), |
|
| 110 | + | title: a.title.clone(), |
|
| 111 | + | artist: a |
|
| 112 | + | .artist_title |
|
| 113 | + | .clone() |
|
| 114 | + | .or_else(|| a.artist_display.clone()) |
|
| 115 | + | .unwrap_or_default(), |
|
| 116 | + | } |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | fn render<T: Template>(t: T) -> Response { |
|
| 120 | + | match t.render() { |
|
| 121 | + | Ok(body) => Html(body).into_response(), |
|
| 122 | + | Err(e) => { |
|
| 123 | + | tracing::error!("render failed: {e}"); |
|
| 124 | + | (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response() |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | async fn index_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 130 | + | let today = scheduler::today_in_tz(&state.tz); |
|
| 131 | + | let artwork = match db::get_daily(&state.db, &today) { |
|
| 132 | + | Ok(Some(a)) => Some(to_view(a)), |
|
| 133 | + | Ok(None) => None, |
|
| 134 | + | Err(e) => { |
|
| 135 | + | tracing::error!("index db error: {e}"); |
|
| 136 | + | return render(ErrorTemplate { |
|
| 137 | + | title: "Error".to_string(), |
|
| 138 | + | message: "Could not load today's artwork.".to_string(), |
|
| 139 | + | }); |
|
| 140 | + | } |
|
| 141 | + | }; |
|
| 142 | + | render(IndexTemplate { |
|
| 143 | + | today_date: today, |
|
| 144 | + | artwork, |
|
| 145 | + | }) |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | async fn day_handler( |
|
| 149 | + | State(state): State<Arc<AppState>>, |
|
| 150 | + | Path(date): Path<String>, |
|
| 151 | + | ) -> Response { |
|
| 152 | + | let parsed = match scheduler::parse_date(&date) { |
|
| 153 | + | Some(d) => d, |
|
| 154 | + | None => { |
|
| 155 | + | return ( |
|
| 156 | + | StatusCode::BAD_REQUEST, |
|
| 157 | + | render(ErrorTemplate { |
|
| 158 | + | title: "Invalid date".to_string(), |
|
| 159 | + | message: format!("'{date}' is not a valid YYYY-MM-DD date."), |
|
| 160 | + | }), |
|
| 161 | + | ) |
|
| 162 | + | .into_response(); |
|
| 163 | + | } |
|
| 164 | + | }; |
|
| 165 | + | let today = scheduler::today_in_tz(&state.tz); |
|
| 166 | + | if date.as_str() > today.as_str() { |
|
| 167 | + | return ( |
|
| 168 | + | StatusCode::NOT_FOUND, |
|
| 169 | + | render(ErrorTemplate { |
|
| 170 | + | title: "Not yet".to_string(), |
|
| 171 | + | message: format!( |
|
| 172 | + | "{} is in the future. The next day's artwork is not available until midnight {}.", |
|
| 173 | + | parsed, state.tz.name() |
|
| 174 | + | ), |
|
| 175 | + | }), |
|
| 176 | + | ) |
|
| 177 | + | .into_response(); |
|
| 178 | + | } |
|
| 179 | + | let artwork = match db::get_daily(&state.db, &date) { |
|
| 180 | + | Ok(Some(a)) => to_view(a), |
|
| 181 | + | Ok(None) => { |
|
| 182 | + | return ( |
|
| 183 | + | StatusCode::NOT_FOUND, |
|
| 184 | + | render(ErrorTemplate { |
|
| 185 | + | title: "Not found".to_string(), |
|
| 186 | + | message: format!("No artwork stored for {date}."), |
|
| 187 | + | }), |
|
| 188 | + | ) |
|
| 189 | + | .into_response(); |
|
| 190 | + | } |
|
| 191 | + | Err(e) => { |
|
| 192 | + | tracing::error!("day db error: {e}"); |
|
| 193 | + | return ( |
|
| 194 | + | StatusCode::INTERNAL_SERVER_ERROR, |
|
| 195 | + | render(ErrorTemplate { |
|
| 196 | + | title: "Error".to_string(), |
|
| 197 | + | message: "Database error.".to_string(), |
|
| 198 | + | }), |
|
| 199 | + | ) |
|
| 200 | + | .into_response(); |
|
| 201 | + | } |
|
| 202 | + | }; |
|
| 203 | + | render(DayTemplate { |
|
| 204 | + | date, |
|
| 205 | + | artwork, |
|
| 206 | + | }) |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | async fn archive_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 210 | + | let archive = db::list_daily(&state.db, 1000) |
|
| 211 | + | .unwrap_or_default() |
|
| 212 | + | .iter() |
|
| 213 | + | .map(to_archive_row) |
|
| 214 | + | .collect(); |
|
| 215 | + | render(ArchiveTemplate { archive }) |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | #[derive(Serialize)] |
|
| 219 | + | struct ApiArtwork<'a> { |
|
| 220 | + | date: &'a str, |
|
| 221 | + | artwork_id: i64, |
|
| 222 | + | title: &'a str, |
|
| 223 | + | artist_display: Option<&'a str>, |
|
| 224 | + | date_display: Option<&'a str>, |
|
| 225 | + | medium_display: Option<&'a str>, |
|
| 226 | + | dimensions: Option<&'a str>, |
|
| 227 | + | place_of_origin: Option<&'a str>, |
|
| 228 | + | credit_line: Option<&'a str>, |
|
| 229 | + | short_description: Option<&'a str>, |
|
| 230 | + | image_id: &'a str, |
|
| 231 | + | image_url: String, |
|
| 232 | + | source_url: String, |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | fn to_api<'a>(a: &'a DailyArtwork) -> ApiArtwork<'a> { |
|
| 236 | + | ApiArtwork { |
|
| 237 | + | date: &a.date, |
|
| 238 | + | artwork_id: a.artwork_id, |
|
| 239 | + | title: &a.title, |
|
| 240 | + | artist_display: a.artist_display.as_deref(), |
|
| 241 | + | date_display: a.date_display.as_deref(), |
|
| 242 | + | medium_display: a.medium_display.as_deref(), |
|
| 243 | + | dimensions: a.dimensions.as_deref(), |
|
| 244 | + | place_of_origin: a.place_of_origin.as_deref(), |
|
| 245 | + | credit_line: a.credit_line.as_deref(), |
|
| 246 | + | short_description: a.short_description.as_deref(), |
|
| 247 | + | image_id: &a.image_id, |
|
| 248 | + | image_url: iiif_url(&a.image_id), |
|
| 249 | + | source_url: source_url(a.artwork_id), |
|
| 250 | + | } |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | async fn api_today(State(state): State<Arc<AppState>>) -> Response { |
|
| 254 | + | let today = scheduler::today_in_tz(&state.tz); |
|
| 255 | + | match db::get_daily(&state.db, &today) { |
|
| 256 | + | Ok(Some(a)) => Json(to_api(&a)).into_response(), |
|
| 257 | + | Ok(None) => ( |
|
| 258 | + | StatusCode::NOT_FOUND, |
|
| 259 | + | Json(serde_json::json!({"error": "today not yet populated"})), |
|
| 260 | + | ) |
|
| 261 | + | .into_response(), |
|
| 262 | + | Err(e) => { |
|
| 263 | + | tracing::error!("api_today db error: {e}"); |
|
| 264 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 265 | + | } |
|
| 266 | + | } |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | async fn api_day( |
|
| 270 | + | State(state): State<Arc<AppState>>, |
|
| 271 | + | Path(date): Path<String>, |
|
| 272 | + | ) -> Response { |
|
| 273 | + | if scheduler::parse_date(&date).is_none() { |
|
| 274 | + | return ( |
|
| 275 | + | StatusCode::BAD_REQUEST, |
|
| 276 | + | Json(serde_json::json!({"error": "invalid date format"})), |
|
| 277 | + | ) |
|
| 278 | + | .into_response(); |
|
| 279 | + | } |
|
| 280 | + | let today = scheduler::today_in_tz(&state.tz); |
|
| 281 | + | if date.as_str() > today.as_str() { |
|
| 282 | + | return ( |
|
| 283 | + | StatusCode::NOT_FOUND, |
|
| 284 | + | Json(serde_json::json!({"error": "future date"})), |
|
| 285 | + | ) |
|
| 286 | + | .into_response(); |
|
| 287 | + | } |
|
| 288 | + | match db::get_daily(&state.db, &date) { |
|
| 289 | + | Ok(Some(a)) => Json(to_api(&a)).into_response(), |
|
| 290 | + | Ok(None) => ( |
|
| 291 | + | StatusCode::NOT_FOUND, |
|
| 292 | + | Json(serde_json::json!({"error": "no record for date"})), |
|
| 293 | + | ) |
|
| 294 | + | .into_response(), |
|
| 295 | + | Err(e) => { |
|
| 296 | + | tracing::error!("api_day db error: {e}"); |
|
| 297 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 298 | + | } |
|
| 299 | + | } |
|
| 300 | + | } |
|
| 301 | + | ||
| 302 | + | async fn api_archive(State(state): State<Arc<AppState>>) -> Response { |
|
| 303 | + | match db::list_daily(&state.db, 1000) { |
|
| 304 | + | Ok(items) => { |
|
| 305 | + | let out: Vec<ApiArtwork> = items.iter().map(to_api).collect(); |
|
| 306 | + | Json(out).into_response() |
|
| 307 | + | } |
|
| 308 | + | Err(e) => { |
|
| 309 | + | tracing::error!("api_archive db error: {e}"); |
|
| 310 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 311 | + | } |
|
| 312 | + | } |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | fn escape_xml(s: &str) -> String { |
|
| 316 | + | s.replace('&', "&") |
|
| 317 | + | .replace('<', "<") |
|
| 318 | + | .replace('>', ">") |
|
| 319 | + | .replace('"', """) |
|
| 320 | + | .replace('\'', "'") |
|
| 321 | + | } |
|
| 322 | + | ||
| 323 | + | fn entry_published(date: &str) -> String { |
|
| 324 | + | chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") |
|
| 325 | + | .ok() |
|
| 326 | + | .and_then(|d| d.and_hms_opt(12, 0, 0)) |
|
| 327 | + | .map(|dt| dt.and_utc().to_rfc3339()) |
|
| 328 | + | .unwrap_or_else(|| Utc::now().to_rfc3339()) |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | async fn atom_feed_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 332 | + | let items = match db::list_daily(&state.db, 100) { |
|
| 333 | + | Ok(items) => items, |
|
| 334 | + | Err(e) => { |
|
| 335 | + | tracing::error!("atom feed query failed: {e}"); |
|
| 336 | + | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 337 | + | } |
|
| 338 | + | }; |
|
| 339 | + | ||
| 340 | + | let updated = items |
|
| 341 | + | .first() |
|
| 342 | + | .map(|i| entry_published(&i.date)) |
|
| 343 | + | .unwrap_or_else(|| Utc::now().to_rfc3339()); |
|
| 344 | + | ||
| 345 | + | let base = state.base_url.trim_end_matches('/'); |
|
| 346 | + | let self_url = format!("{base}/feed.xml"); |
|
| 347 | + | ||
| 348 | + | let mut xml = String::with_capacity(8192); |
|
| 349 | + | xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); |
|
| 350 | + | xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"); |
|
| 351 | + | xml.push_str(" <title>Easel — Daily Artwork</title>\n"); |
|
| 352 | + | xml.push_str(" <subtitle>A daily painting from the Art Institute of Chicago</subtitle>\n"); |
|
| 353 | + | xml.push_str(&format!( |
|
| 354 | + | " <link href=\"{}\" rel=\"self\" type=\"application/atom+xml\" />\n", |
|
| 355 | + | escape_xml(&self_url) |
|
| 356 | + | )); |
|
| 357 | + | xml.push_str(&format!(" <link href=\"{}\" />\n", escape_xml(base))); |
|
| 358 | + | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(&self_url))); |
|
| 359 | + | xml.push_str(&format!(" <updated>{updated}</updated>\n")); |
|
| 360 | + | ||
| 361 | + | for item in &items { |
|
| 362 | + | let published = entry_published(&item.date); |
|
| 363 | + | let entry_url = format!("{base}/day/{}", item.date); |
|
| 364 | + | let author_name = item |
|
| 365 | + | .artist_title |
|
| 366 | + | .as_deref() |
|
| 367 | + | .or(item.artist_display.as_deref()) |
|
| 368 | + | .filter(|s| !s.is_empty()) |
|
| 369 | + | .unwrap_or("Unknown"); |
|
| 370 | + | let summary = item |
|
| 371 | + | .short_description |
|
| 372 | + | .as_deref() |
|
| 373 | + | .or(item.description.as_deref()) |
|
| 374 | + | .unwrap_or(""); |
|
| 375 | + | let image = iiif_url(&item.image_id); |
|
| 376 | + | let content = format!( |
|
| 377 | + | "<p><img src=\"{}\" alt=\"{}\" /></p><p>{}</p>", |
|
| 378 | + | escape_xml(&image), |
|
| 379 | + | escape_xml(&item.title), |
|
| 380 | + | escape_xml(summary) |
|
| 381 | + | ); |
|
| 382 | + | ||
| 383 | + | xml.push_str(" <entry>\n"); |
|
| 384 | + | xml.push_str(&format!( |
|
| 385 | + | " <title>{} — {}</title>\n", |
|
| 386 | + | escape_xml(&item.date), |
|
| 387 | + | escape_xml(&item.title) |
|
| 388 | + | )); |
|
| 389 | + | xml.push_str(&format!( |
|
| 390 | + | " <link href=\"{}\" />\n", |
|
| 391 | + | escape_xml(&entry_url) |
|
| 392 | + | )); |
|
| 393 | + | xml.push_str(&format!(" <id>{}</id>\n", escape_xml(&entry_url))); |
|
| 394 | + | xml.push_str(&format!(" <updated>{published}</updated>\n")); |
|
| 395 | + | xml.push_str(&format!(" <published>{published}</published>\n")); |
|
| 396 | + | xml.push_str(" <author>\n"); |
|
| 397 | + | xml.push_str(&format!(" <name>{}</name>\n", escape_xml(author_name))); |
|
| 398 | + | xml.push_str(" </author>\n"); |
|
| 399 | + | if !summary.is_empty() { |
|
| 400 | + | xml.push_str(&format!( |
|
| 401 | + | " <summary>{}</summary>\n", |
|
| 402 | + | escape_xml(summary) |
|
| 403 | + | )); |
|
| 404 | + | } |
|
| 405 | + | xml.push_str(&format!( |
|
| 406 | + | " <content type=\"html\">{}</content>\n", |
|
| 407 | + | escape_xml(&content) |
|
| 408 | + | )); |
|
| 409 | + | xml.push_str(" </entry>\n"); |
|
| 410 | + | } |
|
| 411 | + | ||
| 412 | + | xml.push_str("</feed>\n"); |
|
| 413 | + | ||
| 414 | + | ( |
|
| 415 | + | [(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")], |
|
| 416 | + | xml, |
|
| 417 | + | ) |
|
| 418 | + | .into_response() |
|
| 419 | + | } |
|
| 420 | + | ||
| 421 | + | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 422 | + | match Static::get(&path) { |
|
| 423 | + | Some(file) => { |
|
| 424 | + | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 425 | + | ( |
|
| 426 | + | [(header::CONTENT_TYPE, mime.as_ref())], |
|
| 427 | + | file.data.to_vec(), |
|
| 428 | + | ) |
|
| 429 | + | .into_response() |
|
| 430 | + | } |
|
| 431 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 432 | + | } |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | pub async fn run() { |
|
| 436 | + | let db_path = std::env::var("EASEL_DB_PATH").unwrap_or_else(|_| "easel.sqlite".to_string()); |
|
| 437 | + | let tz_name = std::env::var("EASEL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); |
|
| 438 | + | let tz: chrono_tz::Tz = tz_name.parse().unwrap_or_else(|_| { |
|
| 439 | + | tracing::warn!("invalid EASEL_TIMEZONE={tz_name}, falling back to UTC"); |
|
| 440 | + | chrono_tz::UTC |
|
| 441 | + | }); |
|
| 442 | + | let classifications: Vec<String> = std::env::var("EASEL_CLASSIFICATIONS") |
|
| 443 | + | .unwrap_or_else(|_| "painting".to_string()) |
|
| 444 | + | .split(',') |
|
| 445 | + | .map(|s| s.trim().to_string()) |
|
| 446 | + | .filter(|s| !s.is_empty()) |
|
| 447 | + | .collect(); |
|
| 448 | + | if classifications.is_empty() { |
|
| 449 | + | panic!("EASEL_CLASSIFICATIONS resolved to empty list"); |
|
| 450 | + | } |
|
| 451 | + | let exclude_terms: Vec<String> = std::env::var("EASEL_EXCLUDE_TERMS") |
|
| 452 | + | .unwrap_or_else(|_| "erotic,erotica,shunga".to_string()) |
|
| 453 | + | .split(',') |
|
| 454 | + | .map(|s| s.trim().to_string()) |
|
| 455 | + | .filter(|s| !s.is_empty()) |
|
| 456 | + | .collect(); |
|
| 457 | + | let backfill_days: u32 = std::env::var("EASEL_BACKFILL_DAYS") |
|
| 458 | + | .ok() |
|
| 459 | + | .and_then(|v| v.parse().ok()) |
|
| 460 | + | .unwrap_or(0); |
|
| 461 | + | let max_dedup_retries: u32 = std::env::var("EASEL_MAX_DEDUP_RETRIES") |
|
| 462 | + | .ok() |
|
| 463 | + | .and_then(|v| v.parse().ok()) |
|
| 464 | + | .unwrap_or(10); |
|
| 465 | + | let base_url = std::env::var("EASEL_BASE_URL") |
|
| 466 | + | .unwrap_or_else(|_| "http://localhost:4242".to_string()) |
|
| 467 | + | .trim_end_matches('/') |
|
| 468 | + | .to_string(); |
|
| 469 | + | ||
| 470 | + | let db = db::init_db(&db_path); |
|
| 471 | + | let http = crate::aic::build_client(); |
|
| 472 | + | ||
| 473 | + | let state = Arc::new(AppState { |
|
| 474 | + | db, |
|
| 475 | + | http, |
|
| 476 | + | tz, |
|
| 477 | + | classifications: classifications.clone(), |
|
| 478 | + | exclude_terms: exclude_terms.clone(), |
|
| 479 | + | backfill_days, |
|
| 480 | + | max_dedup_retries, |
|
| 481 | + | base_url, |
|
| 482 | + | }); |
|
| 483 | + | ||
| 484 | + | tracing::info!( |
|
| 485 | + | "easel starting: tz={} classifications={:?} exclude_terms={:?} backfill_days={} retries={}", |
|
| 486 | + | state.tz.name(), |
|
| 487 | + | classifications, |
|
| 488 | + | exclude_terms, |
|
| 489 | + | backfill_days, |
|
| 490 | + | max_dedup_retries |
|
| 491 | + | ); |
|
| 492 | + | tracing::info!("startup time: {}", Utc::now().to_rfc3339()); |
|
| 493 | + | ||
| 494 | + | tokio::spawn(scheduler::run(state.clone())); |
|
| 495 | + | ||
| 496 | + | let public_cors = CorsLayer::new() |
|
| 497 | + | .allow_origin(Any) |
|
| 498 | + | .allow_methods([Method::GET]) |
|
| 499 | + | .allow_headers(Any); |
|
| 500 | + | ||
| 501 | + | let api_router = Router::new() |
|
| 502 | + | .route("/api/today", get(api_today)) |
|
| 503 | + | .route("/api/day/{date}", get(api_day)) |
|
| 504 | + | .route("/api/archive", get(api_archive)) |
|
| 505 | + | .route("/feed.xml", get(atom_feed_handler)) |
|
| 506 | + | .layer(public_cors); |
|
| 507 | + | ||
| 508 | + | let app = Router::new() |
|
| 509 | + | .route("/", get(index_handler)) |
|
| 510 | + | .route("/day/{date}", get(day_handler)) |
|
| 511 | + | .route("/archive", get(archive_handler)) |
|
| 512 | + | .route("/static/{*path}", get(static_handler)) |
|
| 513 | + | .merge(api_router) |
|
| 514 | + | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 515 | + | .with_state(state); |
|
| 516 | + | ||
| 517 | + | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 518 | + | let port: u16 = std::env::var("PORT") |
|
| 519 | + | .ok() |
|
| 520 | + | .and_then(|v| v.parse().ok()) |
|
| 521 | + | .unwrap_or(4242); |
|
| 522 | + | let addr = format!("{host}:{port}"); |
|
| 523 | + | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 524 | + | .await |
|
| 525 | + | .unwrap_or_else(|_| panic!("failed to bind {addr}")); |
|
| 526 | + | tracing::info!("easel listening on http://{addr}"); |
|
| 527 | + | axum::serve(listener, app).await.expect("axum serve"); |
|
| 528 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | .artwork-figure { |
|
| 2 | + | margin: 0 0 1rem; |
|
| 3 | + | border: 1px solid #333; |
|
| 4 | + | } |
|
| 5 | + | ||
| 6 | + | .artwork-figure img { |
|
| 7 | + | display: block; |
|
| 8 | + | width: 100%; |
|
| 9 | + | height: auto; |
|
| 10 | + | } |
|
| 11 | + | ||
| 12 | + | .artwork-meta { |
|
| 13 | + | margin-bottom: 0.5rem; |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | .artwork-date { |
|
| 17 | + | opacity: 0.5; |
|
| 18 | + | font-size: 12px; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | .artwork-title { |
|
| 22 | + | font-size: 16px; |
|
| 23 | + | font-weight: 700; |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | .artwork-artist { |
|
| 27 | + | opacity: 0.7; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .artwork-details { |
|
| 31 | + | display: grid; |
|
| 32 | + | grid-template-columns: max-content 1fr; |
|
| 33 | + | gap: 0.25rem 1rem; |
|
| 34 | + | margin: 1rem 0; |
|
| 35 | + | font-size: 13px; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | .artwork-details dt { |
|
| 39 | + | opacity: 0.5; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | .artwork-description { |
|
| 43 | + | opacity: 0.85; |
|
| 44 | + | font-size: 13px; |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | .artwork-description p + p { |
|
| 48 | + | margin-top: 0.75rem; |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | .archive-list { |
|
| 52 | + | margin-top: 2rem; |
|
| 53 | + | border-top: 1px solid #333; |
|
| 54 | + | padding-top: 1rem; |
|
| 55 | + | width: 100%; |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | .archive-list h3 { |
|
| 59 | + | font-size: 12px; |
|
| 60 | + | opacity: 0.5; |
|
| 61 | + | text-transform: uppercase; |
|
| 62 | + | letter-spacing: 0.05em; |
|
| 63 | + | margin-bottom: 0.5rem; |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | .item a { |
|
| 67 | + | display: grid; |
|
| 68 | + | grid-template-columns: 90px 1fr auto; |
|
| 69 | + | gap: 0.75rem; |
|
| 70 | + | text-decoration: none; |
|
| 71 | + | color: inherit; |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | .item-title { |
|
| 75 | + | overflow: hidden; |
|
| 76 | + | text-overflow: ellipsis; |
|
| 77 | + | white-space: nowrap; |
|
| 78 | + | } |
| 1 | + | <article class="artwork"> |
|
| 2 | + | <figure class="artwork-figure"> |
|
| 3 | + | <img src="{{ artwork.image_url }}" alt="{{ artwork.title }}" loading="lazy" /> |
|
| 4 | + | </figure> |
|
| 5 | + | <header class="artwork-meta"> |
|
| 6 | + | <p class="artwork-date">{{ artwork.date }}</p> |
|
| 7 | + | <h2 class="artwork-title"> |
|
| 8 | + | <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer"><em>{{ artwork.title }}</em></a> |
|
| 9 | + | </h2> |
|
| 10 | + | {% if !artwork.artist_display.is_empty() %} |
|
| 11 | + | <p class="artwork-artist">{{ artwork.artist_display }}</p> |
|
| 12 | + | {% endif %} |
|
| 13 | + | </header> |
|
| 14 | + | <dl class="artwork-details"> |
|
| 15 | + | {% if !artwork.date_display.is_empty() %} |
|
| 16 | + | <dt>Date</dt><dd>{{ artwork.date_display }}</dd> |
|
| 17 | + | {% endif %} |
|
| 18 | + | {% if !artwork.place_of_origin.is_empty() %} |
|
| 19 | + | <dt>Origin</dt><dd>{{ artwork.place_of_origin }}</dd> |
|
| 20 | + | {% endif %} |
|
| 21 | + | {% if !artwork.medium_display.is_empty() %} |
|
| 22 | + | <dt>Medium</dt><dd>{{ artwork.medium_display }}</dd> |
|
| 23 | + | {% endif %} |
|
| 24 | + | {% if !artwork.dimensions.is_empty() %} |
|
| 25 | + | <dt>Dimensions</dt><dd>{{ artwork.dimensions }}</dd> |
|
| 26 | + | {% endif %} |
|
| 27 | + | {% if !artwork.credit_line.is_empty() %} |
|
| 28 | + | <dt>Credit</dt><dd>{{ artwork.credit_line }}</dd> |
|
| 29 | + | {% endif %} |
|
| 30 | + | </dl> |
|
| 31 | + | {% if !artwork.description.is_empty() %} |
|
| 32 | + | <div class="artwork-description">{{ artwork.description|safe }}</div> |
|
| 33 | + | {% else if !artwork.short_description.is_empty() %} |
|
| 34 | + | <p class="artwork-description">{{ artwork.short_description }}</p> |
|
| 35 | + | {% endif %} |
|
| 36 | + | </article> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Easel — Archive{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <h2>Archive</h2> |
|
| 5 | + | {% if archive.is_empty() %} |
|
| 6 | + | <p class="empty">No artworks stored yet.</p> |
|
| 7 | + | {% else %} |
|
| 8 | + | <ul class="item-list"> |
|
| 9 | + | {% for row in archive %} |
|
| 10 | + | <li class="item"> |
|
| 11 | + | <a href="/day/{{ row.date }}"> |
|
| 12 | + | <span class="item-meta">{{ row.date }}</span> |
|
| 13 | + | <span class="item-title"><em>{{ row.title }}</em></span> |
|
| 14 | + | {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %} |
|
| 15 | + | </a> |
|
| 16 | + | </li> |
|
| 17 | + | {% endfor %} |
|
| 18 | + | </ul> |
|
| 19 | + | {% endif %} |
|
| 20 | + | {% endblock %} |
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8" /> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
| 6 | + | <title>{% block title %}Easel{% endblock %}</title> |
|
| 7 | + | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
|
| 8 | + | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
|
| 9 | + | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
|
| 10 | + | <link rel="manifest" href="/static/site.webmanifest"> |
|
| 11 | + | <link rel="icon" href="/static/favicon.ico"> |
|
| 12 | + | <meta property="og:title" content="Easel"> |
|
| 13 | + | <meta property="og:description" content="A daily painting from the Art Institute of Chicago"> |
|
| 14 | + | <meta property="og:image" content="/static/og.png"> |
|
| 15 | + | <meta property="og:type" content="website"> |
|
| 16 | + | <meta name="theme-color" content="#121113" /> |
|
| 17 | + | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
|
| 18 | + | <link rel="stylesheet" href="/static/styles.css" /> |
|
| 19 | + | <meta name="description" content="A daily painting from the Art Institute of Chicago" /> |
|
| 20 | + | <link rel="alternate" type="application/atom+xml" title="Easel — Daily Artwork" href="/feed.xml" /> |
|
| 21 | + | </head> |
|
| 22 | + | <body> |
|
| 23 | + | <header class="header"> |
|
| 24 | + | <a href="/" class="logo">EASEL</a> |
|
| 25 | + | <nav class="links"> |
|
| 26 | + | <a href="/">today</a> |
|
| 27 | + | <a href="/archive">archive</a> |
|
| 28 | + | <a href="/feed.xml">rss</a> |
|
| 29 | + | </nav> |
|
| 30 | + | </header> |
|
| 31 | + | <main> |
|
| 32 | + | {% block content %}{% endblock %} |
|
| 33 | + | </main> |
|
| 34 | + | </body> |
|
| 35 | + | </html> |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Easel — {{ date }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% include "_artwork.html" %} |
|
| 5 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Easel — {{ title }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="error-page"> |
|
| 5 | + | <h2>{{ title }}</h2> |
|
| 6 | + | <p>{{ message }}</p> |
|
| 7 | + | <p><a href="/">← back to today</a></p> |
|
| 8 | + | </div> |
|
| 9 | + | {% endblock %} |
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Easel — {{ today_date }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% if let Some(artwork) = artwork %} |
|
| 5 | + | {% include "_artwork.html" %} |
|
| 6 | + | {% else %} |
|
| 7 | + | <div class="empty"> |
|
| 8 | + | <p>Today's artwork ({{ today_date }}) is not yet available. Check back shortly.</p> |
|
| 9 | + | </div> |
|
| 10 | + | {% endif %} |
|
| 11 | + | {% endblock %} |
| 10 | 10 | "cargo:apps/posts", |
|
| 11 | 11 | "cargo:apps/library", |
|
| 12 | 12 | "cargo:apps/bookmarks", |
|
| 13 | + | "cargo:apps/easel", |
|
| 13 | 14 | ] |
|
| 14 | 15 | ||
| 15 | 16 | # Config for 'dist' |
| 86 | 86 | - bookmarks_data:/data |
|
| 87 | 87 | env_file: apps/bookmarks/.env |
|
| 88 | 88 | ||
| 89 | + | easel: |
|
| 90 | + | image: ghcr.io/stevedylandev/andromeda/easel:latest |
|
| 91 | + | restart: unless-stopped |
|
| 92 | + | ports: |
|
| 93 | + | - "4242:3000" |
|
| 94 | + | volumes: |
|
| 95 | + | - easel_data:/data |
|
| 96 | + | env_file: apps/easel/.env |
|
| 97 | + | ||
| 89 | 98 | backup: |
|
| 90 | 99 | image: ghcr.io/stevedylandev/andromeda/backup:latest |
|
| 91 | 100 | volumes: |
|
| 94 | 103 | - cellar_data:/data/cellar:ro |
|
| 95 | 104 | - library_data:/data/library:ro |
|
| 96 | 105 | - bookmarks_data:/data/bookmarks:ro |
|
| 106 | + | - easel_data:/data/easel:ro |
|
| 97 | 107 | env_file: apps/backup/.env |
|
| 98 | 108 | restart: unless-stopped |
|
| 99 | 109 | ||
| 122 | 132 | bookmarks_data: |
|
| 123 | 133 | external: true |
|
| 124 | 134 | name: bookmarks_bookmarks-data |
|
| 135 | + | easel_data: |
|
| 136 | + | external: true |
|
| 137 | + | name: easel_easel-data |
|