Add exports: timesheet copy presets, clipboard and report files

- timesheet: copy presets (total H:MM, decimal hours, segments,
  in/out/break as tab-separated cells, custom template) with optional
  rounding to a step.
- clipboard: system clipboard (Windows, X11, Wayland), falling back to
  the terminal's OSC 52, or a custom command like wl-copy.
- csv, json, markdown: report files for a range of dates. CSV uses ';'
  when the decimal separator is ','; the Markdown report has a summary
  and one padded table per week.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:21 -04:00
1 parent a646337339
commit 97f834a299
7 files changed
+673

No files matched your search

+117
View File
@@ -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<arboard::Clipboard>,
}
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==");
}
}
+47
View File
@@ -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<String> = 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<String> = 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()
}
}
+65
View File
@@ -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<JsonDay>,
}
#[derive(Serialize)]
struct JsonDay {
date: String,
worked_minutes: i64,
break_minutes: i64,
segments: Vec<JsonSegment>,
}
#[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"
}
+145
View File
@@ -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<Vec<String>> = 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<String>]) -> Vec<String> {
let widths: Vec<usize> = (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$}") } else { format!("{text:>width$}") }
};
let line = |cells: Vec<String>| 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()
}
+110
View File
@@ -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<PathBuf> {
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)
}
+188
View File
@@ -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::<Vec<_>>().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<Timestamp>| {
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::<Vec<_>>()
.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");
}
}
+1
View File
@@ -6,6 +6,7 @@
mod analysis;
mod domain;
mod export;
mod time;
fn main() {}