jobber 0.1.2-alpha

Minimalistic console work time tracker
use crate::{
    commands::{Cli, Config, Error, Result},
    jobber::Jobber,
    views::{BillView, ClientView, CompanyView, InvoiceView, WorkView},
};
use clap::Parser;
use color_print::cprintln;
use typst::foundations::Array;
use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions};
use typst_library::foundations::{Dict, Value};

/// Export (or restore) a invoice.
///
/// Bill will be marked as export and can be restored later.
#[derive(Parser, Debug)]
pub(crate) struct ExportInvoice {
    /// Invoice number
    no: usize,
    /// Use a Typst template.
    ///
    /// pre-installed templates can be selected by a ":" followed by their
    /// folder name (e.g. ":marx_and_friends")
    #[clap(short, long, default_value = ":default")]
    template: String,
}

impl ExportInvoice {
    pub(crate) fn run(&self, cli: &Cli) -> Result<()> {
        let config = Config::load()?;
        let database = cli.open_database()?;
        let template = if self.template.starts_with(':') {
            config
                .app_path()?
                .join(&self.template[1..])
                .join("invoice.typ")
        } else {
            std::path::PathBuf::from(&self.template)
        };
        export_pdf(self.no, &database, &template)?;
        cprintln!("Successfully exported invoice <s>{}</>.", self.no);
        Ok(())
    }
}

fn insert(dict: &mut Dict, key: &str, s: &str) {
    dict.insert(key.into(), Value::Str(s.into()));
}

impl std::convert::From<InvoiceView> for Value {
    fn from(invoice: InvoiceView) -> Value {
        let mut dict = Dict::new();
        insert(&mut dict, "no", &invoice.no);
        insert(&mut dict, "date", &invoice.date);
        insert(&mut dict, "client", &invoice.client);
        insert(&mut dict, "subject", &invoice.subject);
        insert(&mut dict, "worked", &invoice.worked);
        insert(&mut dict, "revenue", &invoice.revenue);
        insert(&mut dict, "tax", &invoice.tax);
        insert(&mut dict, "revenue_taxed", &invoice.revenue_taxed);
        Value::Dict(dict)
    }
}

impl std::convert::From<&BillView> for Value {
    fn from(bill: &BillView) -> Value {
        let mut dict = Dict::new();
        insert(&mut dict, "pos", &bill.pos.to_string());
        insert(&mut dict, "worker", &bill.worker);
        insert(&mut dict, "subject", &bill.subject);
        insert(&mut dict, "rate", &bill.rate);
        insert(&mut dict, "interval", &bill.interval);
        insert(&mut dict, "worked", &bill.worked);
        insert(&mut dict, "revenue", &bill.revenue);
        Value::Dict(dict)
    }
}

impl std::convert::From<&CompanyView> for Value {
    fn from(company: &CompanyView) -> Value {
        let mut dict = Dict::new();
        insert(&mut dict, "name", &company.name);
        insert(&mut dict, "street", &company.street);
        insert(&mut dict, "zip", &company.zip);
        insert(&mut dict, "city", &company.city);
        if let Some(vat) = &company.vat {
            insert(&mut dict, "vat", vat);
        }
        if let Some(bank) = &company.bank {
            insert(&mut dict, "bank", &bank.name);
            insert(&mut dict, "iban", &bank.iban);
            insert(&mut dict, "bic", &bank.bic);
        }
        insert(&mut dict, "register", &company.register);
        Value::Dict(dict)
    }
}

impl std::convert::From<&ClientView> for Value {
    fn from(client: &ClientView) -> Value {
        let mut dict = Dict::new();
        insert(&mut dict, "name", &client.name);
        insert(&mut dict, "street", &client.street);
        insert(&mut dict, "zip", &client.zip);
        insert(&mut dict, "city", &client.city);
        insert(&mut dict, "country", &client.country);
        insert(&mut dict, "vat", &client.vat);
        insert(&mut dict, "rate", &client.rate);
        insert(&mut dict, "interval", &client.interval);
        insert(&mut dict, "billing", &client.billing);
        insert(&mut dict, "work", &client.work);
        Value::Dict(dict)
    }
}

impl std::convert::From<&WorkView> for Value {
    fn from(work: &WorkView) -> Value {
        let mut dict = Dict::new();
        insert(&mut dict, "no", &work.no);
        insert(&mut dict, "start", &work.start);
        insert(&mut dict, "end", &work.end);
        insert(&mut dict, "duration", &work.duration);
        insert(&mut dict, "subject", &work.subject);
        insert(&mut dict, "tags", &work.tags);
        insert(&mut dict, "order", &work.order.to_string());
        Value::Dict(dict)
    }
}

fn export_pdf(
    no: usize,
    database: &impl Jobber,
    template: &impl AsRef<std::path::Path>,
) -> Result<()> {
    let config = Config::load()?;
    let template_folder = template.as_ref().parent();
    let invoice = InvoiceView::new(no, database, false)?;
    let mut doc = Dict::new();
    doc.insert("invoice".into(), invoice.into());
    let invoice = database.get_invoice(no)?;
    let mut works = Vec::new();
    let doc_bills = Array::from_iter(
        invoice
            .bills
            .iter()
            .enumerate()
            .inspect(|(pos, bill)| {
                let work: Vec<_> = bill
                    .work
                    .iter()
                    .enumerate()
                    .map(|(pos, work)| {
                        WorkView::new(Some(pos), work, database.get_tags(), &bill.scheme, false)
                    })
                    .collect();
                works.push((
                    *pos,
                    database
                        .get_full_worker_name(&bill.worker)
                        .expect("unknown worker"),
                    work,
                ));
            })
            .map(|(pos, bill)| BillView::new(pos, bill, database, false))
            .collect::<Result<Vec<_>>>()?
            .iter()
            .map(|bill| bill.into()),
    );
    doc.insert("bills".into(), Value::Array(doc_bills));

    let doc_sheets = Array::from_iter(works.iter().map(|(pos, worker, works)| {
        let doc_works = Array::from_iter(works.iter().map(|work| -> Value { work.into() }));
        {
            let mut dict = Dict::new();
            dict.insert("worker".into(), Value::Str(worker.clone().into()));
            dict.insert("works".into(), Value::Array(doc_works));
            dict.insert("pos".into(), Value::Int(*pos as i64));
            Value::Dict(dict)
        }
    }));
    doc.insert("sheets".into(), Value::Array(doc_sheets));

    let company_view = &CompanyView::new(database, false)?;
    doc.insert("company".into(), company_view.into());

    let client_view = &ClientView::new(&invoice.client, &config, database, false)?;
    doc.insert("client".into(), client_view.into());

    let template = std::fs::read_to_string(template)?;
    let engine = TypstEngine::builder()
        .main_file(template)
        .search_fonts_with(TypstKitFontOptions::default().include_system_fonts(true))
        .with_file_system_resolver(template_folder.unwrap_or(&std::path::PathBuf::from(".")))
        .build();
    let doc = engine.compile_with_input(doc).output?;
    let pdf = typst_pdf::pdf(&doc, &Default::default()).map_err(|errors| {
        let msg = errors
            .iter()
            .map(|e| e.message.to_string())
            .collect::<Vec<_>>()
            .join(", ");
        Error::TypstPDF(msg)
    })?;
    std::fs::write(format!("invoice_{no}.pdf"), pdf)?;
    Ok(())
}