Add rebuild: republish an issue's EPUBs from its stored content
`daily-epub rebuild --date D` (and the rebuild-YYYY-MM-DD job) takes the issues.issue_json snapshot and restores each article's body from its row. It then builds both editions and the XTC, publishes them under the same names and records the issue as a run would. Nothing is curated again or refreshed from live rows, so headlines, summaries, social lines and rating links stay as published; only images are downloaded again. It takes the run lock, opens the database without migrating, and the job is marked dangerous like generate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e521ddaef
commit
5abf062c24
3 files changed
+214
-7
No files matched your search
+39
-6
@@ -32,6 +32,8 @@ pub const EXITED_BEFORE_START: &str = "unit exited before the job started; see t
|
||||
pub enum Job {
|
||||
/// `generate` (today) or `generate-YYYY-MM-DD`.
|
||||
Generate { date: Option<Date> },
|
||||
/// `rebuild-YYYY-MM-DD` → `rebuild --date`.
|
||||
Rebuild { date: Date },
|
||||
/// `dry-run` → `generate --dry-run`.
|
||||
DryRun,
|
||||
/// `profile-rebuild` → `profile rebuild`.
|
||||
@@ -70,8 +72,9 @@ impl Job {
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
/// Parse a catalogue name or the dated `generate-YYYY-MM-DD` form; anything
|
||||
/// else (including `../x`, uppercase, unknown names) is `None`.
|
||||
/// Parse a catalogue name or a dated `generate-YYYY-MM-DD` or
|
||||
/// `rebuild-YYYY-MM-DD` form; anything else (including `../x`, uppercase,
|
||||
/// unknown names) is `None`.
|
||||
pub fn parse(name: &str) -> Option<Job> {
|
||||
if !Self::valid_name(name) {
|
||||
return None;
|
||||
@@ -86,10 +89,17 @@ impl Job {
|
||||
"import-ratings" => Some(Job::ImportRatings),
|
||||
"interests-categorize" => Some(Job::InterestsCategorize),
|
||||
_ => {
|
||||
let date = name.strip_prefix("generate-")?;
|
||||
let (rebuild, date) = match name.strip_prefix("generate-") {
|
||||
Some(date) => (false, date),
|
||||
None => (true, name.strip_prefix("rebuild-")?),
|
||||
};
|
||||
// Exactly `YYYY-MM-DD`; the round trip rejects `2026-9-3`.
|
||||
let parsed: Date = date.parse().ok()?;
|
||||
(parsed.to_string() == date).then_some(Job::Generate { date: Some(parsed) })
|
||||
(parsed.to_string() == date).then_some(if rebuild {
|
||||
Job::Rebuild { date: parsed }
|
||||
} else {
|
||||
Job::Generate { date: Some(parsed) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +108,7 @@ impl Job {
|
||||
match self {
|
||||
Job::Generate { date: None } => "generate".into(),
|
||||
Job::Generate { date: Some(date) } => format!("generate-{date}"),
|
||||
Job::Rebuild { date } => format!("rebuild-{date}"),
|
||||
Job::DryRun => "dry-run".into(),
|
||||
Job::ProfileRebuild => "profile-rebuild".into(),
|
||||
Job::FeaturesBackfill => "features-backfill".into(),
|
||||
@@ -121,6 +132,9 @@ impl Job {
|
||||
Job::Generate { date: Some(_) } => {
|
||||
"Build and publish the issue of a given date, republishing it if it exists."
|
||||
}
|
||||
Job::Rebuild { .. } => {
|
||||
"Re-render and republish a published issue's EPUBs from its stored content, without curating again."
|
||||
}
|
||||
Job::DryRun => "Run the whole pipeline but publish nothing and record no issue.",
|
||||
Job::ProfileRebuild => {
|
||||
"Regenerate the learned taste adjustments from ratings with the editor model."
|
||||
@@ -144,6 +158,7 @@ impl Job {
|
||||
pub fn takes_lock(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Job::Generate { .. } | Job::DryRun => Some("generate"),
|
||||
Job::Rebuild { .. } => Some("rebuild"),
|
||||
Job::ProfileRebuild => Some("profile rebuild"),
|
||||
Job::FeaturesBackfill => Some("features backfill"),
|
||||
Job::BackfillSocial => Some("backfill-social"),
|
||||
@@ -151,9 +166,9 @@ impl Job {
|
||||
}
|
||||
}
|
||||
|
||||
/// Needs a confirmation dialog: `generate` republishes an issue.
|
||||
/// Needs a confirmation dialog: `generate` and `rebuild` republish an issue.
|
||||
pub fn dangerous(&self) -> bool {
|
||||
matches!(self, Job::Generate { .. })
|
||||
matches!(self, Job::Generate { .. } | Job::Rebuild { .. })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +496,24 @@ mod tests {
|
||||
assert!(!Job::parse("dry-run").unwrap().dangerous());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_the_dated_rebuild_form() {
|
||||
let job = Job::parse("rebuild-2026-10-01").unwrap();
|
||||
assert_eq!(
|
||||
job,
|
||||
Job::Rebuild {
|
||||
date: "2026-10-01".parse().unwrap()
|
||||
}
|
||||
);
|
||||
assert_eq!(job.name(), "rebuild-2026-10-01");
|
||||
assert_eq!(job.unit(), "daily-epub-job@rebuild-2026-10-01.service");
|
||||
assert_eq!(job.takes_lock(), Some("rebuild"));
|
||||
assert!(job.dangerous());
|
||||
for bad in ["rebuild", "rebuild-", "rebuild-2026-10-1", "rebuild-today"] {
|
||||
assert_eq!(Job::parse(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_traversal_uppercase_and_unknown_names() {
|
||||
for name in [
|
||||
|
||||
Reference in new issue
Block a user