diff --git a/src/export/clipboard.rs b/src/export/clipboard.rs new file mode 100644 index 0000000..f3577cd --- /dev/null +++ b/src/export/clipboard.rs @@ -0,0 +1,117 @@ +//! Copying text to the clipboard, in whichever way works where you are. + +use std::io::Write; +use std::process::{Command, Stdio}; + +/// How skyshift copies text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClipboardMode { + /// The system clipboard, or the terminal's if that fails. + #[default] + Auto, + /// Only the system clipboard (Windows, or X11/Wayland on Linux). + System, + /// Ask the terminal to copy, with the OSC 52 escape code. Works over SSH + /// in terminals that support it (Windows Terminal, kitty, WezTerm, foot, + /// Alacritty...). + Terminal, + /// Run a command that reads the text on its input, like `wl-copy` or + /// `xclip -selection clipboard`. + Command, +} + +pub struct Clipboard { + mode: ClipboardMode, + command: String, + /// Kept for the whole session: on Linux, the app that copied the text is + /// the one that hands it over when you paste, so it must stay alive. + system: Option, +} + +impl Clipboard { + pub fn new(mode: ClipboardMode, command: String) -> Clipboard { + let wants_system = matches!(mode, ClipboardMode::Auto | ClipboardMode::System); + let system = if wants_system { arboard::Clipboard::new().ok() } else { None }; + Clipboard { mode, command, system } + } + + /// Copies `text`. On success, says where it went (for the status line). + pub fn copy(&mut self, text: &str) -> Result<&'static str, String> { + match self.mode { + ClipboardMode::System => self.copy_to_system(text).map(|()| "clipboard"), + ClipboardMode::Terminal => copy_with_osc52(text).map(|()| "terminal clipboard"), + ClipboardMode::Command => copy_with_command(&self.command, text).map(|()| "clipboard"), + ClipboardMode::Auto => self + .copy_to_system(text) + .map(|()| "clipboard") + .or_else(|_| copy_with_osc52(text).map(|()| "terminal clipboard")), + } + } + + fn copy_to_system(&mut self, text: &str) -> Result<(), String> { + let clipboard = self.system.as_mut().ok_or("the system clipboard isn't available")?; + clipboard.set_text(text).map_err(|error| error.to_string()) + } +} + +/// Sends the OSC 52 escape code: the terminal puts the (base64-encoded) +/// text on the clipboard. Terminals without OSC 52 support ignore it. +fn copy_with_osc52(text: &str) -> Result<(), String> { + let sequence = format!("\x1b]52;c;{}\x07", base64(text.as_bytes())); + let mut stdout = std::io::stdout(); + stdout.write_all(sequence.as_bytes()).and_then(|()| stdout.flush()).map_err(|error| error.to_string()) +} + +/// Runs `command` (program and arguments, separated by spaces) and writes +/// the text to its input. +fn copy_with_command(command: &str, text: &str) -> Result<(), String> { + let mut words = command.split_whitespace(); + let program = words.next().ok_or("the clipboard command setting is empty")?; + let mut child = Command::new(program) + .args(words) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("can't run {program}: {error}"))?; + if let Some(mut input) = child.stdin.take() { + input.write_all(text.as_bytes()).map_err(|error| error.to_string())?; + // `input` is dropped here, which closes it: the command sees the end of the text. + } + let status = child.wait().map_err(|error| error.to_string())?; + if status.success() { Ok(()) } else { Err(format!("{program} failed ({status})")) } +} + +/// Standard base64 (RFC 4648), as OSC 52 requires. +fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + // Three bytes make 24 bits, written as four 6-bit characters. + let byte = |index: usize| u32::from(chunk.get(index).copied().unwrap_or(0)); + let bits = (byte(0) << 16) | (byte(1) << 8) | byte(2); + for position in 0..4 { + if position <= chunk.len() { + let six_bits = (bits >> (18 - 6 * position)) & 0b11_1111; + out.push(char::from(ALPHABET[six_bits as usize])); + } else { + out.push('='); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::base64; + + #[test] + fn base64_matches_the_standard() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"5:40\t22:00"), "NTo0MAkyMjowMA=="); + } +} diff --git a/src/export/csv.rs b/src/export/csv.rs new file mode 100644 index 0000000..24cf80b --- /dev/null +++ b/src/export/csv.rs @@ -0,0 +1,47 @@ +//! CSV export: one row per work day, for spreadsheets and timesheet tools. + +use super::Report; +use crate::time::{DecimalSeparator, DurationStyle}; + +pub fn render(report: &Report) -> String { + // Where the decimal separator is a comma (French, German...), + // spreadsheets expect the columns to be separated by semicolons. + let delimiter = match report.fmt.decimal_separator { + DecimalSeparator::Dot => ",", + DecimalSeparator::Comma => ";", + }; + let header = ["Date", "Weekday", "In", "Out", "Break", "Worked", "Worked (hours)", "Segments"]; + let mut rows = vec![header.join(delimiter)]; + + for day in &report.days { + let segments: Vec = day + .segments() + .iter() + .map(|segment| format!("{}-{}", report.time(segment.start()), report.time(segment.end()))) + .collect(); + let fields = [ + day.date().to_string(), + day.date().strftime("%a").to_string(), + day.first_start().map(|start| report.time(start)).unwrap_or_default(), + day.last_end().map(|end| report.time(end)).unwrap_or_default(), + report.fmt.duration_as(DurationStyle::Clock, day.break_time()), + report.fmt.duration_as(DurationStyle::Clock, day.worked()), + report.fmt.decimal_hours(day.worked()), + segments.join(" "), + ]; + let quoted: Vec = fields.iter().map(|field| quote(field, delimiter)).collect(); + rows.push(quoted.join(delimiter)); + } + // CSV lines end with CRLF (RFC 4180), which every spreadsheet accepts. + rows.join("\r\n") + "\r\n" +} + +/// Quotes a field when it contains the delimiter, a quote or a line break +/// (doubling the quotes inside), as CSV requires. +fn quote(field: &str, delimiter: &str) -> String { + if field.contains(delimiter) || field.contains(['"', '\n', '\r']) { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} diff --git a/src/export/json.rs b/src/export/json.rs new file mode 100644 index 0000000..88b7513 --- /dev/null +++ b/src/export/json.rs @@ -0,0 +1,65 @@ +//! JSON export: everything, in a machine-friendly shape. Moments are RFC 3339 +//! timestamps with their UTC offset; durations are whole minutes. + +use serde::Serialize; + +use super::Report; +use crate::time::clock::zone_name; +use crate::time::format::{rfc3339, whole_minutes}; + +#[derive(Serialize)] +struct JsonReport { + generated: String, + time_zone: String, + from: String, + to: String, + worked_minutes: i64, + days: Vec, +} + +#[derive(Serialize)] +struct JsonDay { + date: String, + worked_minutes: i64, + break_minutes: i64, + segments: Vec, +} + +#[derive(Serialize)] +struct JsonSegment { + start: String, + end: String, + minutes: i64, +} + +pub fn render(report: &Report) -> String { + let days = report + .days + .iter() + .map(|day| JsonDay { + date: day.date().to_string(), + worked_minutes: whole_minutes(day.worked()), + break_minutes: whole_minutes(day.break_time()), + segments: day + .segments() + .iter() + .map(|segment| JsonSegment { + start: rfc3339(&report.local(segment.start())), + end: rfc3339(&report.local(segment.end())), + minutes: whole_minutes(segment.duration()), + }) + .collect(), + }) + .collect(); + + let json = JsonReport { + generated: rfc3339(&report.generated), + time_zone: zone_name(report.zone), + from: report.range.first().to_string(), + to: report.range.last().to_string(), + worked_minutes: whole_minutes(report.total()), + days, + }; + // Plain structs of strings and numbers always serialize. + serde_json::to_string_pretty(&json).unwrap_or_default() + "\n" +} diff --git a/src/export/markdown.rs b/src/export/markdown.rs new file mode 100644 index 0000000..b6f2425 --- /dev/null +++ b/src/export/markdown.rs @@ -0,0 +1,145 @@ +//! Markdown export: a clean report with a summary and one table per week. +//! The tables are padded so the file also reads well as plain text. + +use jiff::civil::Date; +use jiff::{SignedDuration, Timestamp}; + +use super::Report; +use crate::domain::WorkDay; +use crate::time::DateRange; +use crate::time::clock::zone_name; + +pub fn render(report: &Report) -> String { + let fmt = &report.fmt; + let total = report.total(); + let days_worked = report.days.len(); + + let mut lines = vec![ + "# Work report".to_string(), + String::new(), + format!( + "**{} - {}** ยท {}", + long_date(report.range.first()), + long_date(report.range.last()), + zone_name(report.zone) + ), + String::new(), + format!("- **Worked:** {} over {days_worked} day{}", fmt.duration(total), plural(days_worked)), + ]; + if let Ok(count) = i32::try_from(days_worked) + && count > 0 + { + lines.push(format!("- **Average day:** {}", fmt.duration(total / count))); + } + if let Some(longest) = report.days.iter().max_by_key(|day| day.worked()) { + lines.push(format!( + "- **Longest day:** {} ({})", + fmt.duration(longest.worked()), + fmt.date_short(longest.date()) + )); + } + + for (week, days) in group_by_week(report) { + let worked: SignedDuration = days.iter().map(|day| day.worked()).sum(); + let breaks: SignedDuration = days.iter().map(|day| day.break_time()).sum(); + lines.push(String::new()); + lines.push(format!("## Week of {} ({})", fmt.date_short(week.first()), fmt.duration(worked))); + lines.push(String::new()); + + let mut rows: Vec> = days + .iter() + .map(|day| { + vec![ + fmt.date_short(day.date()), + day.first_start().map(|start| report.time(start)).unwrap_or_default(), + day.last_end().map(|end| out_time(report, day, end)).unwrap_or_default(), + fmt.duration(day.break_time()), + fmt.duration(day.worked()), + ] + }) + .collect(); + rows.push(vec![ + "**Total**".into(), + String::new(), + String::new(), + fmt.duration(breaks), + fmt.duration(worked), + ]); + lines.extend(table(&["Day", "In", "Out", "Break", "Worked"], &rows)); + } + + let generated = &report.generated; + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); + lines.push(format!( + "_Generated by skyshift on {} at {}._", + long_date(generated.date()), + fmt.time(generated.time()) + )); + lines.join("\n") + "\n" +} + +/// The end time, marked "(+1)" when it falls on the next calendar day. +fn out_time(report: &Report, day: &WorkDay, end: Timestamp) -> String { + let days_later = report.local(end).date().duration_since(day.date()).as_hours() / 24; + match days_later { + 0 => report.time(end), + later => format!("{} (+{later})", report.time(end)), + } +} + +/// The report's days grouped by week, in order. +fn group_by_week<'a>(report: &Report<'a>) -> Vec<(DateRange, Vec<&'a WorkDay>)> { + let mut weeks: Vec<(DateRange, Vec<&WorkDay>)> = Vec::new(); + for day in &report.days { + let week = DateRange::week_of(day.date(), report.week_start); + match weeks.last_mut() { + Some((current, days)) if *current == week => days.push(day), + _ => weeks.push((week, vec![day])), + } + } + weeks +} + +fn long_date(date: Date) -> String { + date.strftime("%a %b %-d, %Y").to_string() +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} + +/// A Markdown table, one string per line: the first column left-aligned, +/// the others right-aligned (they hold times and durations), every column +/// padded to the same width. +fn table(headers: &[&str], rows: &[Vec]) -> Vec { + let widths: Vec = (0..headers.len()) + .map(|column| { + let longest_cell = rows.iter().map(|row| row[column].chars().count()).max().unwrap_or(0); + longest_cell.max(headers[column].chars().count()).max(3) + }) + .collect(); + + let pad = |column: usize, text: &str| { + let width = widths[column]; + if column == 0 { format!("{text:width$}") } + }; + let line = |cells: Vec| format!("| {} |", cells.join(" | ")); + + let header = line(headers.iter().enumerate().map(|(column, text)| pad(column, text)).collect()); + let separator = line( + widths + .iter() + .enumerate() + .map(|(column, width)| { + let dashes = "-".repeat(width - 1); + if column == 0 { format!(":{dashes}") } else { format!("{dashes}:") } + }) + .collect(), + ); + let body = + rows.iter().map(|row| line(row.iter().enumerate().map(|(column, cell)| pad(column, cell)).collect())); + + [header, separator].into_iter().chain(body).collect() +} diff --git a/src/export/mod.rs b/src/export/mod.rs new file mode 100644 index 0000000..d1f4728 --- /dev/null +++ b/src/export/mod.rs @@ -0,0 +1,110 @@ +//! Getting your data out of skyshift: +//! - [`timesheet`]: the text you copy for your work's timesheet, +//! - [`clipboard`]: putting that text on the clipboard, +//! - [`csv`], [`json`], [`markdown`]: report files for a range of dates. + +pub mod clipboard; +pub mod csv; +pub mod json; +pub mod markdown; +pub mod timesheet; + +use std::io; +use std::path::{Path, PathBuf}; + +use jiff::civil::Weekday; +use jiff::tz::TimeZone; +use jiff::{SignedDuration, Timestamp, Zoned}; + +use crate::domain::WorkDay; +use crate::time::{DateRange, Formatter}; + +/// The report file formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExportFormat { + /// For spreadsheets (Excel, LibreOffice, Google Sheets) and timesheet tools. + #[default] + Csv, + /// For scripts and other apps. + Json, + /// A readable report for notes apps, emails or printing. + Markdown, +} + +impl ExportFormat { + pub const ALL: [ExportFormat; 3] = [ExportFormat::Csv, ExportFormat::Json, ExportFormat::Markdown]; + + pub fn label(self) -> &'static str { + match self { + ExportFormat::Csv => "CSV", + ExportFormat::Json => "JSON", + ExportFormat::Markdown => "Markdown", + } + } + + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Csv => "csv", + ExportFormat::Json => "json", + ExportFormat::Markdown => "md", + } + } +} + +/// Everything a report file is made from. +pub struct Report<'a> { + /// The work days in the range that have some work, oldest first. + pub days: Vec<&'a WorkDay>, + pub range: DateRange, + pub fmt: Formatter, + pub zone: &'a TimeZone, + pub week_start: Weekday, + /// When the report was made. + pub generated: Zoned, +} + +impl<'a> Report<'a> { + pub fn new( + all_days: &'a [WorkDay], + range: DateRange, + fmt: Formatter, + zone: &'a TimeZone, + week_start: Weekday, + generated: Zoned, + ) -> Report<'a> { + let days = all_days.iter().filter(|day| range.contains(day.date()) && !day.is_empty()).collect(); + Report { days, range, fmt, zone, week_start, generated } + } + + /// A stored moment as a clock time in the report's time zone. + fn time(&self, moment: Timestamp) -> String { + self.fmt.time(self.local(moment).time()) + } + + fn local(&self, moment: Timestamp) -> Zoned { + moment.to_zoned(self.zone.clone()) + } + + fn total(&self) -> SignedDuration { + self.days.iter().map(|day| day.worked()).sum() + } +} + +/// The report as the contents of a file. +pub fn render(format: ExportFormat, report: &Report) -> String { + match format { + ExportFormat::Csv => csv::render(report), + ExportFormat::Json => json::render(report), + ExportFormat::Markdown => markdown::render(report), + } +} + +/// Saves an export in `folder`, named after the dates it covers +/// ("skyshift_2026-09-01_2026-09-30.csv"), and returns where it went. +pub fn save(folder: &Path, format: ExportFormat, range: DateRange, contents: &str) -> io::Result { + std::fs::create_dir_all(folder)?; + let name = format!("skyshift_{}_{}.{}", range.first(), range.last(), format.extension()); + let path = folder.join(name); + std::fs::write(&path, contents)?; + Ok(path) +} diff --git a/src/export/timesheet.rs b/src/export/timesheet.rs new file mode 100644 index 0000000..ecb0be5 --- /dev/null +++ b/src/export/timesheet.rs @@ -0,0 +1,188 @@ +//! The text you copy into your work's timesheet. + +use jiff::tz::TimeZone; +use jiff::{SignedDuration, Timestamp}; + +use crate::domain::WorkDay; +use crate::time::{DurationStyle, Formatter}; + +/// What gets copied for each work day. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CopyPreset { + /// Total time as hours and minutes: `5:40` + #[default] + TotalClock, + /// Total time as decimal hours: `5.67` + TotalDecimal, + /// Every segment: `22:00-01:30, 02:00-04:10` + Segments, + /// First start, last end and total break, separated by tabs so they fill + /// three cells when pasted into a spreadsheet or a web timesheet grid. + InOutBreak, + /// Your own template (the `custom_template` setting). + Custom, +} + +impl CopyPreset { + pub const ALL: [CopyPreset; 5] = [ + CopyPreset::TotalClock, + CopyPreset::TotalDecimal, + CopyPreset::Segments, + CopyPreset::InOutBreak, + CopyPreset::Custom, + ]; + + /// A short description with an example, for menus. + pub fn label(self) -> &'static str { + match self { + CopyPreset::TotalClock => "Total as H:MM (5:40)", + CopyPreset::TotalDecimal => "Total as decimal hours (5.67)", + CopyPreset::Segments => "Each segment (22:00-01:30, 02:00-04:10)", + CopyPreset::InOutBreak => "In, Out, Break (tab-separated cells)", + CopyPreset::Custom => "Custom template", + } + } +} + +/// How the total is rounded for the timesheet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Rounding { + #[default] + None, + Nearest, + Up, + Down, +} + +impl Rounding { + /// Rounds `duration` to a multiple of `step` (for example 15 minutes). + /// A zero step leaves the duration unchanged. + pub fn apply(self, duration: SignedDuration, step: SignedDuration) -> SignedDuration { + let (value, step) = (duration.as_secs(), step.as_secs()); + if step <= 0 { + return duration; + } + let rounded = match self { + Rounding::None => return duration, + Rounding::Down => value.div_euclid(step) * step, + Rounding::Up => -(-value).div_euclid(step) * step, + Rounding::Nearest => (value + step / 2).div_euclid(step) * step, + }; + SignedDuration::from_secs(rounded) + } +} + +/// Everything that shapes the copied text, taken from the settings. +#[derive(Debug, Clone)] +pub struct CopyOptions { + pub fmt: Formatter, + pub zone: TimeZone, + pub rounding: Rounding, + pub rounding_step: SignedDuration, + /// Used by [`CopyPreset::Custom`]: text with placeholders, filled by + /// `fill_template` below. + pub template: String, +} + +/// The text to copy for one work day. +pub fn day_text(day: &WorkDay, preset: CopyPreset, options: &CopyOptions) -> String { + match preset { + CopyPreset::TotalClock => fill_template("{total}", day, options), + CopyPreset::TotalDecimal => fill_template("{decimal}", day, options), + CopyPreset::Segments => fill_template("{segments}", day, options), + CopyPreset::InOutBreak => fill_template("{in}{tab}{out}{tab}{break}", day, options), + CopyPreset::Custom => fill_template(&options.template, day, options), + } +} + +/// The text to copy for several work days: one line per day, so pasting +/// fills consecutive rows. +pub fn days_text(days: &[&WorkDay], preset: CopyPreset, options: &CopyOptions) -> String { + days.iter().map(|day| day_text(day, preset, options)).collect::>().join("\n") +} + +/// Replaces every placeholder of `template` with the day's values: +/// +/// | Placeholder | Becomes | +/// |--------------|-----------------------------------------| +/// | `{date}` | 2026-09-24 | +/// | `{day}` | Thu Sep 24 | +/// | `{in}` | the first start | +/// | `{out}` | the last end | +/// | `{break}` | total break, H:MM | +/// | `{total}` | time worked, H:MM (rounded) | +/// | `{decimal}` | time worked in hours (rounded) | +/// | `{segments}` | 22:00-01:30, 02:00-04:10 | +/// | `{tab}` | a tab, which jumps to the next cell | +fn fill_template(template: &str, day: &WorkDay, options: &CopyOptions) -> String { + let fmt = &options.fmt; + let total = options.rounding.apply(day.worked(), options.rounding_step); + let time = |moment: Option| { + moment.map(|moment| fmt.time(moment.to_zoned(options.zone.clone()).time())).unwrap_or_default() + }; + let segments = day + .segments() + .iter() + .map(|segment| format!("{}-{}", time(Some(segment.start())), time(Some(segment.end())))) + .collect::>() + .join(", "); + + template + .replace("{date}", &day.date().to_string()) + .replace("{day}", &fmt.date_short(day.date())) + .replace("{in}", &time(day.first_start())) + .replace("{out}", &time(day.last_end())) + .replace("{break}", &fmt.duration_as(DurationStyle::Clock, day.break_time())) + .replace("{total}", &fmt.duration_as(DurationStyle::Clock, total)) + .replace("{decimal}", &fmt.decimal_hours(total)) + .replace("{segments}", &segments) + .replace("{tab}", "\t") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::{Journal, Segment, ShiftState}; + use jiff::civil::date; + + fn night_shift() -> WorkDay { + let at = |day: i8, hour: i8, minute: i8| { + date(2026, 9, day).at(hour, minute, 0, 0).to_zoned(TimeZone::UTC).unwrap().timestamp() + }; + let segments = vec![ + Segment::new(at(24, 22, 0), at(25, 1, 30)).unwrap(), + Segment::new(at(25, 2, 0), at(25, 4, 10)).unwrap(), + ]; + let journal = Journal::from_parts(vec![(date(2026, 9, 24), segments)], ShiftState::Idle).unwrap(); + journal.days().next().unwrap().clone() + } + + fn options(rounding: Rounding) -> CopyOptions { + CopyOptions { + fmt: Formatter::default(), + zone: TimeZone::UTC, + rounding, + rounding_step: SignedDuration::from_mins(15), + template: "{date}{tab}{total}".to_string(), + } + } + + #[test] + fn presets() { + let day = night_shift(); + let options = options(Rounding::None); + assert_eq!(day_text(&day, CopyPreset::TotalClock, &options), "5:40"); + assert_eq!(day_text(&day, CopyPreset::TotalDecimal, &options), "5.67"); + assert_eq!(day_text(&day, CopyPreset::Segments, &options), "22:00-01:30, 02:00-04:10"); + assert_eq!(day_text(&day, CopyPreset::InOutBreak, &options), "22:00\t04:10\t0:30"); + assert_eq!(day_text(&day, CopyPreset::Custom, &options), "2026-09-24\t5:40"); + } + + #[test] + fn rounding_to_quarter_hours() { + let day = night_shift(); + assert_eq!(day_text(&day, CopyPreset::TotalClock, &options(Rounding::Nearest)), "5:45"); + assert_eq!(day_text(&day, CopyPreset::TotalClock, &options(Rounding::Down)), "5:30"); + assert_eq!(day_text(&day, CopyPreset::TotalDecimal, &options(Rounding::Up)), "5.75"); + } +} diff --git a/src/main.rs b/src/main.rs index 0b7f45e..af0a032 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod analysis; mod domain; +mod export; mod time; fn main() {}