feat: init easels c46c11b6
Steve Simkins · 2026-05-08 21:39 23 file(s) · +1476 −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 +63 −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 +
 "tracing",
1328 +
 "tracing-subscriber",
1329 +
 "urlencoding",
1330 +
]
1298 1331
1299 1332
[[package]]
1300 1333
name = "ego-tree"
2603 2636
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
2604 2637
dependencies = [
2605 2638
 "log",
2606 -
 "phf",
2639 +
 "phf 0.11.3",
2607 2640
 "phf_codegen",
2608 2641
 "string_cache",
2609 2642
 "string_cache_codegen",
3222 3255
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
3223 3256
dependencies = [
3224 3257
 "phf_macros",
3225 -
 "phf_shared",
3258 +
 "phf_shared 0.11.3",
3259 +
]
3260 +
3261 +
[[package]]
3262 +
name = "phf"
3263 +
version = "0.12.1"
3264 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3265 +
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
3266 +
dependencies = [
3267 +
 "phf_shared 0.12.1",
3226 3268
]
3227 3269
3228 3270
[[package]]
3232 3274
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
3233 3275
dependencies = [
3234 3276
 "phf_generator",
3235 -
 "phf_shared",
3277 +
 "phf_shared 0.11.3",
3236 3278
]
3237 3279
3238 3280
[[package]]
3241 3283
source = "registry+https://github.com/rust-lang/crates.io-index"
3242 3284
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
3243 3285
dependencies = [
3244 -
 "phf_shared",
3286 +
 "phf_shared 0.11.3",
3245 3287
 "rand 0.8.5",
3246 3288
]
3247 3289
3252 3294
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
3253 3295
dependencies = [
3254 3296
 "phf_generator",
3255 -
 "phf_shared",
3297 +
 "phf_shared 0.11.3",
3256 3298
 "proc-macro2",
3257 3299
 "quote",
3258 3300
 "syn 2.0.117",
3268 3310
]
3269 3311
3270 3312
[[package]]
3313 +
name = "phf_shared"
3314 +
version = "0.12.1"
3315 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3316 +
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
3317 +
dependencies = [
3318 +
 "siphasher",
3319 +
]
3320 +
3321 +
[[package]]
3271 3322
name = "pin-project-lite"
3272 3323
version = "0.2.17"
3273 3324
source = "registry+https://github.com/rust-lang/crates.io-index"
4213 4264
 "fxhash",
4214 4265
 "log",
4215 4266
 "new_debug_unreachable",
4216 -
 "phf",
4267 +
 "phf 0.11.3",
4217 4268
 "phf_codegen",
4218 4269
 "precomputed-hash",
4219 4270
 "servo_arc",
4527 4578
dependencies = [
4528 4579
 "new_debug_unreachable",
4529 4580
 "parking_lot",
4530 -
 "phf_shared",
4581 +
 "phf_shared 0.11.3",
4531 4582
 "precomputed-hash",
4532 4583
 "serde",
4533 4584
]
4539 4590
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
4540 4591
dependencies = [
4541 4592
 "phf_generator",
4542 -
 "phf_shared",
4593 +
 "phf_shared 0.11.3",
4543 4594
 "proc-macro2",
4544 4595
 "quote",
4545 4596
]
4693 4744
dependencies = [
4694 4745
 "fnv",
4695 4746
 "nom 7.1.3",
4696 -
 "phf",
4747 +
 "phf 0.11.3",
4697 4748
 "phf_codegen",
4698 4749
]
4699 4750
4730 4781
 "ordered-float",
4731 4782
 "pest",
4732 4783
 "pest_derive",
4733 -
 "phf",
4784 +
 "phf 0.11.3",
4734 4785
 "sha2 0.10.9",
4735 4786
 "signal-hook",
4736 4787
 "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) +18 −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 +
# On startup, fill any missing day in the last N days. 0 disables backfill.
15 +
EASEL_BACKFILL_DAYS=0
16 +
17 +
# Max retries when picking a non-duplicate artwork
18 +
EASEL_MAX_DEDUP_RETRIES=10
apps/easel/Cargo.toml (added) +27 −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"
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) +197 −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 +
fn build_params(classifications: &[String]) -> String {
52 +
    let terms: Vec<serde_json::Value> = classifications
53 +
        .iter()
54 +
        .map(|c| serde_json::Value::String(c.to_lowercase()))
55 +
        .collect();
56 +
    let body = serde_json::json!({
57 +
        "query": {
58 +
            "bool": {
59 +
                "must": [
60 +
                    { "term": { "is_public_domain": true } },
61 +
                    { "terms": { "classification_title.keyword": terms } },
62 +
                    { "exists": { "field": "image_id" } }
63 +
                ]
64 +
            }
65 +
        }
66 +
    });
67 +
    body.to_string()
68 +
}
69 +
70 +
pub async fn total_matching(
71 +
    client: &reqwest::Client,
72 +
    classifications: &[String],
73 +
) -> Result<u64, String> {
74 +
    let params = build_params(classifications);
75 +
    let url = format!(
76 +
        "{SEARCH_URL}?params={}&limit=1&fields=id",
77 +
        urlencoding::encode(&params)
78 +
    );
79 +
    let resp = client
80 +
        .get(&url)
81 +
        .send()
82 +
        .await
83 +
        .map_err(|e| format!("count fetch failed: {e}"))?;
84 +
    if !resp.status().is_success() {
85 +
        return Err(format!("count returned status {}", resp.status()));
86 +
    }
87 +
    let body: SearchResponse<IdOnly> = resp
88 +
        .json()
89 +
        .await
90 +
        .map_err(|e| format!("count parse failed: {e}"))?;
91 +
    Ok(body.pagination.total)
92 +
}
93 +
94 +
pub async fn fetch_artwork_at(
95 +
    client: &reqwest::Client,
96 +
    classifications: &[String],
97 +
    page: u64,
98 +
) -> Result<Option<RawArtwork>, String> {
99 +
    let params = build_params(classifications);
100 +
    let url = format!(
101 +
        "{SEARCH_URL}?params={}&limit=1&page={page}&fields={FIELDS}",
102 +
        urlencoding::encode(&params)
103 +
    );
104 +
    let resp = client
105 +
        .get(&url)
106 +
        .send()
107 +
        .await
108 +
        .map_err(|e| format!("artwork fetch failed: {e}"))?;
109 +
    if !resp.status().is_success() {
110 +
        return Err(format!("artwork returned status {}", resp.status()));
111 +
    }
112 +
    let mut body: SearchResponse<RawArtwork> = resp
113 +
        .json()
114 +
        .await
115 +
        .map_err(|e| format!("artwork parse failed: {e}"))?;
116 +
    Ok(body.data.pop())
117 +
}
118 +
119 +
pub async fn pick_unique(
120 +
    client: &reqwest::Client,
121 +
    db: &Db,
122 +
    classifications: &[String],
123 +
    max_retries: u32,
124 +
) -> Result<RawArtwork, String> {
125 +
    let total = total_matching(client, classifications).await?;
126 +
    if total == 0 {
127 +
        return Err("AIC search returned zero matches for given classifications".to_string());
128 +
    }
129 +
130 +
    for attempt in 0..=max_retries {
131 +
        let page = {
132 +
            let mut rng = rand::thread_rng();
133 +
            rng.gen_range(1..=total)
134 +
        };
135 +
        let art = match fetch_artwork_at(client, classifications, page).await? {
136 +
            Some(a) => a,
137 +
            None => continue,
138 +
        };
139 +
        if art.image_id.is_none() || art.image_id.as_deref() == Some("") {
140 +
            tracing::warn!("artwork {} has no image_id, retrying", art.id);
141 +
            continue;
142 +
        }
143 +
        match db::artwork_id_exists(db, art.id) {
144 +
            Ok(true) => {
145 +
                tracing::info!(
146 +
                    "duplicate artwork {} on attempt {}, retrying",
147 +
                    art.id,
148 +
                    attempt + 1
149 +
                );
150 +
                continue;
151 +
            }
152 +
            Ok(false) => return Ok(art),
153 +
            Err(e) => return Err(format!("dedup check failed: {e}")),
154 +
        }
155 +
    }
156 +
    Err(format!(
157 +
        "failed to pick a non-duplicate artwork after {} retries",
158 +
        max_retries + 1
159 +
    ))
160 +
}
161 +
162 +
pub fn raw_to_daily(raw: RawArtwork, date: String, fetched_at: String) -> Option<DailyArtwork> {
163 +
    let image_id = raw.image_id?;
164 +
    if image_id.is_empty() {
165 +
        return None;
166 +
    }
167 +
    Some(DailyArtwork {
168 +
        date,
169 +
        artwork_id: raw.id,
170 +
        title: raw.title.unwrap_or_else(|| "Untitled".to_string()),
171 +
        artist_display: raw.artist_display,
172 +
        artist_title: raw.artist_title,
173 +
        date_display: raw.date_display,
174 +
        medium_display: raw.medium_display,
175 +
        dimensions: raw.dimensions,
176 +
        place_of_origin: raw.place_of_origin,
177 +
        credit_line: raw.credit_line,
178 +
        description: raw.description,
179 +
        short_description: raw.short_description,
180 +
        image_id,
181 +
        fetched_at,
182 +
    })
183 +
}
184 +
185 +
#[cfg(test)]
186 +
mod tests {
187 +
    use super::*;
188 +
189 +
    #[test]
190 +
    fn build_params_lowercases_classifications() {
191 +
        let p = build_params(&["Painting".to_string(), "DRAWING".to_string()]);
192 +
        assert!(p.contains("\"painting\""));
193 +
        assert!(p.contains("\"drawing\""));
194 +
        assert!(p.contains("is_public_domain"));
195 +
        assert!(p.contains("image_id"));
196 +
    }
197 +
}
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) +139 −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.max_dedup_retries,
62 +
    )
63 +
    .await?;
64 +
    let now = Utc::now().to_rfc3339();
65 +
    let daily: DailyArtwork = aic::raw_to_daily(raw, date.to_string(), now)
66 +
        .ok_or_else(|| "missing image_id on selected artwork".to_string())?;
67 +
    db::insert_daily(&state.db, &daily).map_err(|e| e.to_string())?;
68 +
    tracing::info!(
69 +
        "stored artwork {} for {} (image_id={})",
70 +
        daily.artwork_id,
71 +
        date,
72 +
        daily.image_id
73 +
    );
74 +
    Ok(())
75 +
}
76 +
77 +
pub fn today_in_tz(tz: &Tz) -> String {
78 +
    Utc::now()
79 +
        .with_timezone(tz)
80 +
        .date_naive()
81 +
        .format("%Y-%m-%d")
82 +
        .to_string()
83 +
}
84 +
85 +
pub fn past_n_dates(tz: &Tz, n: u32) -> Vec<String> {
86 +
    let today = Utc::now().with_timezone(tz).date_naive();
87 +
    (1..=n as i64)
88 +
        .filter_map(|i| today.checked_sub_signed(ChronoDuration::days(i)))
89 +
        .map(|d| d.format("%Y-%m-%d").to_string())
90 +
        .collect()
91 +
}
92 +
93 +
pub fn parse_date(s: &str) -> Option<NaiveDate> {
94 +
    NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
95 +
}
96 +
97 +
fn duration_until_next_midnight(tz: &Tz) -> Duration {
98 +
    let now = Utc::now().with_timezone(tz);
99 +
    let next_day = now.date_naive() + ChronoDuration::days(1);
100 +
    let next_midnight = tz
101 +
        .from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time"))
102 +
        .single()
103 +
        .or_else(|| {
104 +
            tz.from_local_datetime(&next_day.and_hms_opt(0, 0, 1).expect("valid time"))
105 +
                .earliest()
106 +
        })
107 +
        .unwrap_or_else(|| now + ChronoDuration::days(1));
108 +
    let delta = next_midnight.signed_duration_since(now);
109 +
    delta
110 +
        .to_std()
111 +
        .unwrap_or_else(|_| Duration::from_secs(60 * 60))
112 +
}
113 +
114 +
#[cfg(test)]
115 +
mod tests {
116 +
    use super::*;
117 +
118 +
    #[test]
119 +
    fn past_n_dates_count() {
120 +
        let tz: Tz = "UTC".parse().unwrap();
121 +
        let dates = past_n_dates(&tz, 5);
122 +
        assert_eq!(dates.len(), 5);
123 +
    }
124 +
125 +
    #[test]
126 +
    fn past_n_dates_excludes_today() {
127 +
        let tz: Tz = "UTC".parse().unwrap();
128 +
        let today = today_in_tz(&tz);
129 +
        let dates = past_n_dates(&tz, 3);
130 +
        assert!(!dates.contains(&today));
131 +
    }
132 +
133 +
    #[test]
134 +
    fn parse_date_valid_invalid() {
135 +
        assert!(parse_date("2024-05-01").is_some());
136 +
        assert!(parse_date("2024-13-01").is_none());
137 +
        assert!(parse_date("notadate").is_none());
138 +
    }
139 +
}
apps/easel/src/server.rs (added) +408 −0
1 +
use std::sync::Arc;
2 +
3 +
use askama::Template;
4 +
use axum::{
5 +
    extract::{Path, State},
6 +
    http::{header, 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 +
15 +
use crate::db::{self, DailyArtwork, Db};
16 +
use crate::scheduler;
17 +
18 +
#[derive(Embed)]
19 +
#[folder = "static/"]
20 +
struct Static;
21 +
22 +
pub struct AppState {
23 +
    pub db: Db,
24 +
    pub http: reqwest::Client,
25 +
    pub tz: chrono_tz::Tz,
26 +
    pub classifications: Vec<String>,
27 +
    pub backfill_days: u32,
28 +
    pub max_dedup_retries: u32,
29 +
}
30 +
31 +
#[derive(Template)]
32 +
#[template(path = "index.html")]
33 +
struct IndexTemplate {
34 +
    today_date: String,
35 +
    artwork: Option<ArtworkView>,
36 +
    archive: Vec<ArchiveRow>,
37 +
}
38 +
39 +
#[derive(Template)]
40 +
#[template(path = "day.html")]
41 +
struct DayTemplate {
42 +
    date: String,
43 +
    artwork: ArtworkView,
44 +
    archive: Vec<ArchiveRow>,
45 +
}
46 +
47 +
#[derive(Template)]
48 +
#[template(path = "archive.html")]
49 +
struct ArchiveTemplate {
50 +
    archive: Vec<ArchiveRow>,
51 +
}
52 +
53 +
#[derive(Template)]
54 +
#[template(path = "error.html")]
55 +
struct ErrorTemplate {
56 +
    title: String,
57 +
    message: String,
58 +
}
59 +
60 +
struct ArtworkView {
61 +
    date: String,
62 +
    title: String,
63 +
    artist_display: String,
64 +
    date_display: String,
65 +
    medium_display: String,
66 +
    dimensions: String,
67 +
    place_of_origin: String,
68 +
    credit_line: String,
69 +
    short_description: String,
70 +
    image_url: String,
71 +
    source_url: String,
72 +
}
73 +
74 +
struct ArchiveRow {
75 +
    date: String,
76 +
    title: String,
77 +
    artist: String,
78 +
}
79 +
80 +
fn iiif_url(image_id: &str) -> String {
81 +
    format!("https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg")
82 +
}
83 +
84 +
fn source_url(artwork_id: i64) -> String {
85 +
    format!("https://www.artic.edu/artworks/{artwork_id}")
86 +
}
87 +
88 +
fn to_view(a: DailyArtwork) -> ArtworkView {
89 +
    ArtworkView {
90 +
        date: a.date,
91 +
        title: a.title,
92 +
        artist_display: a.artist_display.unwrap_or_default(),
93 +
        date_display: a.date_display.unwrap_or_default(),
94 +
        medium_display: a.medium_display.unwrap_or_default(),
95 +
        dimensions: a.dimensions.unwrap_or_default(),
96 +
        place_of_origin: a.place_of_origin.unwrap_or_default(),
97 +
        credit_line: a.credit_line.unwrap_or_default(),
98 +
        short_description: a.short_description.unwrap_or_default(),
99 +
        image_url: iiif_url(&a.image_id),
100 +
        source_url: source_url(a.artwork_id),
101 +
    }
102 +
}
103 +
104 +
fn to_archive_row(a: &DailyArtwork) -> ArchiveRow {
105 +
    ArchiveRow {
106 +
        date: a.date.clone(),
107 +
        title: a.title.clone(),
108 +
        artist: a
109 +
            .artist_title
110 +
            .clone()
111 +
            .or_else(|| a.artist_display.clone())
112 +
            .unwrap_or_default(),
113 +
    }
114 +
}
115 +
116 +
fn render<T: Template>(t: T) -> Response {
117 +
    match t.render() {
118 +
        Ok(body) => Html(body).into_response(),
119 +
        Err(e) => {
120 +
            tracing::error!("render failed: {e}");
121 +
            (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()
122 +
        }
123 +
    }
124 +
}
125 +
126 +
async fn index_handler(State(state): State<Arc<AppState>>) -> Response {
127 +
    let today = scheduler::today_in_tz(&state.tz);
128 +
    let artwork = match db::get_daily(&state.db, &today) {
129 +
        Ok(Some(a)) => Some(to_view(a)),
130 +
        Ok(None) => None,
131 +
        Err(e) => {
132 +
            tracing::error!("index db error: {e}");
133 +
            return render(ErrorTemplate {
134 +
                title: "Error".to_string(),
135 +
                message: "Could not load today's artwork.".to_string(),
136 +
            });
137 +
        }
138 +
    };
139 +
    let archive = db::list_daily(&state.db, 30)
140 +
        .unwrap_or_default()
141 +
        .iter()
142 +
        .map(to_archive_row)
143 +
        .collect();
144 +
    render(IndexTemplate {
145 +
        today_date: today,
146 +
        artwork,
147 +
        archive,
148 +
    })
149 +
}
150 +
151 +
async fn day_handler(
152 +
    State(state): State<Arc<AppState>>,
153 +
    Path(date): Path<String>,
154 +
) -> Response {
155 +
    let parsed = match scheduler::parse_date(&date) {
156 +
        Some(d) => d,
157 +
        None => {
158 +
            return (
159 +
                StatusCode::BAD_REQUEST,
160 +
                render(ErrorTemplate {
161 +
                    title: "Invalid date".to_string(),
162 +
                    message: format!("'{date}' is not a valid YYYY-MM-DD date."),
163 +
                }),
164 +
            )
165 +
                .into_response();
166 +
        }
167 +
    };
168 +
    let today = scheduler::today_in_tz(&state.tz);
169 +
    if date.as_str() > today.as_str() {
170 +
        return (
171 +
            StatusCode::NOT_FOUND,
172 +
            render(ErrorTemplate {
173 +
                title: "Not yet".to_string(),
174 +
                message: format!(
175 +
                    "{} is in the future. The next day's artwork is not available until midnight {}.",
176 +
                    parsed, state.tz.name()
177 +
                ),
178 +
            }),
179 +
        )
180 +
            .into_response();
181 +
    }
182 +
    let artwork = match db::get_daily(&state.db, &date) {
183 +
        Ok(Some(a)) => to_view(a),
184 +
        Ok(None) => {
185 +
            return (
186 +
                StatusCode::NOT_FOUND,
187 +
                render(ErrorTemplate {
188 +
                    title: "Not found".to_string(),
189 +
                    message: format!("No artwork stored for {date}."),
190 +
                }),
191 +
            )
192 +
                .into_response();
193 +
        }
194 +
        Err(e) => {
195 +
            tracing::error!("day db error: {e}");
196 +
            return (
197 +
                StatusCode::INTERNAL_SERVER_ERROR,
198 +
                render(ErrorTemplate {
199 +
                    title: "Error".to_string(),
200 +
                    message: "Database error.".to_string(),
201 +
                }),
202 +
            )
203 +
                .into_response();
204 +
        }
205 +
    };
206 +
    let archive = db::list_daily(&state.db, 30)
207 +
        .unwrap_or_default()
208 +
        .iter()
209 +
        .map(to_archive_row)
210 +
        .collect();
211 +
    render(DayTemplate {
212 +
        date,
213 +
        artwork,
214 +
        archive,
215 +
    })
216 +
}
217 +
218 +
async fn archive_handler(State(state): State<Arc<AppState>>) -> Response {
219 +
    let archive = db::list_daily(&state.db, 1000)
220 +
        .unwrap_or_default()
221 +
        .iter()
222 +
        .map(to_archive_row)
223 +
        .collect();
224 +
    render(ArchiveTemplate { archive })
225 +
}
226 +
227 +
#[derive(Serialize)]
228 +
struct ApiArtwork<'a> {
229 +
    date: &'a str,
230 +
    artwork_id: i64,
231 +
    title: &'a str,
232 +
    artist_display: Option<&'a str>,
233 +
    date_display: Option<&'a str>,
234 +
    medium_display: Option<&'a str>,
235 +
    dimensions: Option<&'a str>,
236 +
    place_of_origin: Option<&'a str>,
237 +
    credit_line: Option<&'a str>,
238 +
    short_description: Option<&'a str>,
239 +
    image_id: &'a str,
240 +
    image_url: String,
241 +
    source_url: String,
242 +
}
243 +
244 +
fn to_api<'a>(a: &'a DailyArtwork) -> ApiArtwork<'a> {
245 +
    ApiArtwork {
246 +
        date: &a.date,
247 +
        artwork_id: a.artwork_id,
248 +
        title: &a.title,
249 +
        artist_display: a.artist_display.as_deref(),
250 +
        date_display: a.date_display.as_deref(),
251 +
        medium_display: a.medium_display.as_deref(),
252 +
        dimensions: a.dimensions.as_deref(),
253 +
        place_of_origin: a.place_of_origin.as_deref(),
254 +
        credit_line: a.credit_line.as_deref(),
255 +
        short_description: a.short_description.as_deref(),
256 +
        image_id: &a.image_id,
257 +
        image_url: iiif_url(&a.image_id),
258 +
        source_url: source_url(a.artwork_id),
259 +
    }
260 +
}
261 +
262 +
async fn api_today(State(state): State<Arc<AppState>>) -> Response {
263 +
    let today = scheduler::today_in_tz(&state.tz);
264 +
    match db::get_daily(&state.db, &today) {
265 +
        Ok(Some(a)) => Json(to_api(&a)).into_response(),
266 +
        Ok(None) => (
267 +
            StatusCode::NOT_FOUND,
268 +
            Json(serde_json::json!({"error": "today not yet populated"})),
269 +
        )
270 +
            .into_response(),
271 +
        Err(e) => {
272 +
            tracing::error!("api_today db error: {e}");
273 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
274 +
        }
275 +
    }
276 +
}
277 +
278 +
async fn api_day(
279 +
    State(state): State<Arc<AppState>>,
280 +
    Path(date): Path<String>,
281 +
) -> Response {
282 +
    if scheduler::parse_date(&date).is_none() {
283 +
        return (
284 +
            StatusCode::BAD_REQUEST,
285 +
            Json(serde_json::json!({"error": "invalid date format"})),
286 +
        )
287 +
            .into_response();
288 +
    }
289 +
    let today = scheduler::today_in_tz(&state.tz);
290 +
    if date.as_str() > today.as_str() {
291 +
        return (
292 +
            StatusCode::NOT_FOUND,
293 +
            Json(serde_json::json!({"error": "future date"})),
294 +
        )
295 +
            .into_response();
296 +
    }
297 +
    match db::get_daily(&state.db, &date) {
298 +
        Ok(Some(a)) => Json(to_api(&a)).into_response(),
299 +
        Ok(None) => (
300 +
            StatusCode::NOT_FOUND,
301 +
            Json(serde_json::json!({"error": "no record for date"})),
302 +
        )
303 +
            .into_response(),
304 +
        Err(e) => {
305 +
            tracing::error!("api_day db error: {e}");
306 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
307 +
        }
308 +
    }
309 +
}
310 +
311 +
async fn api_archive(State(state): State<Arc<AppState>>) -> Response {
312 +
    match db::list_daily(&state.db, 1000) {
313 +
        Ok(items) => {
314 +
            let out: Vec<ApiArtwork> = items.iter().map(to_api).collect();
315 +
            Json(out).into_response()
316 +
        }
317 +
        Err(e) => {
318 +
            tracing::error!("api_archive db error: {e}");
319 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
320 +
        }
321 +
    }
322 +
}
323 +
324 +
async fn static_handler(Path(path): Path<String>) -> Response {
325 +
    match Static::get(&path) {
326 +
        Some(file) => {
327 +
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
328 +
            (
329 +
                [(header::CONTENT_TYPE, mime.as_ref())],
330 +
                file.data.to_vec(),
331 +
            )
332 +
                .into_response()
333 +
        }
334 +
        None => StatusCode::NOT_FOUND.into_response(),
335 +
    }
336 +
}
337 +
338 +
pub async fn run() {
339 +
    let db_path = std::env::var("EASEL_DB_PATH").unwrap_or_else(|_| "easel.sqlite".to_string());
340 +
    let tz_name = std::env::var("EASEL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
341 +
    let tz: chrono_tz::Tz = tz_name.parse().unwrap_or_else(|_| {
342 +
        tracing::warn!("invalid EASEL_TIMEZONE={tz_name}, falling back to UTC");
343 +
        chrono_tz::UTC
344 +
    });
345 +
    let classifications: Vec<String> = std::env::var("EASEL_CLASSIFICATIONS")
346 +
        .unwrap_or_else(|_| "painting".to_string())
347 +
        .split(',')
348 +
        .map(|s| s.trim().to_string())
349 +
        .filter(|s| !s.is_empty())
350 +
        .collect();
351 +
    if classifications.is_empty() {
352 +
        panic!("EASEL_CLASSIFICATIONS resolved to empty list");
353 +
    }
354 +
    let backfill_days: u32 = std::env::var("EASEL_BACKFILL_DAYS")
355 +
        .ok()
356 +
        .and_then(|v| v.parse().ok())
357 +
        .unwrap_or(0);
358 +
    let max_dedup_retries: u32 = std::env::var("EASEL_MAX_DEDUP_RETRIES")
359 +
        .ok()
360 +
        .and_then(|v| v.parse().ok())
361 +
        .unwrap_or(10);
362 +
363 +
    let db = db::init_db(&db_path);
364 +
    let http = crate::aic::build_client();
365 +
366 +
    let state = Arc::new(AppState {
367 +
        db,
368 +
        http,
369 +
        tz,
370 +
        classifications: classifications.clone(),
371 +
        backfill_days,
372 +
        max_dedup_retries,
373 +
    });
374 +
375 +
    tracing::info!(
376 +
        "easel starting: tz={} classifications={:?} backfill_days={} retries={}",
377 +
        state.tz.name(),
378 +
        classifications,
379 +
        backfill_days,
380 +
        max_dedup_retries
381 +
    );
382 +
    tracing::info!("startup time: {}", Utc::now().to_rfc3339());
383 +
384 +
    tokio::spawn(scheduler::run(state.clone()));
385 +
386 +
    let app = Router::new()
387 +
        .route("/", get(index_handler))
388 +
        .route("/day/{date}", get(day_handler))
389 +
        .route("/archive", get(archive_handler))
390 +
        .route("/api/today", get(api_today))
391 +
        .route("/api/day/{date}", get(api_day))
392 +
        .route("/api/archive", get(api_archive))
393 +
        .route("/static/{*path}", get(static_handler))
394 +
        .merge(andromeda_darkmatter_css::router::<Arc<AppState>>())
395 +
        .with_state(state);
396 +
397 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
398 +
    let port: u16 = std::env::var("PORT")
399 +
        .ok()
400 +
        .and_then(|v| v.parse().ok())
401 +
        .unwrap_or(4242);
402 +
    let addr = format!("{host}:{port}");
403 +
    let listener = tokio::net::TcpListener::bind(&addr)
404 +
        .await
405 +
        .unwrap_or_else(|_| panic!("failed to bind {addr}"));
406 +
    tracing::info!("easel listening on http://{addr}");
407 +
    axum::serve(listener, app).await.expect("axum serve");
408 +
}
apps/easel/static/styles.css (added) +116 −0
1 +
.container {
2 +
  max-width: 720px;
3 +
  margin: 0 auto;
4 +
  padding: 24px 16px 64px;
5 +
}
6 +
7 +
.artwork-figure {
8 +
  margin: 16px 0 24px;
9 +
  border: 1px solid #333;
10 +
}
11 +
12 +
.artwork-figure img {
13 +
  display: block;
14 +
  width: 100%;
15 +
  height: auto;
16 +
}
17 +
18 +
.artwork-meta {
19 +
  margin-bottom: 16px;
20 +
}
21 +
22 +
.artwork-date {
23 +
  opacity: 0.5;
24 +
  font-size: 12px;
25 +
  margin: 0 0 4px;
26 +
}
27 +
28 +
.artwork-title {
29 +
  font-size: 18px;
30 +
  margin: 0 0 4px;
31 +
}
32 +
33 +
.artwork-artist {
34 +
  margin: 0;
35 +
  opacity: 0.7;
36 +
}
37 +
38 +
.artwork-details {
39 +
  display: grid;
40 +
  grid-template-columns: max-content 1fr;
41 +
  gap: 4px 16px;
42 +
  margin: 16px 0;
43 +
  font-size: 13px;
44 +
}
45 +
46 +
.artwork-details dt {
47 +
  opacity: 0.5;
48 +
}
49 +
50 +
.artwork-details dd {
51 +
  margin: 0;
52 +
}
53 +
54 +
.artwork-description {
55 +
  margin: 16px 0;
56 +
  opacity: 0.85;
57 +
}
58 +
59 +
.artwork-source {
60 +
  font-size: 12px;
61 +
  opacity: 0.7;
62 +
}
63 +
64 +
.archive-list {
65 +
  margin-top: 48px;
66 +
  border-top: 1px solid #333;
67 +
  padding-top: 16px;
68 +
}
69 +
70 +
.archive-list h3 {
71 +
  font-size: 14px;
72 +
  opacity: 0.5;
73 +
  margin: 0 0 8px;
74 +
  text-transform: uppercase;
75 +
  letter-spacing: 0.05em;
76 +
}
77 +
78 +
.item-list {
79 +
  list-style: none;
80 +
  margin: 0;
81 +
  padding: 0;
82 +
}
83 +
84 +
.item {
85 +
  border-bottom: 1px solid #333;
86 +
}
87 +
88 +
.item a {
89 +
  display: grid;
90 +
  grid-template-columns: 90px 1fr auto;
91 +
  gap: 12px;
92 +
  padding: 8px 0;
93 +
  text-decoration: none;
94 +
  color: inherit;
95 +
}
96 +
97 +
.item-meta {
98 +
  opacity: 0.5;
99 +
  font-size: 12px;
100 +
}
101 +
102 +
.item-title {
103 +
  overflow: hidden;
104 +
  text-overflow: ellipsis;
105 +
  white-space: nowrap;
106 +
}
107 +
108 +
.empty {
109 +
  opacity: 0.5;
110 +
  padding: 32px 0;
111 +
  text-align: center;
112 +
}
113 +
114 +
.error-page {
115 +
  padding: 32px 0;
116 +
}
apps/easel/templates/_artwork.html (added) +39 −0
1 +
<article class="artwork">
2 +
  <figure class="artwork-figure">
3 +
    <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer">
4 +
      <img src="{{ artwork.image_url }}" alt="{{ artwork.title }}" loading="lazy" />
5 +
    </a>
6 +
  </figure>
7 +
  <header class="artwork-meta">
8 +
    <p class="artwork-date">{{ artwork.date }}</p>
9 +
    <h2 class="artwork-title"><em>{{ artwork.title }}</em></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.short_description.is_empty() %}
32 +
  <p class="artwork-description">{{ artwork.short_description }}</p>
33 +
  {% endif %}
34 +
  <p class="artwork-source">
35 +
    <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer">
36 +
      view on artic.edu →
37 +
    </a>
38 +
  </p>
39 +
</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) +24 −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 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>{% block title %}Easel{% endblock %}</title>
10 +
    <meta name="description" content="A daily painting from the Art Institute of Chicago" />
11 +
  </head>
12 +
  <body>
13 +
    <div class="container">
14 +
      <div class="header">
15 +
        <a href="/" class="logo"><h1>EASEL</h1></a>
16 +
        <nav class="links">
17 +
          <a href="/">today</a>
18 +
          <a href="/archive">archive</a>
19 +
        </nav>
20 +
      </div>
21 +
      {% block content %}{% endblock %}
22 +
    </div>
23 +
  </body>
24 +
</html>
apps/easel/templates/day.html (added) +22 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Easel — {{ date }}{% endblock %}
3 +
{% block content %}
4 +
  {% include "_artwork.html" %}
5 +
6 +
  {% if !archive.is_empty() %}
7 +
  <section class="archive-list">
8 +
    <h3>Other days</h3>
9 +
    <ul class="item-list">
10 +
      {% for row in archive %}
11 +
      <li class="item">
12 +
        <a href="/day/{{ row.date }}">
13 +
          <span class="item-meta">{{ row.date }}</span>
14 +
          <span class="item-title"><em>{{ row.title }}</em></span>
15 +
          {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %}
16 +
        </a>
17 +
      </li>
18 +
      {% endfor %}
19 +
    </ul>
20 +
  </section>
21 +
  {% endif %}
22 +
{% 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) +28 −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 +
12 +
  {% if !archive.is_empty() %}
13 +
  <section class="archive-list">
14 +
    <h3>Recent days</h3>
15 +
    <ul class="item-list">
16 +
      {% for row in archive %}
17 +
      <li class="item">
18 +
        <a href="/day/{{ row.date }}">
19 +
          <span class="item-meta">{{ row.date }}</span>
20 +
          <span class="item-title"><em>{{ row.title }}</em></span>
21 +
          {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %}
22 +
        </a>
23 +
      </li>
24 +
      {% endfor %}
25 +
    </ul>
26 +
  </section>
27 +
  {% endif %}
28 +
{% 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