use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use crate::meta::Project;
use crate::term::BOLD;
const SKIP_DIRS: &[&str] = &[
"_drive",
"macos-gtk",
"macos-qt",
"windows-gtk",
"windows-qt",
"android-widget",
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Text {
Plain(String),
ByLocale(BTreeMap<String, String>),
}
impl Text {
pub fn resolve(&self, locale: &str) -> Option<&str> {
match self {
Text::Plain(s) => Some(s),
Text::ByLocale(map) => {
if let Some(s) = map.get(locale) {
return Some(s);
}
let lang = primary_language(locale);
if let Some((_, s)) = map.iter().find(|(k, _)| primary_language(k) == lang) {
return Some(s);
}
if let Some((_, s)) = map.iter().find(|(k, _)| primary_language(k) == "en") {
return Some(s);
}
map.values().next().map(String::as_str)
}
}
}
pub fn locales(&self) -> Vec<&str> {
match self {
Text::Plain(_) => Vec::new(),
Text::ByLocale(map) => map.keys().map(String::as_str).collect(),
}
}
fn to_map(&self, default_locale: &str) -> BTreeMap<String, String> {
match self {
Text::Plain(s) => BTreeMap::from([(default_locale.to_string(), s.clone())]),
Text::ByLocale(map) => map.clone(),
}
}
}
fn primary_language(tag: &str) -> &str {
tag.split(['-', '_']).next().unwrap_or(tag)
}
fn is_locale_like(s: &str) -> bool {
let mut parts = s.split('-');
let Some(lang) = parts.next() else {
return false;
};
((2..=3).contains(&lang.len()) && lang.chars().all(|c| c.is_ascii_lowercase()))
&& parts.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric()))
}
fn parse_variant(name: &str) -> (Option<&str>, Option<&str>) {
if name == "default" {
return (None, None);
}
for theme in ["light", "dark"] {
if name == theme {
return (Some(theme), None);
}
if let Some(rest) = name.strip_prefix(theme).and_then(|r| r.strip_prefix('-')) {
return (Some(theme), is_locale_like(rest).then_some(rest));
}
}
(None, is_locale_like(name).then_some(name))
}
fn png_dims(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[12..16] != b"IHDR" {
return None;
}
let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
Some((be(16), be(20)))
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = sha2::Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
fn derived_label(id: &str) -> String {
let mut out = String::with_capacity(id.len());
let mut start = true;
for c in id.chars() {
if c == '-' || c == '_' {
out.push(' ');
start = true;
} else if start {
out.extend(c.to_uppercase());
start = false;
} else {
out.push(c);
}
}
out
}
fn iso_utc_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (days, rem) = (secs / 86_400, secs % 86_400);
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days as i64 + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TargetEntry {
pub file: String,
pub variant: String,
pub shot: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Text>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caption: Option<Text>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub bytes: u64,
pub sha256: String,
}
#[derive(Default, Serialize, Deserialize)]
struct TargetIndex {
generator: String,
target: String,
screenshots: Vec<TargetEntry>,
}
pub fn target_entry(
path: &Path,
variant: &str,
shot: &str,
locale: Option<&str>,
meta: Option<&ShotMeta>,
) -> Option<TargetEntry> {
let bytes = std::fs::read(path).ok()?;
let dims = png_dims(&bytes);
Some(TargetEntry {
file: path.file_name()?.to_string_lossy().into_owned(),
variant: variant.to_string(),
shot: shot.to_string(),
locale: locale.map(str::to_string),
title: meta.and_then(|m| m.title.clone()),
caption: meta.and_then(|m| m.caption.clone()),
source: meta.and_then(|m| m.source.clone()),
width: dims.map(|d| d.0),
height: dims.map(|d| d.1),
bytes: bytes.len() as u64,
sha256: sha256_hex(&bytes),
})
}
pub fn record_target_entries(screenshots_root: &Path, target: &str, entries: Vec<TargetEntry>) {
if entries.is_empty() {
return;
}
let target_dir = screenshots_root.join(target);
let path = target_dir.join("gallery.json");
let mut index: TargetIndex = std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
index.generator = "day launch --script".into();
index.target = target.into();
for e in entries {
if let Some(slot) = index
.screenshots
.iter_mut()
.find(|s| s.variant == e.variant && s.file == e.file)
{
*slot = e;
} else {
index.screenshots.push(e);
}
}
index
.screenshots
.retain(|e| target_dir.join(&e.variant).join(&e.file).exists());
if let Ok(json) = serde_json::to_string_pretty(&index) {
let _ = std::fs::write(&path, json + "\n");
}
}
#[derive(Clone, Debug, Default)]
pub struct ShotMeta {
pub title: Option<Text>,
pub caption: Option<Text>,
pub source: Option<String>,
}
pub fn extract_meta(step: &mut serde_json::Map<String, serde_json::Value>) -> ShotMeta {
let text = |v: serde_json::Value| serde_json::from_value::<Text>(v).ok();
ShotMeta {
title: step.remove("title").and_then(text),
caption: step.remove("caption").and_then(text),
source: step
.remove("source")
.and_then(|v| v.as_str().map(str::to_string)),
}
}
pub fn script_screenshot_meta(path: &Path) -> Vec<(String, ShotMeta)> {
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let Ok(doc) = serde_norway::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let Some(flow) = doc.get("flow").and_then(|f| f.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in flow {
let Some(obj) = entry.as_object() else {
continue;
};
let Some(params) = obj.get("screenshot") else {
continue;
};
let Some(params) = params.as_object() else {
continue;
};
let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
continue;
};
let mut params = params.clone();
out.push((name.to_string(), extract_meta(&mut params)));
}
out
}
fn site_host(project_root: &Path) -> Option<(String, String)> {
#[derive(Deserialize)]
struct SiteToml {
host: Option<String>,
}
let text = std::fs::read_to_string(project_root.join("website/site.toml")).ok()?;
let site: SiteToml = toml::from_str(&text).ok()?;
let host = site.host?;
let rest = host.split_once("://")?;
let (origin, base) = match rest.1.split_once('/') {
Some((h, path)) => (
format!("{}://{h}", rest.0),
format!("/{}", path.trim_end_matches('/')),
),
None => (host.clone(), String::new()),
};
Some((origin, base))
}
fn default_locale(project_root: &Path) -> String {
let fluent = crate::localize::survey(project_root).fluent;
if fluent.iter().any(|l| l == "en") || fluent.is_empty() {
"en".into()
} else {
fluent[0].clone()
}
}
pub struct IndexOptions {
pub screenshot_paths: Vec<PathBuf>,
pub out: Option<PathBuf>,
}
pub fn index(project: &Project, opts: &IndexOptions) -> Result<PathBuf, String> {
let roots = if opts.screenshot_paths.is_empty() {
vec![project.root.join("build/day/screenshots")]
} else {
opts.screenshot_paths.clone()
};
let out = opts
.out
.clone()
.unwrap_or_else(|| roots[0].join("gallery.json"));
let host = site_host(&project.root);
let fallback_locale = default_locale(&project.root);
let mut by_target: BTreeMap<String, Vec<TargetEntry>> = BTreeMap::new();
let mut script_ordered: Vec<String> = Vec::new();
for root in &roots {
let Ok(targets) = std::fs::read_dir(root) else {
continue;
};
for t in targets.flatten() {
let tdir = t.path();
let Some(target) = t.file_name().to_str().map(str::to_string) else {
continue;
};
if !tdir.is_dir() || SKIP_DIRS.contains(&target.as_str()) {
continue;
}
let known: TargetIndex = std::fs::read_to_string(tdir.join("gallery.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if !known.screenshots.is_empty() && !script_ordered.contains(&target) {
script_ordered.push(target.clone());
}
let list = by_target.entry(target).or_default();
for e in &known.screenshots {
if tdir.join(&e.variant).join(&e.file).exists()
&& !list
.iter()
.any(|x| x.variant == e.variant && x.file == e.file)
{
list.push(e.clone());
}
}
let Ok(variants) = std::fs::read_dir(&tdir) else {
continue;
};
let mut vnames: Vec<String> = variants
.flatten()
.filter(|v| v.path().is_dir())
.filter_map(|v| v.file_name().to_str().map(str::to_string))
.collect();
vnames.sort();
for vname in vnames {
let vdir = tdir.join(&vname);
let Ok(files) = std::fs::read_dir(&vdir) else {
continue;
};
let mut fnames: Vec<String> = files
.flatten()
.filter_map(|f| f.file_name().to_str().map(str::to_string))
.filter(|f| f.to_lowercase().ends_with(".png"))
.collect();
fnames.sort();
for fname in fnames {
if list.iter().any(|e| e.variant == vname && e.file == fname) {
continue; }
let shot = fname.trim_end_matches(".png").to_string();
if let Some(e) = target_entry(&vdir.join(&fname), &vname, &shot, None, None) {
list.push(e);
}
}
}
}
}
let mut platforms: Vec<String> = crate::targets::TARGETS
.iter()
.map(|t| t.name.to_string())
.filter(|n| by_target.contains_key(n))
.collect();
for t in by_target.keys() {
if !platforms.contains(t) {
platforms.push(t.clone());
}
}
let mut order_walk: Vec<&String> = platforms
.iter()
.filter(|p| script_ordered.contains(p))
.collect();
order_walk.extend(platforms.iter().filter(|p| !script_ordered.contains(p)));
let mut shot_order: Vec<String> = Vec::new();
let mut shot_meta: BTreeMap<String, ShotMeta> = BTreeMap::new();
for platform in order_walk {
for e in &by_target[platform] {
if !shot_order.contains(&e.shot) {
shot_order.push(e.shot.clone());
}
let m = shot_meta.entry(e.shot.clone()).or_default();
if m.title.is_none() {
m.title = e.title.clone();
}
if m.caption.is_none() {
m.caption = e.caption.clone();
}
if m.source.is_none() {
m.source = e.source.clone();
}
}
}
let mut themes: Vec<String> = Vec::new();
let mut locales: Vec<String> = Vec::new();
let mut screenshots = Vec::new();
for platform in &platforms {
let (os, toolkit) = crate::targets::TARGETS
.iter()
.find(|t| t.name == *platform)
.map(|t| (t.os.to_string(), t.toolkit.to_string()))
.unwrap_or_else(|| {
let (os, tk) = platform.split_once('-').unwrap_or((platform, ""));
(os.to_string(), tk.to_string())
});
let mut entries: Vec<&TargetEntry> = by_target[platform].iter().collect();
entries.sort_by(|a, b| {
let ra = shot_order.iter().position(|s| *s == a.shot);
let rb = shot_order.iter().position(|s| *s == b.shot);
ra.cmp(&rb).then_with(|| a.variant.cmp(&b.variant))
});
for e in entries {
let (theme, vlocale) = parse_variant(&e.variant);
let locale = e
.locale
.clone()
.or_else(|| vlocale.map(str::to_string))
.or_else(|| theme.is_some().then(|| fallback_locale.clone()));
if let Some(t) = theme
&& !themes.iter().any(|x| x == t)
{
themes.push(t.to_string());
}
if let Some(l) = &locale
&& !locales.contains(l)
{
locales.push(l.clone());
}
let meta = shot_meta.get(&e.shot);
let for_locale = locale.as_deref().unwrap_or(&fallback_locale);
let title = meta
.and_then(|m| m.title.as_ref())
.and_then(|t| t.resolve(for_locale))
.map(str::to_string)
.unwrap_or_else(|| derived_label(&e.shot));
let caption = meta
.and_then(|m| m.caption.as_ref())
.and_then(|c| c.resolve(for_locale))
.map(str::to_string);
let path = format!("gallery/{platform}/{}/{}", e.variant, e.file);
screenshots.push(serde_json::json!({
"file": e.file,
"path": path,
"url": host.as_ref().map(|(o, b)| format!("{o}{b}/{path}")),
"shot": e.shot,
"title": title,
"caption": caption,
"platform": platform,
"os": os,
"toolkit": toolkit,
"variant": e.variant,
"theme": theme,
"locale": locale,
"width": e.width,
"height": e.height,
"bytes": e.bytes,
"sha256": e.sha256,
}));
}
}
let shots: Vec<serde_json::Value> = shot_order
.iter()
.map(|id| {
let m = shot_meta.get(id).cloned().unwrap_or_default();
serde_json::json!({
"id": id,
"title": m.title.map(|t| t.to_map(&fallback_locale)),
"caption": m.caption.map(|c| c.to_map(&fallback_locale)),
"source": m.source,
})
})
.collect();
let doc = serde_json::json!({
"generator": "day screenshot index",
"generated": iso_utc_now(),
"site": host.as_ref().map(|(o, b)| format!("{o}{b}")),
"themes": themes,
"locales": locales,
"platforms": platforms,
"shots": shots,
"screenshots": screenshots,
});
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(
&out,
serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n",
)
.map_err(|e| e.to_string())?;
eprintln!(
"{BOLD} Index{BOLD:#} {} shot(s), {} capture(s) on {} target(s) → {}",
shot_order.len(),
screenshots.len(),
platforms.len(),
out.display()
);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_resolution_falls_back_language_then_english() {
let map = Text::ByLocale(BTreeMap::from([
("en".into(), "Home".into()),
("fr-FR".into(), "Accueil".into()),
]));
assert_eq!(map.resolve("fr-FR"), Some("Accueil"));
assert_eq!(map.resolve("fr"), Some("Accueil")); assert_eq!(map.resolve("zh-CN"), Some("Home")); let no_en = Text::ByLocale(BTreeMap::from([("fr".into(), "Accueil".into())]));
assert_eq!(no_en.resolve("de"), Some("Accueil")); assert_eq!(Text::Plain("X".into()).resolve("anything"), Some("X"));
}
#[test]
fn variant_names_parse_to_theme_and_locale() {
assert_eq!(parse_variant("default"), (None, None));
assert_eq!(parse_variant("light"), (Some("light"), None));
assert_eq!(parse_variant("dark-fr"), (Some("dark"), Some("fr")));
assert_eq!(parse_variant("light-zh-CN"), (Some("light"), Some("zh-CN")));
assert_eq!(parse_variant("fr"), (None, Some("fr")));
assert_eq!(parse_variant("uicheck"), (None, None));
assert_eq!(parse_variant("light-uicheck"), (Some("light"), None));
}
#[test]
fn derived_labels_title_case_ids() {
assert_eq!(derived_label("home"), "Home");
assert_eq!(derived_label("list-item-100"), "List Item 100");
}
#[test]
fn iso_utc_now_is_shaped_like_iso8601() {
let s = iso_utc_now();
assert_eq!(s.len(), 20, "{s}");
assert_eq!(&s[4..5], "-");
assert!(s.ends_with('Z'));
let year: i32 = s[..4].parse().unwrap();
assert!((2024..2100).contains(&year), "{s}");
}
#[test]
fn extract_meta_strips_the_step() {
let mut step = serde_json::from_str::<serde_json::Map<_, _>>(
r#"{"op":"screenshot","name":"home","title":{"en":"Home","fr":"Accueil"},"caption":"The hub","source":"src/lib.rs"}"#,
)
.unwrap();
let meta = extract_meta(&mut step);
assert!(
!step.contains_key("title")
&& !step.contains_key("caption")
&& !step.contains_key("source")
);
assert_eq!(meta.title.unwrap().resolve("fr"), Some("Accueil"));
assert_eq!(meta.caption.unwrap().resolve("en"), Some("The hub"));
assert_eq!(meta.source.as_deref(), Some("src/lib.rs"));
}
#[test]
fn record_upserts_and_prunes() {
let dir = std::env::temp_dir().join(format!("day-shot-index-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vdir = dir.join("macos-appkit").join("light");
std::fs::create_dir_all(&vdir).unwrap();
let mut png = vec![
0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d,
];
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(3u32.to_be_bytes());
std::fs::write(vdir.join("home.png"), &png).unwrap();
let entry =
target_entry(&vdir.join("home.png"), "light", "home", Some("en"), None).unwrap();
assert_eq!((entry.width, entry.height), (Some(2), Some(3)));
record_target_entries(&dir, "macos-appkit", vec![entry.clone()]);
record_target_entries(&dir, "macos-appkit", vec![entry]);
let idx: TargetIndex = serde_json::from_str(
&std::fs::read_to_string(dir.join("macos-appkit/gallery.json")).unwrap(),
)
.unwrap();
assert_eq!(idx.screenshots.len(), 1);
std::fs::remove_file(vdir.join("home.png")).unwrap();
let ghost = TargetEntry {
file: "gone.png".into(),
variant: "light".into(),
shot: "gone".into(),
locale: None,
title: None,
caption: None,
source: None,
width: None,
height: None,
bytes: 0,
sha256: String::new(),
};
record_target_entries(&dir, "macos-appkit", vec![ghost]);
let idx: TargetIndex = serde_json::from_str(
&std::fs::read_to_string(dir.join("macos-appkit/gallery.json")).unwrap(),
)
.unwrap();
assert!(idx.screenshots.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}