Merge pull request #44 from stevedylandev/feat/init-easel d88f02e6
feat/init easel
Steve Simkins · 2026-05-08 22:25 32 file(s) · +1569 −16
.github/workflows/docker-test.yml +2 −2
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
.github/workflows/docker.yml +2 −2
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
Cargo.lock +64 −12
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",
Cargo.toml +1 −0
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",
apps/easel/.env.example (added) +25 −0
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
apps/easel/Cargo.toml (added) +28 −0
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"] }
apps/easel/Dockerfile (added) +24 −0
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"]
apps/easel/README.md (added) +37 −0
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`
apps/easel/docker-compose.yml (added) +21 −0
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:
apps/easel/src/aic.rs (added) +223 −0
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(&params)
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(&params)
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 +
}
apps/easel/src/db.rs (added) +249 −0
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 +
}
apps/easel/src/main.rs (added) +16 −0
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 +
}
apps/easel/src/scheduler.rs (added) +140 −0
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 +
}
apps/easel/src/server.rs (added) +528 −0
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('&', "&amp;")
317 +
        .replace('<', "&lt;")
318 +
        .replace('>', "&gt;")
319 +
        .replace('"', "&quot;")
320 +
        .replace('\'', "&apos;")
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 +
}
apps/easel/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/easel/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/easel/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/easel/static/icon.png (added) +0 −0

Binary file — no preview.

apps/easel/static/og.png (added) +0 −0

Binary file — no preview.

apps/easel/static/site.webmanifest (added) +1 −0
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"}
apps/easel/static/styles.css (added) +78 −0
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 +
}
apps/easel/templates/_artwork.html (added) +36 −0
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>
apps/easel/templates/archive.html (added) +20 −0
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 %}
apps/easel/templates/base.html (added) +35 −0
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>
apps/easel/templates/day.html (added) +5 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Easel — {{ date }}{% endblock %}
3 +
{% block content %}
4 +
  {% include "_artwork.html" %}
5 +
{% endblock %}
apps/easel/templates/error.html (added) +9 −0
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 %}
apps/easel/templates/index.html (added) +11 −0
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 %}
dist-workspace.toml +1 −0
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'
docker-compose.yml +13 −0
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