Initial commit: The Daily EPUB full implementation

Full implementation of a personalized daily newspaper delivered as an
EPUB.

Articles are pulled from a local self-hosted Miniflux instance, enriched
with comments, summarized and filtered by DeepSeek AI, and then
assembled into two EPUB editions: standard and optimized for the Xteink
X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS
server in a separate library. Then the X4 edition is futher converted to
XTC format and served over a separate OPDS server hosted by the Rust
binary. Runs are tracked in a local SQLite database so runs are
idempotent per date.

Full documentation of the plan is in docs/plans and setup and install
instructions are in the README.md file.
This commit is contained in:
2026-08-15 17:46:19 +00:00
commit 9e30c1dcdf
80 changed files with 27578 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
//! `daily-epub` — CLI entry point (spec §2).
//!
//! Everything of substance lives in the library (`src/lib.rs`); this binary only
//! parses flags, loads config, opens the database and dispatches.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use daily_epub::config::Config;
use daily_epub::db::Db;
use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome};
use daily_epub::report::RunReport;
use daily_epub::{curate, http, server, social};
/// A personalized daily newspaper, delivered as an EPUB.
#[derive(Debug, Parser)]
#[command(name = "daily-epub", version, about, long_about = None)]
struct Cli {
/// Config file path (defaults to ./config.toml when present).
#[arg(long, short, global = true, value_name = "FILE")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Build (and publish) one issue.
Generate(GenerateArgs),
/// Run the rating endpoints, XTC OPDS feed and static files.
Serve,
/// Taste-profile maintenance.
#[command(subcommand)]
Profile(ProfileCommand),
/// Re-poll social scores for recent entries.
BackfillSocial(BackfillSocialArgs),
/// Database maintenance.
#[command(subcommand)]
Db(DbCommand),
}
#[derive(Debug, clap::Args)]
struct GenerateArgs {
/// Issue date in the configured timezone (defaults to today).
#[arg(long, value_name = "YYYY-MM-DD")]
date: Option<String>,
/// Build everything but publish nothing: no BookOrbit copy, no issue record.
#[arg(long)]
dry_run: bool,
/// Write artifacts here instead of `out_dir`.
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
/// Cap the lineup size (overrides `target_article_count`).
#[arg(long, value_name = "N")]
max_articles: Option<usize>,
/// Skip every LLM call: prefilter order selects, excerpts stand in for summaries.
#[arg(long)]
skip_llm: bool,
}
impl From<&GenerateArgs> for GenerateOptions {
fn from(args: &GenerateArgs) -> Self {
Self {
date: args.date.clone(),
dry_run: args.dry_run,
out: args.out.clone(),
max_articles: args.max_articles,
skip_llm: args.skip_llm,
}
}
}
#[derive(Debug, Subcommand)]
enum ProfileCommand {
/// Regenerate the taste profile from ratings history.
Rebuild,
}
#[derive(Debug, clap::Args)]
struct BackfillSocialArgs {
/// How many days back to re-poll.
#[arg(long, default_value_t = 7)]
days: u32,
}
#[derive(Debug, Subcommand)]
enum DbCommand {
/// Run pending sqlx migrations.
Migrate,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let config = Config::load(cli.config.as_deref()).context("loading configuration")?;
tracing::debug!(?config.database_path, "configuration loaded");
match cli.command {
Command::Generate(args) => {
let db = Db::open_and_migrate(&config.database_path).await?;
let outcome = pipeline::generate(&config, &db, &GenerateOptions::from(&args)).await?;
print_outcome(&outcome);
}
Command::Serve => {
let db = Db::open_and_migrate(&config.database_path).await?;
server::serve(config, db).await?;
}
Command::Profile(ProfileCommand::Rebuild) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_profile_rebuild(&config, &db).await?;
}
Command::BackfillSocial(args) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_backfill_social(&db, args.days).await?;
}
Command::Db(DbCommand::Migrate) => {
let db = Db::open(&config.database_path).await?;
db.migrate().await?;
println!("migrations up to date: {}", config.database_path.display());
}
}
Ok(())
}
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,reqwest=warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_writer(std::io::stderr)
.init();
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
/// Human-readable end-of-run output; the machine-readable form lives in `runs`
/// and in the issue's `report_json` (§3.13).
fn print_outcome(outcome: &GenerateOutcome) {
print_report(&outcome.report);
if let Some(issue) = &outcome.issue {
print_lineup(issue);
}
for artifact in &outcome.artifacts {
println!(
"built: {} ({:.1} MiB)",
artifact.path.display(),
artifact.bytes as f64 / (1024.0 * 1024.0)
);
}
if let Some(xtc) = &outcome.xtc {
println!("xtc: {}", xtc.display());
}
match &outcome.published {
Some(published) => {
for artifact in &published.epubs {
println!("published: {}", artifact.path.display());
}
if let Some(xtc) = &published.xtc {
println!("published: {}", xtc.display());
}
if let Some(opds) = &published.opds {
println!("opds: {}", opds.display());
}
if published.pruned > 0 {
println!("pruned: {} expired files", published.pruned);
}
}
None => println!("dry run: nothing was published"),
}
}
fn print_report(report: &RunReport) {
println!("{}", report.summary_line());
if let (Some(start), Some(end)) = (report.window_start, report.window_end) {
println!("window: {start}{end}");
}
println!(
"entries: {} from {} feeds → {} articles ({} merged, {} dropped)",
report.counts.entries_fetched,
report.counts.feeds_seen,
report.counts.articles,
report.counts.duplicates_merged,
report.counts.entries_dropped,
);
println!(
"tokens: {} input · {} cached · {} output = ${:.4}",
report.usage.input_tokens,
report.usage.cached_tokens,
report.usage.output_tokens,
report.cost_usd,
);
for warning in &report.warnings {
println!("warning: {warning}");
}
if let Some(err) = &report.error {
println!("error: {err}");
}
tracing::debug!("{}", report.to_json());
}
/// The day's lineup, section by section — the `--dry-run` deliverable (§4 M3).
fn print_lineup(issue: &daily_epub::types::Issue) {
println!(
"\nThe Daily EPUB No. {}{} · {}",
issue.meta.issue_number,
issue.meta.display_date,
issue.meta.stats_line()
);
for section in &issue.lineup.section_order {
println!("\n {section}");
for pick in issue.lineup.section_picks(section) {
let lead = if pick.is_lead { "" } else { " " };
println!(
" {lead}{}{} ({} min)",
pick.article.title,
pick.article.feed_title,
pick.article.reading_minutes()
);
}
}
if issue.world_briefing.is_some() {
println!("\n {}", daily_epub::types::WORLD_BRIEFING_SECTION);
}
println!();
}
// ---------------------------------------------------------------------------
// Other subcommands
// ---------------------------------------------------------------------------
async fn cmd_profile_rebuild(config: &Config, db: &Db) -> Result<()> {
let meter = curate::llm::UsageMeter::new(&config.deepseek, config.max_daily_usd);
let profile = curate::profile::load_or_build(db, &config.interests_opml).await?;
let llm = curate::llm::LlmClient::new(&config.deepseek, profile.text, meter)?;
let rebuilt = curate::profile::rebuild(db, &llm, &config.interests_opml).await?;
let feeds = curate::profile::rebuild_feed_priors(db).await?;
println!(
"taste profile rebuilt (version {}, {} chars); {feeds} feed priors refreshed",
rebuilt.version,
rebuilt.text.len()
);
Ok(())
}
async fn cmd_backfill_social(db: &Db, days: u32) -> Result<()> {
let http = http::build_client(http::DEFAULT_TIMEOUT)?;
let enricher = social::SocialEnricher::new(http, db.clone());
let updated = enricher.backfill(days).await?;
println!("refreshed social scores for {updated} articles");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_every_subcommand_from_the_spec() {
let cli = Cli::try_parse_from([
"daily-epub",
"generate",
"--date",
"2026-08-15",
"--dry-run",
"--out",
"./out",
"--max-articles",
"6",
"--skip-llm",
])
.unwrap();
match cli.command {
Command::Generate(a) => {
assert_eq!(a.date.as_deref(), Some("2026-08-15"));
assert!(a.dry_run);
assert_eq!(a.out, Some(PathBuf::from("./out")));
assert_eq!(a.max_articles, Some(6));
assert!(a.skip_llm);
let opts = GenerateOptions::from(&a);
assert_eq!(opts.date.as_deref(), Some("2026-08-15"));
assert!(opts.dry_run && opts.skip_llm);
assert_eq!(opts.max_articles, Some(6));
}
other => panic!("expected generate, got {other:?}"),
}
assert!(matches!(
Cli::try_parse_from(["daily-epub", "serve"])
.unwrap()
.command,
Command::Serve
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "profile", "rebuild"])
.unwrap()
.command,
Command::Profile(ProfileCommand::Rebuild)
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "backfill-social", "--days", "14"])
.unwrap()
.command,
Command::BackfillSocial(BackfillSocialArgs { days: 14 })
));
assert!(matches!(
Cli::try_parse_from(["daily-epub", "db", "migrate"])
.unwrap()
.command,
Command::Db(DbCommand::Migrate)
));
let cli = Cli::try_parse_from(["daily-epub", "--config", "/tmp/x.toml", "serve"]).unwrap();
assert_eq!(cli.config, Some(PathBuf::from("/tmp/x.toml")));
}
}