refactor: rewrite in rust
All checks were successful
Continuous Integration / Lint, Check & Test (push) Successful in 1m38s
Continuous Integration / Build Package (push) Successful in 1m54s

This commit is contained in:
Konstantin Fickel 2026-03-29 18:19:15 +02:00
parent 20a3e8b437
commit ed493cff29
Signed by: kfickel
GPG key ID: A793722F9933C1A5
72 changed files with 5684 additions and 3688 deletions

40
src/cli/args.rs Normal file
View file

@ -0,0 +1,40 @@
use clap::{Parser, Subcommand};
use clap_complete::Shell;
#[derive(Parser)]
#[command(name = "streamd")]
#[command(
author,
version,
about = "Personal knowledge management and time-tracking CLI using @Tag annotations"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Create a new stream file
New,
/// Display open tasks
Todo,
/// Edit a stream file by position
Edit {
/// Position of the file to edit (0 = most recent, negative = from oldest)
#[arg(default_value = "1")]
number: i32,
},
/// Display extracted timesheets
Timesheet,
/// Generate shell completions
Completions {
/// Shell to generate completions for
#[arg(value_enum)]
shell: Shell,
},
}

View file

@ -0,0 +1,11 @@
use clap::CommandFactory;
use clap_complete::{generate, Shell};
use std::io;
use crate::cli::Cli;
pub fn run(shell: Shell) {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut io::stdout());
}

73
src/cli/commands/edit.rs Normal file
View file

@ -0,0 +1,73 @@
use std::fs;
use std::process::Command;
use walkdir::WalkDir;
use crate::config::Settings;
use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
use crate::localize::{localize_stream_file, TaskConfiguration};
use crate::models::LocalizedShard;
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let file_name = path.to_string_lossy().to_string();
let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content);
if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) {
shards.push(shard);
}
}
}
Ok(shards)
}
pub fn run(number: i32) -> Result<(), StreamdError> {
let all_shards = all_files()?;
// Sort by moment (timestamp)
let mut sorted_shards = all_shards;
sorted_shards.sort_by_key(|s| s.moment);
if sorted_shards.is_empty() {
return Err(StreamdError::ConfigError("No files found".to_string()));
}
let selected_index = if number >= 0 {
// 0 = most recent, 1 = second most recent, etc.
let idx = sorted_shards.len() as i32 - number;
if idx < 0 {
return Err(StreamdError::ConfigError(
"Argument out of range".to_string(),
));
}
idx as usize
} else {
// -1 = oldest, -2 = second oldest, etc.
let idx = (-number - 1) as usize;
if idx >= sorted_shards.len() {
return Err(StreamdError::ConfigError(
"Argument out of range".to_string(),
));
}
idx
};
if let Some(file_path) = sorted_shards[selected_index].location.get("file") {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
Command::new(&editor).arg(file_path).status()?;
}
Ok(())
}

5
src/cli/commands/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod completions;
pub mod edit;
pub mod new;
pub mod timesheet;
pub mod todo;

60
src/cli/commands/new.rs Normal file
View file

@ -0,0 +1,60 @@
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use chrono::Local;
use crate::config::Settings;
use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
pub fn run() -> Result<(), StreamdError> {
let settings = Settings::load()?;
let streamd_directory = &settings.base_folder;
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let preliminary_file_name = format!("{}_wip.md", timestamp);
let preliminary_path = Path::new(streamd_directory).join(&preliminary_file_name);
// Create initial file with heading
let content = "# ";
let mut file = fs::File::create(&preliminary_path)?;
file.write_all(content.as_bytes())?;
drop(file);
// Open in editor
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let status = Command::new(&editor).arg(&preliminary_path).status()?;
if !status.success() {
return Err(StreamdError::IoError(std::io::Error::other(
"Editor exited with non-zero status",
)));
}
// Read the edited content
let edited_content = fs::read_to_string(&preliminary_path)?;
let parsed_content =
parse_markdown_file(preliminary_path.to_string_lossy().as_ref(), &edited_content);
// Determine final filename based on markers
let final_file_name = if let Some(ref shard) = parsed_content.shard {
if !shard.markers.is_empty() {
format!("{} {}.md", timestamp, shard.markers.join(" "))
} else {
format!("{}.md", timestamp)
}
} else {
format!("{}.md", timestamp)
};
let final_path = Path::new(streamd_directory).join(&final_file_name);
// Rename the file
fs::rename(&preliminary_path, &final_path)?;
println!("Saved as {}", final_file_name);
Ok(())
}

View file

@ -0,0 +1,52 @@
use std::fs;
use walkdir::WalkDir;
use crate::config::Settings;
use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
use crate::localize::localize_stream_file;
use crate::models::LocalizedShard;
use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration};
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let file_name = path.to_string_lossy().to_string();
let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content);
if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration) {
shards.push(shard);
}
}
}
Ok(shards)
}
pub fn run() -> Result<(), StreamdError> {
let all_shards = all_files()?;
let mut sheets = extract_timesheets(&all_shards)?;
sheets.sort_by_key(|s| s.date);
for sheet in sheets {
println!("{}", sheet.date);
let times: Vec<String> = sheet
.timecards
.iter()
.map(|card| format!("{},{}", card.from_time, card.to_time))
.collect();
println!("{}", times.join(","));
}
Ok(())
}

56
src/cli/commands/todo.rs Normal file
View file

@ -0,0 +1,56 @@
use std::fs;
use walkdir::WalkDir;
use crate::config::Settings;
use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
use crate::localize::{localize_stream_file, TaskConfiguration};
use crate::models::LocalizedShard;
use crate::query::find_shard_by_position;
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let file_name = path.to_string_lossy().to_string();
let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content);
if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) {
shards.push(shard);
}
}
}
Ok(shards)
}
pub fn run() -> Result<(), StreamdError> {
let all_shards = all_files()?;
for task_shard in find_shard_by_position(&all_shards, "task", "open") {
if let Some(file_path) = task_shard.location.get("file") {
let content = fs::read_to_string(file_path)?;
let lines: Vec<&str> = content.lines().collect();
let start = task_shard.start_line.saturating_sub(1);
let end = std::cmp::min(task_shard.end_line, lines.len());
println!("--- {}:{} ---", file_path, task_shard.start_line);
for line in &lines[start..end] {
println!("{}", line);
}
println!();
}
}
Ok(())
}

4
src/cli/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod args;
pub mod commands;
pub use args::{Cli, Commands};

44
src/config.rs Normal file
View file

@ -0,0 +1,44 @@
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
use crate::error::StreamdError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub base_folder: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
base_folder: env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string()),
}
}
}
impl Settings {
pub fn load() -> Result<Self, StreamdError> {
let config_path = Self::config_path();
if config_path.exists() {
let content = fs::read_to_string(&config_path)?;
let settings: Settings = serde_yaml::from_str(&content)?;
Ok(settings)
} else {
Ok(Settings::default())
}
}
fn config_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("", "", "streamd") {
proj_dirs.config_dir().join("config.yaml")
} else {
PathBuf::from("~/.config/streamd/config.yaml")
}
}
}

25
src/error.rs Normal file
View file

@ -0,0 +1,25 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StreamdError {
#[error("Could not extract date from file name: {0}")]
DateExtractionError(String),
#[error("Timesheet error: {0}")]
TimesheetError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("YAML error: {0}")]
YamlError(#[from] serde_yaml::Error),
}
impl From<StreamdError> for miette::Report {
fn from(err: StreamdError) -> Self {
miette::Report::msg(err.to_string())
}
}

5
src/extract/mod.rs Normal file
View file

@ -0,0 +1,5 @@
mod parser;
mod tag_extraction;
pub use parser::parse_markdown_file;
pub use tag_extraction::{extract_markers_and_tags, has_markers};

739
src/extract/parser.rs Normal file
View file

@ -0,0 +1,739 @@
use std::collections::HashMap;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use crate::extract::tag_extraction::{extract_markers_and_tags, has_markers};
use crate::models::{Shard, StreamFile};
/// Information about a block element.
#[derive(Debug, Clone)]
struct BlockInfo {
start_line: usize,
end_line: usize,
block_type: BlockType,
events: Vec<Event<'static>>,
}
#[derive(Debug, Clone, PartialEq)]
enum BlockType {
Paragraph,
Heading(usize),
List,
ListItem,
CodeBlock,
#[allow(dead_code)]
Other,
}
/// Build a shard, applying simplification rules.
/// If the shard has exactly one child with the same line range and no markers/tags,
/// return that child instead.
fn build_shard(
start_line: usize,
end_line: usize,
markers: Vec<String>,
tags: Vec<String>,
children: Vec<Shard>,
) -> Shard {
if children.len() == 1
&& tags.is_empty()
&& markers.is_empty()
&& children[0].start_line == start_line
&& children[0].end_line == end_line
{
return children.into_iter().next().unwrap();
}
Shard {
markers,
tags,
start_line,
end_line,
children,
}
}
/// Merge shards where the first one becomes the parent with its markers/tags preserved.
fn merge_into_first_shard(
mut shards: Vec<Shard>,
start_line: usize,
end_line: usize,
additional_tags: Vec<String>,
) -> Shard {
if shards.is_empty() {
return build_shard(start_line, end_line, vec![], additional_tags, vec![]);
}
let mut first = shards.remove(0);
first.start_line = start_line;
first.end_line = end_line;
first.children = shards;
first.tags.extend(additional_tags);
first
}
/// Parse a markdown file into a StreamFile with shard structure.
pub fn parse_markdown_file(file_name: &str, file_content: &str) -> StreamFile {
let line_count = std::cmp::max(file_content.lines().count(), 1);
let end_line = line_count;
// Handle empty file
if file_content.is_empty() {
return StreamFile {
file_name: file_name.to_string(),
shard: Some(Shard::new(1, 1)),
};
}
// Parse the markdown with offset tracking
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(file_content, options);
// Collect blocks with their line information
let blocks = collect_blocks(file_content, parser);
// Parse into shard structure
let shard = if blocks.is_empty() {
Shard::new(1, end_line)
} else {
parse_header_shards(&blocks, 1, end_line, false).unwrap_or_else(|| Shard::new(1, end_line))
};
StreamFile {
file_name: file_name.to_string(),
shard: Some(shard),
}
}
/// Collect block-level elements from the parser.
fn collect_blocks(content: &str, parser: Parser) -> Vec<BlockInfo> {
let mut blocks = Vec::new();
let mut current_block: Option<BlockInfo> = None;
let _current_events: Vec<Event<'static>> = Vec::new();
let mut depth = 0;
let mut list_items: Vec<BlockInfo> = Vec::new();
let mut in_list = false;
let mut list_start_line = 0;
// Pre-compute line starts for offset-to-line mapping
let line_starts: Vec<usize> = std::iter::once(0)
.chain(content.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let offset_to_line =
|offset: usize| -> usize { line_starts.partition_point(|&start| start <= offset) };
for (event, range) in parser.into_offset_iter() {
let line = offset_to_line(range.start);
match &event {
Event::Start(Tag::Paragraph) => {
if depth == 0 {
current_block = Some(BlockInfo {
start_line: line,
end_line: line,
block_type: BlockType::Paragraph,
events: Vec::new(),
});
}
depth += 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
}
}
Event::End(TagEnd::Paragraph) => {
depth -= 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
block.end_line = line;
}
if depth == 0 {
if let Some(block) = current_block.take() {
if in_list {
list_items.push(block);
} else {
blocks.push(block);
}
}
}
}
Event::Start(Tag::Heading { level, .. }) => {
let heading_level = heading_level_to_usize(*level);
if depth == 0 {
current_block = Some(BlockInfo {
start_line: line,
end_line: line,
block_type: BlockType::Heading(heading_level),
events: Vec::new(),
});
}
depth += 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
}
}
Event::End(TagEnd::Heading(_)) => {
depth -= 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
block.end_line = line;
}
if depth == 0 {
if let Some(block) = current_block.take() {
blocks.push(block);
}
}
}
Event::Start(Tag::List(_)) => {
if !in_list {
in_list = true;
list_start_line = line;
list_items.clear();
}
depth += 1;
}
Event::End(TagEnd::List(_)) => {
depth -= 1;
if depth == 0 && in_list {
in_list = false;
// Create a list block containing all list items
if !list_items.is_empty() {
blocks.push(BlockInfo {
start_line: list_start_line,
end_line: line,
block_type: BlockType::List,
events: vec![], // List events are handled through list_items
});
// Store list items for later processing
for item in list_items.drain(..) {
blocks.push(BlockInfo {
block_type: BlockType::ListItem,
..item
});
}
}
}
}
Event::Start(Tag::Item) => {
if in_list {
current_block = Some(BlockInfo {
start_line: line,
end_line: line,
block_type: BlockType::ListItem,
events: Vec::new(),
});
}
}
Event::End(TagEnd::Item) => {
if let Some(ref mut block) = current_block {
block.end_line = line;
}
if let Some(block) = current_block.take() {
list_items.push(block);
}
}
Event::Start(Tag::CodeBlock(_)) => {
if depth == 0 {
current_block = Some(BlockInfo {
start_line: line,
end_line: line,
block_type: BlockType::CodeBlock,
events: Vec::new(),
});
}
depth += 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
}
}
Event::End(TagEnd::CodeBlock) => {
depth -= 1;
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
block.end_line = line;
}
if depth == 0 {
if let Some(block) = current_block.take() {
blocks.push(block);
}
}
}
_ => {
if let Some(ref mut block) = current_block {
block.events.push(event.clone().into_static());
}
}
}
}
blocks
}
fn heading_level_to_usize(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
/// Check if a block has markers.
fn block_has_markers(block: &BlockInfo) -> bool {
has_markers(block.events.iter().cloned())
}
/// Extract markers and tags from a block.
fn extract_block_markers_and_tags(block: &BlockInfo) -> (Vec<String>, Vec<String>) {
extract_markers_and_tags(block.events.iter().cloned())
}
/// Find positions of paragraph blocks that have markers.
fn find_paragraph_shard_positions(blocks: &[BlockInfo]) -> Vec<usize> {
blocks
.iter()
.enumerate()
.filter(|(_, block)| block.block_type == BlockType::Paragraph && block_has_markers(block))
.map(|(i, _)| i)
.collect()
}
/// Find positions of headings at a specific level.
fn find_headings_by_level(blocks: &[BlockInfo], level: usize) -> Vec<usize> {
blocks
.iter()
.enumerate()
.filter(|(_, block)| matches!(block.block_type, BlockType::Heading(l) if l == level))
.map(|(i, _)| i)
.collect()
}
/// Calculate the heading level to split on for the next parsing step.
fn calculate_heading_level_for_next_split(blocks: &[BlockInfo]) -> Option<usize> {
// Find heading levels that have markers (excluding first block)
let levels_with_markers: Vec<usize> = blocks[1..]
.iter()
.filter_map(|block| {
if let BlockType::Heading(level) = block.block_type {
if block_has_markers(block) {
return Some(level);
}
}
None
})
.collect();
if levels_with_markers.is_empty() {
return None;
}
// Count headings at each level
let mut level_counts: HashMap<usize, usize> = HashMap::new();
for block in blocks {
if let BlockType::Heading(level) = block.block_type {
*level_counts.entry(level).or_insert(0) += 1;
}
}
// Return the minimum level that either:
// - Has count >= 2
// - Has a marker (excluding first block)
let levels_with_multiple: Vec<usize> = level_counts
.into_iter()
.filter(|(_, count)| *count >= 2)
.map(|(level, _)| level)
.collect();
let mut candidates = levels_with_multiple;
candidates.extend(levels_with_markers);
candidates.into_iter().min()
}
/// Split a slice at the given positions.
fn split_at<T: Clone>(items: &[T], positions: &[usize]) -> Vec<Vec<T>> {
let mut all_positions: Vec<usize> = vec![0];
all_positions.extend(positions.iter().cloned());
all_positions.push(items.len());
all_positions.sort();
all_positions.dedup();
all_positions
.windows(2)
.map(|window| items[window[0]..window[1]].to_vec())
.filter(|v| !v.is_empty())
.collect()
}
/// Parse blocks into shard hierarchy based on headings.
fn parse_header_shards(
blocks: &[BlockInfo],
start_line: usize,
end_line: usize,
use_first_child_as_header: bool,
) -> Option<Shard> {
if blocks.is_empty() {
return Some(build_shard(start_line, end_line, vec![], vec![], vec![]));
}
let split_at_heading_level = calculate_heading_level_for_next_split(blocks);
if split_at_heading_level.is_none() {
return parse_multiple_block_shards(blocks, start_line, end_line, true).0;
}
let heading_level = split_at_heading_level.unwrap();
let heading_positions = find_headings_by_level(blocks, heading_level);
let block_groups = split_at(blocks, &heading_positions);
let mut children = Vec::new();
for (i, group) in block_groups.iter().enumerate() {
if group.is_empty() {
continue;
}
let child_start_line = group[0].start_line;
let child_end_line = if i + 1 < block_groups.len() && !block_groups[i + 1].is_empty() {
block_groups[i + 1][0].start_line - 1
} else {
end_line
};
if let Some(child_shard) = parse_header_shards(
group,
child_start_line,
child_end_line,
i > 0 || heading_positions.contains(&0),
) {
children.push(child_shard);
}
}
if use_first_child_as_header && !children.is_empty() {
Some(merge_into_first_shard(
children,
start_line,
end_line,
vec![],
))
} else {
Some(build_shard(start_line, end_line, vec![], vec![], children))
}
}
/// Parse multiple blocks into shards.
fn parse_multiple_block_shards(
blocks: &[BlockInfo],
start_line: usize,
end_line: usize,
enforce_shard: bool,
) -> (Option<Shard>, Vec<String>) {
if blocks.is_empty() {
if enforce_shard {
return (
Some(build_shard(start_line, end_line, vec![], vec![], vec![])),
vec![],
);
}
return (None, vec![]);
}
let is_first_block_heading =
matches!(blocks[0].block_type, BlockType::Heading(_)) && block_has_markers(&blocks[0]);
let paragraph_positions = find_paragraph_shard_positions(blocks);
let mut children = Vec::new();
let mut tags = Vec::new();
let mut is_first_block_only_with_marker = false;
for (i, block) in blocks.iter().enumerate() {
if paragraph_positions.contains(&i) {
is_first_block_only_with_marker = i == 0;
}
let child_start_line = block.start_line;
let child_end_line = if i + 1 < blocks.len() {
blocks[i + 1].start_line - 1
} else {
end_line
};
let (child_shard, child_tags) =
parse_single_block_shard(block, child_start_line, child_end_line);
if let Some(shard) = child_shard {
children.push(shard);
}
tags.extend(child_tags);
}
if children.is_empty() && !enforce_shard {
return (None, tags);
}
if is_first_block_heading || is_first_block_only_with_marker {
(
Some(merge_into_first_shard(children, start_line, end_line, tags)),
vec![],
)
} else {
(
Some(build_shard(start_line, end_line, vec![], tags, children)),
vec![],
)
}
}
/// Parse a single block into a shard.
fn parse_single_block_shard(
block: &BlockInfo,
start_line: usize,
end_line: usize,
) -> (Option<Shard>, Vec<String>) {
match block.block_type {
BlockType::Paragraph | BlockType::Heading(_) => {
let (markers, tags) = extract_block_markers_and_tags(block);
if markers.is_empty() {
(None, tags)
} else {
(
Some(build_shard(start_line, end_line, markers, tags, vec![])),
vec![],
)
}
}
BlockType::List | BlockType::ListItem => {
// List handling is complex - for now, extract any markers/tags
let (markers, tags) = extract_block_markers_and_tags(block);
if markers.is_empty() {
(None, tags)
} else {
(
Some(build_shard(start_line, end_line, markers, tags, vec![])),
vec![],
)
}
}
_ => (None, vec![]),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_file_name() -> String {
"test.md".to_string()
}
#[test]
fn test_parse_empty_file() {
let result = parse_markdown_file(&make_file_name(), "");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard::new(1, 1)),
}
);
}
#[test]
fn test_parse_basic_one_line_file() {
let result = parse_markdown_file(&make_file_name(), "Hello World");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard::new(1, 1)),
}
);
}
#[test]
fn test_parse_basic_multi_line_file() {
let result = parse_markdown_file(&make_file_name(), "Hello World\n\nHello again!");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard::new(1, 3)),
}
);
}
#[test]
fn test_parse_single_line_with_tag() {
let result = parse_markdown_file(&make_file_name(), "@Tag Hello World");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["Tag".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_single_line_with_two_tags() {
let result = parse_markdown_file(&make_file_name(), "@Marker1 @Marker2 Hello World");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["Marker1".to_string(), "Marker2".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_single_line_with_two_tags_and_misplaced_tag() {
let result = parse_markdown_file(&make_file_name(), "@Tag1 @Tag2 Hello World @Tag3");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["Tag1".to_string(), "Tag2".to_string()],
tags: vec!["Tag3".to_string()],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_header_without_markers() {
let result = parse_markdown_file(&make_file_name(), "# Heading\n\n## Subheading");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard::new(1, 3)),
}
);
}
#[test]
fn test_parse_ignores_tags_in_code() {
let result = parse_markdown_file(&make_file_name(), "```\n@Marker\n```");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard::new(1, 3)),
}
);
}
#[test]
fn test_parse_finds_tags_in_italic_text() {
let result = parse_markdown_file(&make_file_name(), "*@ItalicMarker*");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["ItalicMarker".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_finds_tags_in_bold_text() {
let result = parse_markdown_file(&make_file_name(), "**@BoldMarker**");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["BoldMarker".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_finds_tags_in_strikethrough_text() {
let result = parse_markdown_file(&make_file_name(), "~~@StrikeMarker~~");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["StrikeMarker".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_finds_tags_in_link() {
let result = parse_markdown_file(&make_file_name(), "[@LinkMarker](https://example.com)");
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["LinkMarker".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
#[test]
fn test_parse_continues_looking_for_markers_after_first_link_marker() {
let result = parse_markdown_file(
&make_file_name(),
"[@LinkMarker1](https://example.com) [@LinkMarker2](https://example.com)",
);
assert_eq!(
result,
StreamFile {
file_name: make_file_name(),
shard: Some(Shard {
markers: vec!["LinkMarker1".to_string(), "LinkMarker2".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
}),
}
);
}
}

View file

@ -0,0 +1,219 @@
use once_cell::sync::Lazy;
use pulldown_cmark::{Event, Tag, TagEnd};
use regex::Regex;
/// Regex pattern for matching @Tags.
/// Matches @ followed by any characters except whitespace, *, `, ~, [, ]
static TAG_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"@([^\s*`~\[\]]+)").unwrap());
/// Token type for tag extraction state machine.
#[derive(Debug, Clone)]
enum Token {
Tag(String),
Content,
Whitespace,
}
/// Tokenizes text content into Tags, Content, and Whitespace tokens.
fn tokenize(text: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut last_end = 0;
for mat in TAG_PATTERN.find_iter(text) {
// Handle content before the match
let before = &text[last_end..mat.start()];
if !before.is_empty() {
if before.chars().all(|c| c.is_whitespace()) {
tokens.push(Token::Whitespace);
} else {
tokens.push(Token::Content);
}
}
// Extract the tag name (without the @)
let tag_name = &text[mat.start() + 1..mat.end()];
tokens.push(Token::Tag(tag_name.to_string()));
last_end = mat.end();
}
// Handle remaining content after last match
if last_end < text.len() {
let remaining = &text[last_end..];
if !remaining.is_empty() {
if remaining.chars().all(|c| c.is_whitespace()) {
tokens.push(Token::Whitespace);
} else {
tokens.push(Token::Content);
}
}
}
tokens
}
/// Extract markers and tags from a sequence of pulldown-cmark events.
///
/// Markers are @-prefixed identifiers that appear before any non-whitespace content.
/// Tags are @-prefixed identifiers that appear after content has started.
///
/// Returns (markers, tags).
pub fn extract_markers_and_tags<'a>(
events: impl Iterator<Item = Event<'a>>,
) -> (Vec<String>, Vec<String>) {
let mut markers = Vec::new();
let mut tags = Vec::new();
let mut boundary_crossed = false;
let mut in_code = false;
for event in events {
match event {
Event::Start(Tag::CodeBlock(_)) | Event::Start(Tag::MetadataBlock(_)) => {
in_code = true;
}
Event::End(TagEnd::CodeBlock) | Event::End(TagEnd::MetadataBlock(_)) => {
in_code = false;
}
Event::Code(_) => {
// Inline code is a content boundary but we don't extract tags from it
boundary_crossed = true;
}
Event::Text(text) | Event::InlineHtml(text) if !in_code => {
for token in tokenize(&text) {
match token {
Token::Whitespace => {}
Token::Tag(name) => {
if boundary_crossed {
tags.push(name);
} else {
markers.push(name);
}
}
Token::Content => {
boundary_crossed = true;
}
}
}
}
_ => {}
}
}
(markers, tags)
}
/// Check if the events contain any markers (tags before content).
pub fn has_markers<'a>(events: impl Iterator<Item = Event<'a>>) -> bool {
let (markers, _) = extract_markers_and_tags(events);
!markers.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use pulldown_cmark::Parser;
fn extract_from_text(text: &str) -> (Vec<String>, Vec<String>) {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(text, options);
extract_markers_and_tags(parser)
}
#[test]
fn test_extract_single_marker() {
let (markers, tags) = extract_from_text("@Tag Hello World");
assert_eq!(markers, vec!["Tag"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_two_markers() {
let (markers, tags) = extract_from_text("@Marker1 @Marker2 Hello World");
assert_eq!(markers, vec!["Marker1", "Marker2"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_markers_and_tags() {
let (markers, tags) = extract_from_text("@Tag1 @Tag2 Hello World @Tag3");
assert_eq!(markers, vec!["Tag1", "Tag2"]);
assert_eq!(tags, vec!["Tag3"]);
}
#[test]
fn test_extract_inner_tags() {
let (markers, tags) = extract_from_text("Hello @Tag1 World!");
assert!(markers.is_empty());
assert_eq!(tags, vec!["Tag1"]);
}
#[test]
fn test_extract_ignores_code_blocks() {
let (markers, tags) = extract_from_text("```\n@Marker\n```");
assert!(markers.is_empty());
assert!(tags.is_empty());
}
#[test]
fn test_extract_italic_marker() {
let (markers, tags) = extract_from_text("*@ItalicMarker*");
assert_eq!(markers, vec!["ItalicMarker"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_bold_marker() {
let (markers, tags) = extract_from_text("**@BoldMarker**");
assert_eq!(markers, vec!["BoldMarker"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_strikethrough_marker() {
let (markers, tags) = extract_from_text("~~@StrikeMarker~~");
assert_eq!(markers, vec!["StrikeMarker"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_link_marker() {
let (markers, tags) = extract_from_text("[@LinkMarker](https://example.com)");
assert_eq!(markers, vec!["LinkMarker"]);
assert!(tags.is_empty());
}
#[test]
fn test_extract_multiple_link_markers() {
let (markers, tags) = extract_from_text(
"[@LinkMarker1](https://example.com) [@LinkMarker2](https://example.com)",
);
assert_eq!(markers, vec!["LinkMarker1", "LinkMarker2"]);
assert!(tags.is_empty());
}
#[test]
fn test_has_markers_true() {
let parser = Parser::new("@Tag Hello");
assert!(has_markers(parser));
}
#[test]
fn test_has_markers_false() {
let parser = Parser::new("Hello @Tag");
assert!(!has_markers(parser));
}
#[test]
fn test_empty_text() {
let (markers, tags) = extract_from_text("");
assert!(markers.is_empty());
assert!(tags.is_empty());
}
#[test]
fn test_no_tags() {
let (markers, tags) = extract_from_text("Hello World");
assert!(markers.is_empty());
assert!(tags.is_empty());
}
}

14
src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
pub mod cli;
pub mod config;
pub mod error;
pub mod extract;
pub mod localize;
pub mod models;
pub mod query;
pub mod timesheet;
pub use error::StreamdError;
pub use models::{
Dimension, LocalizedShard, Marker, MarkerPlacement, RepositoryConfiguration, Shard,
SpecialDayType, StreamFile, Timecard, Timesheet,
};

View file

@ -0,0 +1,448 @@
use std::collections::BTreeSet;
use indexmap::IndexMap;
use crate::models::{Dimension, Marker, MarkerPlacement, RepositoryConfiguration};
/// Merge two dimensions, with the second taking precedence.
///
/// - display_name: second wins if non-empty, else base
/// - comment: second wins if not None, else base
/// - propagate: second wins if explicitly set, else base
pub fn merge_single_dimension(base: &Dimension, second: &Dimension) -> Dimension {
Dimension {
display_name: if second.display_name.is_empty() {
base.display_name.clone()
} else {
second.display_name.clone()
},
comment: if second.comment.is_some() {
second.comment.clone()
} else {
base.comment.clone()
},
propagate: if second.propagate_was_set {
second.propagate
} else {
base.propagate
},
propagate_was_set: second.propagate_was_set || base.propagate_was_set,
}
}
/// Merge two dimension maps.
pub fn merge_dimensions(
base: &IndexMap<String, Dimension>,
second: &IndexMap<String, Dimension>,
) -> IndexMap<String, Dimension> {
let mut merged = base.clone();
for (key, second_dim) in second {
if let Some(base_dim) = merged.get(key) {
merged.insert(key.clone(), merge_single_dimension(base_dim, second_dim));
} else {
merged.insert(key.clone(), second_dim.clone());
}
}
merged
}
/// Create a placement identity tuple for deduplication.
/// We use BTreeSet to make it hashable and order-independent.
fn placement_identity(p: &MarkerPlacement) -> (BTreeSet<String>, String) {
(p.if_with.iter().cloned().collect(), p.dimension.clone())
}
/// Merge two markers, with the second taking precedence.
pub fn merge_single_marker(base: &Marker, second: &Marker) -> Marker {
let display_name = if second.display_name.is_empty() {
base.display_name.clone()
} else {
second.display_name.clone()
};
let mut merged_placements: Vec<MarkerPlacement> = Vec::new();
let mut seen: IndexMap<(BTreeSet<String>, String), usize> = IndexMap::new();
for placement in &base.placements {
let ident = placement_identity(placement);
seen.insert(ident, merged_placements.len());
merged_placements.push(placement.clone());
}
for placement in &second.placements {
let ident = placement_identity(placement);
if let Some(&idx) = seen.get(&ident) {
merged_placements[idx] = placement.clone();
} else {
seen.insert(ident, merged_placements.len());
merged_placements.push(placement.clone());
}
}
Marker {
display_name,
placements: merged_placements,
}
}
/// Merge two marker maps.
pub fn merge_markers(
base: &IndexMap<String, Marker>,
second: &IndexMap<String, Marker>,
) -> IndexMap<String, Marker> {
let mut merged = base.clone();
for (key, second_marker) in second {
if let Some(base_marker) = merged.get(key) {
merged.insert(key.clone(), merge_single_marker(base_marker, second_marker));
} else {
merged.insert(key.clone(), second_marker.clone());
}
}
merged
}
/// Merge two repository configurations.
pub fn merge_repository_configuration(
base: &RepositoryConfiguration,
second: &RepositoryConfiguration,
) -> RepositoryConfiguration {
RepositoryConfiguration {
dimensions: merge_dimensions(&base.dimensions, &second.dimensions),
markers: merge_markers(&base.markers, &second.markers),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_second_overrides_display_name_when_non_empty() {
let base = Dimension::new("Base")
.with_comment("c1")
.with_propagate(true);
let second = Dimension::new("Second")
.with_comment("c2")
.with_propagate(false);
let merged = merge_single_dimension(&base, &second);
assert_eq!(merged.display_name, "Second");
assert_eq!(merged.comment, Some("c2".to_string()));
assert!(!merged.propagate);
}
#[test]
fn test_second_empty_display_name_falls_back_to_base() {
let base = Dimension::new("Base")
.with_comment("c1")
.with_propagate(true);
let second = Dimension::new("").with_comment("c2").with_propagate(false);
let merged = merge_single_dimension(&base, &second);
assert_eq!(merged.display_name, "Base");
assert_eq!(merged.comment, Some("c2".to_string()));
assert!(!merged.propagate);
}
#[test]
fn test_second_comment_none_does_not_erase_base_comment() {
let base = Dimension::new("Base")
.with_comment("keep")
.with_propagate(true);
let mut second = Dimension::new("Second");
second.propagate = false;
second.propagate_was_set = true;
let merged = merge_single_dimension(&base, &second);
assert_eq!(merged.display_name, "Second");
assert_eq!(merged.comment, Some("keep".to_string()));
}
#[test]
fn test_second_comment_non_none_overrides_base_comment() {
let base = Dimension::new("Base")
.with_comment("c1")
.with_propagate(true);
let second = Dimension::new("Second")
.with_comment("c2")
.with_propagate(true);
let merged = merge_single_dimension(&base, &second);
assert_eq!(merged.comment, Some("c2".to_string()));
}
#[test]
fn test_second_propagate_overrides_base_when_provided() {
let base = Dimension::new("Base")
.with_comment("c1")
.with_propagate(true);
let second = Dimension::new("Second")
.with_comment("c2")
.with_propagate(false);
let merged = merge_single_dimension(&base, &second);
assert!(!merged.propagate);
}
#[test]
fn test_propagate_merging_retains_base_when_second_not_provided() {
let base = Dimension::new("Base")
.with_comment("c1")
.with_propagate(true);
let second = Dimension::new("Second").with_comment("c2");
let merged = merge_single_dimension(&base, &second);
assert!(merged.propagate);
}
#[test]
fn test_adds_new_keys_from_second() {
let mut base = IndexMap::new();
base.insert("a".to_string(), Dimension::new("A").with_propagate(true));
let mut second = IndexMap::new();
second.insert("b".to_string(), Dimension::new("B").with_propagate(false));
let merged = merge_dimensions(&base, &second);
assert!(merged.contains_key("a"));
assert!(merged.contains_key("b"));
assert_eq!(merged["a"].display_name, "A");
assert_eq!(merged["b"].display_name, "B");
}
#[test]
fn test_merges_existing_keys() {
let mut base = IndexMap::new();
base.insert(
"a".to_string(),
Dimension::new("A").with_comment("c1").with_propagate(true),
);
let mut second = IndexMap::new();
second.insert("a".to_string(), Dimension::new("A2").with_propagate(false));
let merged = merge_dimensions(&base, &second);
assert_eq!(merged["a"].display_name, "A2");
assert_eq!(merged["a"].comment, Some("c1".to_string()));
assert!(!merged["a"].propagate);
}
#[test]
fn test_does_not_mutate_inputs() {
let mut base = IndexMap::new();
base.insert(
"a".to_string(),
Dimension::new("A").with_comment("c1").with_propagate(true),
);
let mut second = IndexMap::new();
second.insert(
"b".to_string(),
Dimension::new("B").with_comment("c2").with_propagate(false),
);
let merged = merge_dimensions(&base, &second);
assert!(!base.contains_key("b"));
assert!(!second.contains_key("a"));
assert!(merged.contains_key("a"));
assert!(merged.contains_key("b"));
}
#[test]
fn test_second_marker_overrides_display_name_when_non_empty() {
let base = Marker::new("Base").with_placements(vec![MarkerPlacement::new("project")]);
let second = Marker::new("Second")
.with_placements(vec![MarkerPlacement::new("timesheet").with_value("coding")]);
let merged = merge_single_marker(&base, &second);
assert_eq!(merged.display_name, "Second");
assert_eq!(merged.placements.len(), 2);
assert_eq!(merged.placements[0].dimension, "project");
assert_eq!(merged.placements[1].dimension, "timesheet");
}
#[test]
fn test_second_marker_empty_display_name_falls_back_to_base() {
let base = Marker::new("Base").with_placements(vec![]);
let second = Marker::new("").with_placements(vec![]);
let merged = merge_single_marker(&base, &second);
assert_eq!(merged.display_name, "Base");
}
#[test]
fn test_appends_new_placements() {
let base = Marker::new("Base").with_placements(vec![MarkerPlacement::new("project")]);
let second = Marker::new("Second").with_placements(vec![MarkerPlacement::new("timesheet")
.with_if_with(vec!["Timesheet"])
.with_value("x")]);
let merged = merge_single_marker(&base, &second);
assert_eq!(merged.placements.len(), 2);
assert_eq!(merged.placements[0].dimension, "project");
assert_eq!(merged.placements[1].dimension, "timesheet");
}
#[test]
fn test_deduplicates_by_identity_and_second_overrides_base() {
let base = Marker::new("Base").with_placements(vec![
MarkerPlacement::new("d")
.with_if_with(vec!["A"])
.with_value("v"),
MarkerPlacement::new("d")
.with_if_with(vec!["B"])
.with_value("v2"),
]);
let second = Marker::new("Second").with_placements(vec![
MarkerPlacement::new("d")
.with_if_with(vec!["A"])
.with_value("v"),
MarkerPlacement::new("d")
.with_if_with(vec!["C"])
.with_value("v3"),
]);
let merged = merge_single_marker(&base, &second);
assert_eq!(merged.placements.len(), 3);
// First placement (A, d) should be from second
assert_eq!(
merged.placements[0].if_with.iter().collect::<Vec<_>>(),
vec!["A"]
);
// Second placement (B, d) should be from base
assert_eq!(
merged.placements[1].if_with.iter().collect::<Vec<_>>(),
vec!["B"]
);
// Third placement (C, d) should be from second
assert_eq!(
merged.placements[2].if_with.iter().collect::<Vec<_>>(),
vec!["C"]
);
}
#[test]
fn test_identity_is_order_insensitive_for_if_with() {
let base = Marker::new("Base").with_placements(vec![MarkerPlacement::new("d")
.with_if_with(vec!["A", "B"])
.with_value("v")]);
let second = Marker::new("Second").with_placements(vec![MarkerPlacement::new("d")
.with_if_with(vec!["B", "A"])
.with_value("v2")]);
let merged = merge_single_marker(&base, &second);
// With if_with as a set, identity is order-insensitive; second overrides base.
assert_eq!(merged.placements.len(), 1);
assert_eq!(merged.placements[0].value, Some("v2".to_string()));
}
#[test]
fn test_adds_new_marker_keys_from_second() {
let mut base = IndexMap::new();
base.insert("M1".to_string(), Marker::new("M1").with_placements(vec![]));
let mut second = IndexMap::new();
second.insert("M2".to_string(), Marker::new("M2").with_placements(vec![]));
let merged = merge_markers(&base, &second);
assert!(merged.contains_key("M1"));
assert!(merged.contains_key("M2"));
}
#[test]
fn test_merges_existing_marker_keys() {
let mut base = IndexMap::new();
base.insert(
"M".to_string(),
Marker::new("Base").with_placements(vec![MarkerPlacement::new("project")]),
);
let mut second = IndexMap::new();
second.insert(
"M".to_string(),
Marker::new("Second").with_placements(vec![MarkerPlacement::new("timesheet")
.with_if_with(vec!["Timesheet"])
.with_value("coding")]),
);
let merged = merge_markers(&base, &second);
assert_eq!(merged["M"].display_name, "Second");
assert_eq!(merged["M"].placements.len(), 2);
}
#[test]
fn test_merge_repository_configuration() {
let base = RepositoryConfiguration::new()
.with_dimension(
"project",
Dimension::new("Project")
.with_comment("c1")
.with_propagate(true),
)
.with_dimension(
"moment",
Dimension::new("Moment")
.with_comment("c2")
.with_propagate(true),
)
.with_marker(
"Streamd",
Marker::new("Streamd").with_placements(vec![MarkerPlacement::new("project")]),
);
let second = RepositoryConfiguration::new()
.with_dimension("project", Dimension::new("Project2").with_propagate(false))
.with_dimension(
"timesheet",
Dimension::new("Timesheet")
.with_comment("c3")
.with_propagate(false),
)
.with_marker(
"Streamd",
Marker::new("Streamd2").with_placements(vec![MarkerPlacement::new("timesheet")
.with_if_with(vec!["Timesheet"])
.with_value("coding")]),
)
.with_marker(
"JobHunting",
Marker::new("JobHunting").with_placements(vec![MarkerPlacement::new("project")]),
);
let merged = merge_repository_configuration(&base, &second);
assert!(merged.dimensions.contains_key("project"));
assert!(merged.dimensions.contains_key("moment"));
assert!(merged.dimensions.contains_key("timesheet"));
assert_eq!(merged.dimensions["project"].display_name, "Project2");
assert_eq!(merged.dimensions["project"].comment, Some("c1".to_string()));
assert!(!merged.dimensions["project"].propagate);
assert_eq!(merged.dimensions["moment"].display_name, "Moment");
assert_eq!(merged.dimensions["timesheet"].display_name, "Timesheet");
assert!(merged.markers.contains_key("Streamd"));
assert!(merged.markers.contains_key("JobHunting"));
assert_eq!(merged.markers["Streamd"].display_name, "Streamd2");
assert_eq!(merged.markers["Streamd"].placements.len(), 2);
}
}

365
src/localize/datetime.rs Normal file
View file

@ -0,0 +1,365 @@
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
/// Regex for extracting date and optional time from file names.
/// Format: YYYYMMDD or YYYYMMDD-HHMMSS (time can be 4-6 digits)
static FILE_NAME_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(?P<date>\d{8})(?:-(?P<time>\d{4,6}))?.+\.md$").unwrap());
/// Regex for validating datetime marker format (14 digits).
static DATETIME_MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{14}$").unwrap());
/// Regex for validating date marker format (8 digits).
static DATE_MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{8}$").unwrap());
/// Regex for validating time marker format (6 digits).
static TIME_MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{6}$").unwrap());
/// Extract a datetime from a file name in the format YYYYMMDD-HHMMSS.
///
/// The time component is optional and can be 4-6 digits (HHMM, HHMMS, or HHMMSS).
///
/// # Examples
/// - "20230101-123456 Some Text.md" -> DateTime for 2023-01-01 12:34:56
/// - "20230101 Some Text.md" -> DateTime for 2023-01-01 00:00:00
/// - "invalid-file-name.md" -> None
pub fn extract_datetime_from_file_name(file_name: &str) -> Option<DateTime<Utc>> {
let base_name = Path::new(file_name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(file_name);
let captures = FILE_NAME_REGEX.captures(base_name)?;
let date_str = captures.name("date")?.as_str();
let time_str = captures.name("time").map(|m| m.as_str()).unwrap_or("");
// Pad time string to 6 digits
let time_str = format!("{:0<6}", time_str);
let datetime_str = format!(
"{} {}:{}:{}",
date_str,
&time_str[0..2],
&time_str[2..4],
&time_str[4..6]
);
NaiveDateTime::parse_from_str(&datetime_str, "%Y%m%d %H:%M:%S")
.ok()
.map(|dt| dt.and_utc())
}
/// Extract a datetime from a marker string in the exact format: YYYYMMDDHHMMSS.
///
/// Returns the parsed datetime if the format matches and values are valid.
pub fn extract_datetime_from_marker(marker: &str) -> Option<DateTime<Utc>> {
if !DATETIME_MARKER_REGEX.is_match(marker) {
return None;
}
NaiveDateTime::parse_from_str(marker, "%Y%m%d%H%M%S")
.ok()
.map(|dt| dt.and_utc())
}
/// Extract a date from a marker string in the exact format: YYYYMMDD.
///
/// Returns the parsed date if the format matches and values are valid.
pub fn extract_date_from_marker(marker: &str) -> Option<NaiveDate> {
if !DATE_MARKER_REGEX.is_match(marker) {
return None;
}
NaiveDate::parse_from_str(marker, "%Y%m%d").ok()
}
/// Extract a time from a marker string in the exact format: HHMMSS.
///
/// Returns the parsed time if the format matches and values are valid.
pub fn extract_time_from_marker(marker: &str) -> Option<NaiveTime> {
if !TIME_MARKER_REGEX.is_match(marker) {
return None;
}
NaiveTime::parse_from_str(marker, "%H%M%S").ok()
}
/// Extract a datetime from a list of markers, using an inherited datetime as fallback.
///
/// The function processes markers in reverse order, allowing later markers to override
/// earlier ones. It combines date-only and time-only markers when both are present.
///
/// Rules:
/// - If a full datetime marker (14 digits) is found, it sets both date and time
/// - If only a date marker is found, the time defaults to midnight
/// - If only a time marker is found, the date is inherited
/// - If no valid markers are found, the inherited datetime is returned
pub fn extract_datetime_from_marker_list(
markers: &[String],
inherited_datetime: DateTime<Utc>,
) -> DateTime<Utc> {
let mut shard_time: Option<NaiveTime> = None;
let mut shard_date: Option<NaiveDate> = None;
// Process markers in reverse order (last wins)
for marker in markers.iter().rev() {
if let Some(time) = extract_time_from_marker(marker) {
shard_time = Some(time);
}
if let Some(date) = extract_date_from_marker(marker) {
shard_date = Some(date);
}
if let Some(datetime) = extract_datetime_from_marker(marker) {
shard_date = Some(datetime.naive_utc().date());
shard_time = Some(datetime.naive_utc().time());
}
}
// Combine date and time, applying defaults as needed
let final_date = shard_date.unwrap_or_else(|| inherited_datetime.naive_utc().date());
let final_time = match (shard_date, shard_time) {
// If we have a date but no time, use midnight
(Some(_), None) => NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
// Otherwise use the shard time or inherit
_ => shard_time.unwrap_or_else(|| inherited_datetime.naive_utc().time()),
};
NaiveDateTime::new(final_date, final_time).and_utc()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_extract_date_from_file_name_valid() {
let file_name = "20230101-123456 Some Text.md";
assert_eq!(
extract_datetime_from_file_name(file_name),
Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 56).unwrap())
);
}
#[test]
fn test_extract_date_from_file_name_invalid() {
let file_name = "invalid-file-name.md";
assert_eq!(extract_datetime_from_file_name(file_name), None);
}
#[test]
fn test_extract_date_from_file_name_without_time() {
let file_name = "20230101 Some Text.md";
assert_eq!(
extract_datetime_from_file_name(file_name),
Some(Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap())
);
}
#[test]
fn test_extract_date_from_file_name_short_time() {
let file_name = "20230101-1234 Some Text.md";
assert_eq!(
extract_datetime_from_file_name(file_name),
Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 0).unwrap())
);
}
#[test]
fn test_extract_date_from_file_name_empty_string() {
let file_name = "";
assert_eq!(extract_datetime_from_file_name(file_name), None);
}
#[test]
fn test_extract_date_from_file_name_with_full_path() {
let file_name = "/path/to/20230101-123456 Some Text.md";
assert_eq!(
extract_datetime_from_file_name(file_name),
Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 56).unwrap())
);
}
#[test]
fn test_extract_datetime_from_marker_valid() {
let marker = "20250101150000";
assert_eq!(
extract_datetime_from_marker(marker),
Some(Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap())
);
}
#[test]
fn test_extract_datetime_from_marker_invalid_format() {
assert_eq!(extract_datetime_from_marker("2025010115000"), None); // too short
assert_eq!(extract_datetime_from_marker("202501011500000"), None); // too long
assert_eq!(extract_datetime_from_marker("2025-01-01T150000"), None); // separators
assert_eq!(extract_datetime_from_marker("2025010115000a"), None); // non-digit
assert_eq!(extract_datetime_from_marker(""), None);
}
#[test]
fn test_extract_datetime_from_marker_invalid_values() {
assert_eq!(extract_datetime_from_marker("20250230120000"), None); // Feb 30
assert_eq!(extract_datetime_from_marker("20250101126000"), None); // minute 60
assert_eq!(extract_datetime_from_marker("20250101240000"), None); // hour 24
}
#[test]
fn test_extract_date_from_marker_valid() {
let marker = "20250101";
assert_eq!(
extract_date_from_marker(marker),
Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap())
);
}
#[test]
fn test_extract_date_from_marker_invalid_format() {
assert_eq!(extract_date_from_marker("2025010"), None); // too short
assert_eq!(extract_date_from_marker("202501011"), None); // too long
assert_eq!(extract_date_from_marker("2025-01-01"), None); // separators
assert_eq!(extract_date_from_marker("2025010a"), None); // non-digit
assert_eq!(extract_date_from_marker(""), None);
}
#[test]
fn test_extract_date_from_marker_invalid_values() {
assert_eq!(extract_date_from_marker("20250230"), None); // Feb 30
assert_eq!(extract_date_from_marker("20251301"), None); // month 13
assert_eq!(extract_date_from_marker("20250132"), None); // day 32
}
#[test]
fn test_extract_time_from_marker_valid() {
let marker = "150000";
assert_eq!(
extract_time_from_marker(marker),
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
);
}
#[test]
fn test_extract_time_from_marker_invalid_format() {
assert_eq!(extract_time_from_marker("15000"), None); // too short
assert_eq!(extract_time_from_marker("1500000"), None); // too long
assert_eq!(extract_time_from_marker("15:00:00"), None); // separators
assert_eq!(extract_time_from_marker("15000a"), None); // non-digit
assert_eq!(extract_time_from_marker(""), None);
}
#[test]
fn test_extract_time_from_marker_invalid_values() {
assert_eq!(extract_time_from_marker("240000"), None); // hour 24
assert_eq!(extract_time_from_marker("156000"), None); // minute 60
// Note: chrono allows leap seconds (60), so 150060 is valid
}
#[test]
fn test_no_markers_inherits_datetime() {
let inherited = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap();
assert_eq!(extract_datetime_from_marker_list(&[], inherited), inherited);
}
#[test]
fn test_unrelated_markers_inherits_datetime() {
let inherited = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap();
let markers: Vec<String> = vec![
"not-a-marker".to_string(),
"2025-01-01".to_string(),
"1500".to_string(),
"1234567".to_string(),
];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
inherited
);
}
#[test]
fn test_date_only_marker_sets_midnight() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101".to_string()];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
);
}
#[test]
fn test_time_only_marker_inherits_date() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["150000".to_string()];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 6, 7, 15, 0, 0).unwrap()
);
}
#[test]
fn test_datetime_marker_overrides_both_date_and_time() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101150000".to_string()];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
);
}
#[test]
fn test_combined_date_and_time_markers() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec!["20250101".to_string(), "150000".to_string()];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
);
}
#[test]
fn test_first_marker_wins_when_multiple_dates_or_times() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec![
"20250101".to_string(),
"150000".to_string(),
"20250102".to_string(),
"160000".to_string(),
];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
);
}
#[test]
fn test_last_separated_date_and_time_win() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec![
"20250101".to_string(),
"150000".to_string(),
"20250102160000".to_string(),
];
// The first date (20250101) and first time (150000) should win over the later combined datetime
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
);
}
#[test]
fn test_invalid_date_or_time_markers_are_ignored() {
let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap();
let markers = vec![
"20251301".to_string(), // invalid month
"240000".to_string(), // invalid hour
"20250101".to_string(), // valid
"150000".to_string(), // valid
];
assert_eq!(
extract_datetime_from_marker_list(&markers, inherited),
Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()
);
}
}

15
src/localize/mod.rs Normal file
View file

@ -0,0 +1,15 @@
mod configuration;
mod datetime;
mod preconfigured;
mod shard;
pub use configuration::{
merge_dimensions, merge_markers, merge_repository_configuration, merge_single_dimension,
merge_single_marker,
};
pub use datetime::{
extract_date_from_marker, extract_datetime_from_file_name, extract_datetime_from_marker,
extract_datetime_from_marker_list, extract_time_from_marker,
};
pub use preconfigured::TaskConfiguration;
pub use shard::{localize_shard, localize_stream_file};

View file

@ -0,0 +1,46 @@
use once_cell::sync::Lazy;
use crate::models::{Dimension, Marker, MarkerPlacement, RepositoryConfiguration};
/// Pre-configured repository configuration for task management.
#[allow(non_upper_case_globals)]
pub static TaskConfiguration: Lazy<RepositoryConfiguration> = Lazy::new(|| {
RepositoryConfiguration::new()
.with_dimension(
"task",
Dimension::new("Task")
.with_comment(
"If placed, the given shard is a task. The placement determines the state.",
)
.with_propagate(false),
)
.with_dimension(
"project",
Dimension::new("Project")
.with_comment("Project the task is attached to")
.with_propagate(true),
)
.with_marker(
"Task",
Marker::new("Task").with_placements(vec![
MarkerPlacement::new("task").with_value("open"),
MarkerPlacement::new("task")
.with_if_with(vec!["Done"])
.with_value("done"),
MarkerPlacement::new("task")
.with_if_with(vec!["Waiting"])
.with_value("waiting"),
MarkerPlacement::new("task")
.with_if_with(vec!["Cancelled"])
.with_value("cancelled"),
MarkerPlacement::new("task")
.with_if_with(vec!["NotDone"])
.with_value("cancelled"),
]),
)
.with_marker(
"WaitingFor",
Marker::new("Task")
.with_placements(vec![MarkerPlacement::new("task").with_value("waiting")]),
)
});

282
src/localize/shard.rs Normal file
View file

@ -0,0 +1,282 @@
use chrono::{DateTime, Utc};
use indexmap::{IndexMap, IndexSet};
use crate::error::StreamdError;
use crate::models::{LocalizedShard, RepositoryConfiguration, Shard, StreamFile};
use super::datetime::{extract_datetime_from_file_name, extract_datetime_from_marker_list};
/// Localize a shard within the repository's coordinate system.
///
/// This function:
/// 1. Extracts datetime from markers
/// 2. Applies marker placements to determine dimensional position
/// 3. Propagates dimensional values to children based on dimension configuration
pub fn localize_shard(
shard: &Shard,
config: &RepositoryConfiguration,
propagated: &IndexMap<String, String>,
moment: DateTime<Utc>,
) -> LocalizedShard {
let mut position = propagated.clone();
let mut private_position: IndexMap<String, String> = IndexMap::new();
// Extract datetime from markers
let adjusted_moment = extract_datetime_from_marker_list(&shard.markers, moment);
// Convert markers to a set for if_with checking
let marker_set: IndexSet<String> = shard.markers.iter().cloned().collect();
// Process each marker and its placements
for marker in &shard.markers {
if let Some(marker_def) = config.markers.get(marker) {
for placement in &marker_def.placements {
// Check if_with condition
if !placement.if_with.is_subset(&marker_set) {
continue;
}
// Get the dimension configuration
let dimension = match config.dimensions.get(&placement.dimension) {
Some(d) => d,
None => continue,
};
let value = placement.value.clone().unwrap_or_else(|| marker.clone());
// Check if we should place the value
let should_place = placement.overwrites
|| (!position.contains_key(&placement.dimension)
&& !private_position.contains_key(&placement.dimension));
if should_place {
if dimension.propagate {
position.insert(placement.dimension.clone(), value);
} else {
private_position.insert(placement.dimension.clone(), value);
}
}
}
}
}
// Recursively localize children with propagated position
let children: Vec<LocalizedShard> = shard
.children
.iter()
.map(|child| localize_shard(child, config, &position, adjusted_moment))
.collect();
// Merge private position into final position
position.extend(private_position);
LocalizedShard {
markers: shard.markers.clone(),
tags: shard.tags.clone(),
start_line: shard.start_line,
end_line: shard.end_line,
moment: adjusted_moment,
location: position,
children,
}
}
/// Localize an entire stream file.
///
/// Extracts the datetime from the file name and localizes the root shard.
pub fn localize_stream_file(
stream_file: &StreamFile,
config: &RepositoryConfiguration,
) -> Result<LocalizedShard, StreamdError> {
let shard_date = extract_datetime_from_file_name(&stream_file.file_name)
.ok_or_else(|| StreamdError::DateExtractionError(stream_file.file_name.clone()))?;
let shard = stream_file
.shard
.as_ref()
.ok_or_else(|| StreamdError::DateExtractionError("No shard in file".to_string()))?;
let mut initial_location = IndexMap::new();
initial_location.insert("file".to_string(), stream_file.file_name.clone());
Ok(localize_shard(shard, config, &initial_location, shard_date))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Dimension, Marker, MarkerPlacement};
use chrono::TimeZone;
fn make_config() -> RepositoryConfiguration {
RepositoryConfiguration::new()
.with_dimension(
"project",
Dimension::new("Project")
.with_comment("GTD Project that is being worked on")
.with_propagate(true),
)
.with_dimension(
"moment",
Dimension::new("Moment")
.with_comment("Timestamp this entry was created at")
.with_propagate(true),
)
.with_dimension(
"timesheet",
Dimension::new("Timesheet")
.with_comment("Time Cards for Time Tracking")
.with_propagate(true),
)
.with_marker(
"Streamd",
Marker::new("Streamd").with_placements(vec![
MarkerPlacement::new("project"),
MarkerPlacement::new("timesheet")
.with_if_with(vec!["Timesheet"])
.with_value("coding"),
]),
)
.with_marker(
"JobHunting",
Marker::new("JobHunting").with_placements(vec![MarkerPlacement::new("project")]),
)
}
#[test]
fn test_project_simple_stream_file() {
let config = make_config();
let stream_file = StreamFile::new("20250622-121000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["Streamd".to_string()]));
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(
result.moment,
Utc.with_ymd_and_hms(2025, 6, 22, 12, 10, 0).unwrap()
);
assert_eq!(result.markers, vec!["Streamd"]);
assert_eq!(result.location.get("project"), Some(&"Streamd".to_string()));
assert_eq!(
result.location.get("file"),
Some(&stream_file.file_name.clone())
);
}
#[test]
fn test_timesheet_use_case() {
let config = make_config();
let stream_file = StreamFile::new("20260131-210000 Test File.md").with_shard(
Shard::new(1, 1).with_markers(vec!["Timesheet".to_string(), "Streamd".to_string()]),
);
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(
result.moment,
Utc.with_ymd_and_hms(2026, 1, 31, 21, 0, 0).unwrap()
);
assert_eq!(result.location.get("project"), Some(&"Streamd".to_string()));
assert_eq!(
result.location.get("timesheet"),
Some(&"coding".to_string())
);
}
#[test]
fn test_overwrites_true_propagated_dimension_overwrites_existing_value() {
let config = RepositoryConfiguration::new()
.with_dimension("project", Dimension::new("Project").with_propagate(true))
.with_marker(
"A",
Marker::new("A")
.with_placements(vec![MarkerPlacement::new("project").with_value("a")]),
)
.with_marker(
"B",
Marker::new("B").with_placements(vec![MarkerPlacement::new("project")
.with_value("b")
.with_overwrites(true)]),
);
let stream_file = StreamFile::new("20260131-210000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()]));
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(result.location.get("project"), Some(&"b".to_string()));
}
#[test]
fn test_overwrites_false_propagated_dimension_does_not_overwrite_existing_value() {
let config = RepositoryConfiguration::new()
.with_dimension("project", Dimension::new("Project").with_propagate(true))
.with_marker(
"A",
Marker::new("A")
.with_placements(vec![MarkerPlacement::new("project").with_value("a")]),
)
.with_marker(
"B",
Marker::new("B").with_placements(vec![MarkerPlacement::new("project")
.with_value("b")
.with_overwrites(false)]),
);
let stream_file = StreamFile::new("20260131-210000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()]));
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(result.location.get("project"), Some(&"a".to_string()));
}
#[test]
fn test_overwrites_true_non_propagated_dimension_overwrites_private_value() {
let config = RepositoryConfiguration::new()
.with_dimension("label", Dimension::new("Label").with_propagate(false))
.with_marker(
"A",
Marker::new("A")
.with_placements(vec![MarkerPlacement::new("label").with_value("a")]),
)
.with_marker(
"B",
Marker::new("B").with_placements(vec![MarkerPlacement::new("label")
.with_value("b")
.with_overwrites(true)]),
);
let stream_file = StreamFile::new("20260131-210000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()]));
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(result.location.get("label"), Some(&"b".to_string()));
}
#[test]
fn test_overwrites_false_non_propagated_dimension_does_not_overwrite_private_value() {
let config = RepositoryConfiguration::new()
.with_dimension("label", Dimension::new("Label").with_propagate(false))
.with_marker(
"A",
Marker::new("A").with_placements(vec![MarkerPlacement::new("label")
.with_value("a")
.with_overwrites(true)]),
)
.with_marker(
"B",
Marker::new("B").with_placements(vec![MarkerPlacement::new("label")
.with_value("b")
.with_overwrites(false)]),
);
let stream_file = StreamFile::new("20260131-210000 Test File.md")
.with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()]));
let result = localize_stream_file(&stream_file, &config).unwrap();
assert_eq!(result.location.get("label"), Some(&"a".to_string()));
}
}

19
src/main.rs Normal file
View file

@ -0,0 +1,19 @@
use clap::Parser;
use streamd::cli::{Cli, Commands};
fn main() -> miette::Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::New) => streamd::cli::commands::new::run()?,
Some(Commands::Todo) => streamd::cli::commands::todo::run()?,
Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?,
Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?,
Some(Commands::Completions { shell }) => {
streamd::cli::commands::completions::run(shell);
}
None => streamd::cli::commands::new::run()?,
}
Ok(())
}

42
src/models/dimension.rs Normal file
View file

@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
/// A Dimension represents an axis along which shards can be categorized.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dimension {
/// Human-readable name for display purposes.
pub display_name: String,
/// Optional description of what this dimension represents.
#[serde(default)]
pub comment: Option<String>,
/// Whether values in this dimension should propagate to child shards.
#[serde(default)]
pub propagate: bool,
/// Tracks whether 'propagate' was explicitly set (for merge semantics).
#[serde(skip)]
pub propagate_was_set: bool,
}
impl Dimension {
pub fn new(display_name: impl Into<String>) -> Self {
Self {
display_name: display_name.into(),
comment: None,
propagate: false,
propagate_was_set: false,
}
}
pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
self.comment = Some(comment.into());
self
}
pub fn with_propagate(mut self, propagate: bool) -> Self {
self.propagate = propagate;
self.propagate_was_set = true;
self
}
}

View file

@ -0,0 +1,63 @@
use chrono::{DateTime, Utc};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
/// A LocalizedShard extends a Shard with temporal and dimensional context.
/// It represents a shard that has been placed within the repository's coordinate system.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedShard {
/// Markers are tags that appear at the beginning of a line before any content.
pub markers: Vec<String>,
/// Tags are @-prefixed identifiers that appear after content has started.
pub tags: Vec<String>,
/// The starting line number in the source file (1-indexed).
pub start_line: usize,
/// The ending line number in the source file (1-indexed).
pub end_line: usize,
/// The moment in time this shard is associated with.
pub moment: DateTime<Utc>,
/// The dimensional location of this shard (dimension name -> value).
pub location: IndexMap<String, String>,
/// Child shards nested within this shard.
pub children: Vec<LocalizedShard>,
}
impl LocalizedShard {
pub fn new(start_line: usize, end_line: usize, moment: DateTime<Utc>) -> Self {
Self {
markers: Vec::new(),
tags: Vec::new(),
start_line,
end_line,
moment,
location: IndexMap::new(),
children: Vec::new(),
}
}
pub fn with_markers(mut self, markers: Vec<String>) -> Self {
self.markers = markers;
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_location(mut self, location: IndexMap<String, String>) -> Self {
self.location = location;
self
}
pub fn with_children(mut self, children: Vec<LocalizedShard>) -> Self {
self.children = children;
self
}
}

76
src/models/marker.rs Normal file
View file

@ -0,0 +1,76 @@
use indexmap::IndexSet;
use serde::{Deserialize, Serialize};
/// A MarkerPlacement defines how a marker affects dimension values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MarkerPlacement {
/// Only apply this placement if all markers in `if_with` are also present.
#[serde(default)]
pub if_with: IndexSet<String>,
/// The dimension to place a value in.
pub dimension: String,
/// The value to place. If None, uses the marker name itself.
#[serde(default)]
pub value: Option<String>,
/// Whether this placement should overwrite existing values in the dimension.
#[serde(default = "default_overwrites")]
pub overwrites: bool,
}
fn default_overwrites() -> bool {
true
}
impl MarkerPlacement {
pub fn new(dimension: impl Into<String>) -> Self {
Self {
if_with: IndexSet::new(),
dimension: dimension.into(),
value: None,
overwrites: true,
}
}
pub fn with_if_with(mut self, if_with: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.if_with = if_with.into_iter().map(Into::into).collect();
self
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn with_overwrites(mut self, overwrites: bool) -> Self {
self.overwrites = overwrites;
self
}
}
/// A Marker defines how an @-tag should be interpreted for dimensional placement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Marker {
/// Human-readable name for display purposes.
pub display_name: String,
/// The dimensional placements this marker creates.
#[serde(default)]
pub placements: Vec<MarkerPlacement>,
}
impl Marker {
pub fn new(display_name: impl Into<String>) -> Self {
Self {
display_name: display_name.into(),
placements: Vec::new(),
}
}
pub fn with_placements(mut self, placements: Vec<MarkerPlacement>) -> Self {
self.placements = placements;
self
}
}

11
src/models/mod.rs Normal file
View file

@ -0,0 +1,11 @@
mod dimension;
mod localized_shard;
mod marker;
mod shard;
mod timecard;
pub use dimension::Dimension;
pub use localized_shard::LocalizedShard;
pub use marker::{Marker, MarkerPlacement};
pub use shard::{RepositoryConfiguration, Shard, StreamFile};
pub use timecard::{SpecialDayType, Timecard, Timesheet};

115
src/models/shard.rs Normal file
View file

@ -0,0 +1,115 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::{Dimension, Marker};
/// A Shard represents a section of a markdown file that may contain markers and tags.
/// Shards form a tree structure where children inherit context from their parents.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Shard {
/// Markers are tags that appear at the beginning of a line before any content.
/// They define dimensional placement for the shard.
#[serde(default)]
pub markers: Vec<String>,
/// Tags are @-prefixed identifiers that appear after content has started.
/// They are informational but don't affect dimensional placement.
#[serde(default)]
pub tags: Vec<String>,
/// The starting line number in the source file (1-indexed).
pub start_line: usize,
/// The ending line number in the source file (1-indexed).
pub end_line: usize,
/// Child shards nested within this shard.
#[serde(default)]
pub children: Vec<Shard>,
}
impl Shard {
pub fn new(start_line: usize, end_line: usize) -> Self {
Self {
markers: Vec::new(),
tags: Vec::new(),
start_line,
end_line,
children: Vec::new(),
}
}
pub fn with_markers(mut self, markers: Vec<String>) -> Self {
self.markers = markers;
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_children(mut self, children: Vec<Shard>) -> Self {
self.children = children;
self
}
}
/// A StreamFile represents a parsed markdown file with its associated shard tree.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StreamFile {
/// The file name or path of the source file.
pub file_name: String,
/// The root shard representing the entire file's content structure.
pub shard: Option<Shard>,
}
impl StreamFile {
pub fn new(file_name: impl Into<String>) -> Self {
Self {
file_name: file_name.into(),
shard: None,
}
}
pub fn with_shard(mut self, shard: Shard) -> Self {
self.shard = Some(shard);
self
}
}
/// Repository configuration defines the dimensions and markers used to organize shards.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryConfiguration {
/// Dimensions define the axes along which shards can be positioned.
pub dimensions: IndexMap<String, Dimension>,
/// Markers define how @-tags map to dimension placements.
pub markers: IndexMap<String, Marker>,
}
impl RepositoryConfiguration {
pub fn new() -> Self {
Self {
dimensions: IndexMap::new(),
markers: IndexMap::new(),
}
}
pub fn with_dimension(mut self, name: impl Into<String>, dimension: Dimension) -> Self {
self.dimensions.insert(name.into(), dimension);
self
}
pub fn with_marker(mut self, name: impl Into<String>, marker: Marker) -> Self {
self.markers.insert(name.into(), marker);
self
}
}
impl Default for RepositoryConfiguration {
fn default() -> Self {
Self::new()
}
}

77
src/models/timecard.rs Normal file
View file

@ -0,0 +1,77 @@
use chrono::NaiveDate;
use chrono::NaiveTime;
use serde::{Deserialize, Serialize};
/// Type of special day that affects timesheet calculations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpecialDayType {
#[serde(rename = "VACATION")]
Vacation,
#[serde(rename = "UNDERTIME")]
Undertime,
#[serde(rename = "HOLIDAY")]
Holiday,
#[serde(rename = "WEEKEND")]
Weekend,
}
impl std::fmt::Display for SpecialDayType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SpecialDayType::Vacation => write!(f, "VACATION"),
SpecialDayType::Undertime => write!(f, "UNDERTIME"),
SpecialDayType::Holiday => write!(f, "HOLIDAY"),
SpecialDayType::Weekend => write!(f, "WEEKEND"),
}
}
}
/// A Timecard represents a single work period with start and end times.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Timecard {
pub from_time: NaiveTime,
pub to_time: NaiveTime,
}
impl Timecard {
pub fn new(from_time: NaiveTime, to_time: NaiveTime) -> Self {
Self { from_time, to_time }
}
}
/// A Timesheet aggregates all time tracking information for a single day.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Timesheet {
pub date: NaiveDate,
#[serde(default)]
pub is_sick_leave: bool,
#[serde(default)]
pub special_day_type: Option<SpecialDayType>,
pub timecards: Vec<Timecard>,
}
impl Timesheet {
pub fn new(date: NaiveDate) -> Self {
Self {
date,
is_sick_leave: false,
special_day_type: None,
timecards: Vec::new(),
}
}
pub fn with_sick_leave(mut self, is_sick_leave: bool) -> Self {
self.is_sick_leave = is_sick_leave;
self
}
pub fn with_special_day_type(mut self, special_day_type: SpecialDayType) -> Self {
self.special_day_type = Some(special_day_type);
self
}
pub fn with_timecards(mut self, timecards: Vec<Timecard>) -> Self {
self.timecards = timecards;
self
}
}

209
src/query/find.rs Normal file
View file

@ -0,0 +1,209 @@
use crate::models::LocalizedShard;
/// Find all shards matching a predicate, recursively searching through children.
///
/// The search is depth-first, with the parent tested before its children.
pub fn find_shard<F>(shards: &[LocalizedShard], predicate: F) -> Vec<LocalizedShard>
where
F: Fn(&LocalizedShard) -> bool + Copy,
{
let mut found_shards = Vec::new();
for shard in shards {
if predicate(shard) {
found_shards.push(shard.clone());
}
found_shards.extend(find_shard(&shard.children, predicate));
}
found_shards
}
/// Find all shards where a specific dimension has a specific value.
pub fn find_shard_by_position(
shards: &[LocalizedShard],
dimension: &str,
value: &str,
) -> Vec<LocalizedShard> {
find_shard(shards, |shard| {
shard
.location
.get(dimension)
.map(|v| v == value)
.unwrap_or(false)
})
}
/// Find all shards where a specific dimension is set (regardless of value).
pub fn find_shard_by_set_dimension(
shards: &[LocalizedShard],
dimension: &str,
) -> Vec<LocalizedShard> {
find_shard(shards, |shard| shard.location.contains_key(dimension))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use indexmap::IndexMap;
fn generate_localized_shard(
location: Option<IndexMap<String, String>>,
children: Option<Vec<LocalizedShard>>,
) -> LocalizedShard {
LocalizedShard {
start_line: 1,
end_line: 1,
moment: Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(),
location: location.unwrap_or_default(),
children: children.unwrap_or_default(),
markers: vec![],
tags: vec![],
}
}
#[test]
fn test_returns_empty_when_no_match() {
let mut loc = IndexMap::new();
loc.insert("file".to_string(), "a.md".to_string());
let root = generate_localized_shard(Some(loc), None);
let shards = vec![root];
let result = find_shard(&shards, |s| s.location.contains_key("missing"));
assert!(result.is_empty());
}
#[test]
fn test_finds_matches_depth_first_and_preserves_order() {
let mut loc1 = IndexMap::new();
loc1.insert("k".to_string(), "match".to_string());
let grandchild = generate_localized_shard(Some(loc1.clone()), None);
let child1 = generate_localized_shard(Some(loc1), Some(vec![grandchild.clone()]));
let mut loc2 = IndexMap::new();
loc2.insert("k".to_string(), "nope".to_string());
let child2 = generate_localized_shard(Some(loc2.clone()), None);
let root = generate_localized_shard(Some(loc2), Some(vec![child1.clone(), child2]));
let result = find_shard(&[root], |s| {
s.location.get("k") == Some(&"match".to_string())
});
assert_eq!(result.len(), 2);
assert_eq!(result[0], child1);
assert_eq!(result[1], grandchild);
}
#[test]
fn test_includes_root_if_it_matches() {
let mut loc = IndexMap::new();
loc.insert("k".to_string(), "match".to_string());
let child = generate_localized_shard(Some(loc.clone()), None);
let root = generate_localized_shard(Some(loc), Some(vec![child]));
let result = find_shard(std::slice::from_ref(&root), |s| {
s.location.get("k") == Some(&"match".to_string())
});
assert_eq!(result[0], root);
assert_eq!(result.len(), 2);
}
#[test]
fn test_multiple_roots_keeps_left_to_right_order() {
let mut loc_match = IndexMap::new();
loc_match.insert("k".to_string(), "match".to_string());
let mut loc_nope = IndexMap::new();
loc_nope.insert("k".to_string(), "nope".to_string());
let a = generate_localized_shard(Some(loc_match.clone()), None);
let b = generate_localized_shard(Some(loc_match), None);
let c = generate_localized_shard(Some(loc_nope), None);
let result = find_shard(&[a.clone(), b.clone(), c], |s| {
s.location.get("k") == Some(&"match".to_string())
});
assert_eq!(result, vec![a, b]);
}
#[test]
fn test_query_function_can_use_arbitrary_logic() {
let mut loc1 = IndexMap::new();
loc1.insert("x".to_string(), "1".to_string());
let mut loc2 = IndexMap::new();
loc2.insert("x".to_string(), "2".to_string());
let mut loc3 = IndexMap::new();
loc3.insert("x".to_string(), "3".to_string());
let a = generate_localized_shard(Some(loc1), None);
let b = generate_localized_shard(Some(loc2), None);
let c = generate_localized_shard(Some(loc3), None);
let root = generate_localized_shard(None, Some(vec![a, b.clone(), c]));
let result = find_shard(&[root], |shard| {
shard
.location
.get("x")
.and_then(|x| x.parse::<i32>().ok())
.map(|x| x % 2 == 0)
.unwrap_or(false)
});
assert_eq!(result, vec![b]);
}
#[test]
fn test_matches_only_when_dimension_present_and_equal() {
let mut loc_match = IndexMap::new();
loc_match.insert("file".to_string(), "a.md".to_string());
loc_match.insert("line".to_string(), "10".to_string());
let mut loc_wrong = IndexMap::new();
loc_wrong.insert("file".to_string(), "a.md".to_string());
loc_wrong.insert("line".to_string(), "11".to_string());
let mut loc_missing = IndexMap::new();
loc_missing.insert("file".to_string(), "a.md".to_string());
let match_shard = generate_localized_shard(Some(loc_match), None);
let wrong_value = generate_localized_shard(Some(loc_wrong), None);
let missing_dim = generate_localized_shard(Some(loc_missing), None);
let mut root_loc = IndexMap::new();
root_loc.insert("root".to_string(), "x".to_string());
let root = generate_localized_shard(
Some(root_loc),
Some(vec![match_shard.clone(), wrong_value, missing_dim]),
);
let result = find_shard_by_position(&[root], "line", "10");
assert_eq!(result, vec![match_shard]);
}
#[test]
fn test_recurses_through_children() {
let mut loc_deep = IndexMap::new();
loc_deep.insert("section".to_string(), "s1".to_string());
let deep = generate_localized_shard(Some(loc_deep), None);
let mut loc_mid = IndexMap::new();
loc_mid.insert("section".to_string(), "s0".to_string());
let mid = generate_localized_shard(Some(loc_mid), Some(vec![deep.clone()]));
let root = generate_localized_shard(None, Some(vec![mid]));
let result = find_shard_by_position(&[root], "section", "s1");
assert_eq!(result, vec![deep]);
}
}

3
src/query/mod.rs Normal file
View file

@ -0,0 +1,3 @@
mod find;
pub use find::{find_shard, find_shard_by_position, find_shard_by_set_dimension};

View file

@ -1,126 +0,0 @@
import glob
import os
from collections.abc import Generator
from datetime import datetime
from shutil import move
from typing import Annotated
import click
import typer
from rich import print
from rich.markdown import Markdown
from rich.panel import Panel
from streamd.localize import (
LocalizedShard,
RepositoryConfiguration,
localize_stream_file,
)
from streamd.localize.preconfigured_configurations import TaskConfiguration
from streamd.parse import parse_markdown_file
from streamd.query import find_shard_by_position
from streamd.settings import Settings
from streamd.timesheet.configuration import BasicTimesheetConfiguration
from streamd.timesheet.extract import extract_timesheets
app = typer.Typer()
def all_files(config: RepositoryConfiguration) -> Generator[LocalizedShard]:
for file_name in glob.glob(f"{glob.escape(Settings().base_folder)}/*.md"):
with open(file_name, "r") as file:
file_content = file.read()
if shard := localize_stream_file(
parse_markdown_file(file_name, file_content), config
):
yield shard
@app.command()
def todo() -> None:
all_shards = list(all_files(TaskConfiguration))
for task_shard in find_shard_by_position(all_shards, "task", "open"):
with open(task_shard.location["file"], "r") as file:
file_content = file.read().splitlines()
print(
Panel(
Markdown(
"\n".join(
file_content[
task_shard.start_line - 1 : task_shard.end_line
]
)
),
title=f"{task_shard.location['file']}:{task_shard.start_line}",
)
)
@app.command()
def edit(number: Annotated[int, typer.Argument()] = 1) -> None:
all_shards = list(all_files(TaskConfiguration))
sorted_shards = sorted(all_shards, key=lambda s: s.moment)
if abs(number) >= len(sorted_shards):
raise ValueError("Argument out of range")
selected_number = number
if selected_number >= 0:
selected_number = len(sorted_shards) - selected_number
else:
selected_number = -selected_number
click.edit(None, filename=sorted_shards[selected_number].location["file"])
@app.command()
def timesheet() -> None:
all_shards = list(all_files(BasicTimesheetConfiguration))
sheets = sorted(extract_timesheets(all_shards), key=lambda card: card.date)
for sheet in sheets:
print(sheet.date)
print(
",".join(
map(lambda card: f"{card.from_time},{card.to_time}", sheet.timecards)
),
)
@app.command()
def new() -> None:
streamd_directory = Settings().base_folder
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
preliminary_file_name = f"{timestamp}_wip.md"
prelimary_path = os.path.join(streamd_directory, preliminary_file_name)
content = "# "
with open(prelimary_path, "w") as file:
_ = file.write(content)
click.edit(None, filename=prelimary_path)
with open(prelimary_path, "r") as file:
content = file.read()
parsed_content = parse_markdown_file(prelimary_path, content)
final_file_name = f"{timestamp}.md"
if parsed_content.shard is not None and len(
markers := parsed_content.shard.markers
):
final_file_name = f"{timestamp} {' '.join(markers)}.md"
final_path = os.path.join(streamd_directory, final_file_name)
_ = move(prelimary_path, final_path)
print(f"Saved as [yellow]{final_file_name}")
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
if ctx.invoked_subcommand is None:
new()
if __name__ == "__main__":
app()

View file

@ -1,9 +0,0 @@
from .localize import localize_stream_file
from .localized_shard import LocalizedShard
from .repository_configuration import RepositoryConfiguration
__all__ = [
"RepositoryConfiguration",
"localize_stream_file",
"LocalizedShard",
]

View file

@ -1,92 +0,0 @@
import os
import re
from datetime import date, datetime, time
def extract_datetime_from_file_name(file_name: str) -> datetime | None:
FILE_NAME_REGEX = r"^(?P<date>\d{8})(?:-(?P<time>\d{4,6}))?.+.md$"
base_name = os.path.basename(file_name)
match = re.match(FILE_NAME_REGEX, base_name)
if match:
date_str = match.group("date")
time_str = match.group("time") or ""
time_str = time_str.ljust(6, "0")
datetime_str = f"{date_str} {time_str[:2]}:{time_str[2:4]}:{time_str[4:]}"
return datetime.strptime(datetime_str, "%Y%m%d %H:%M:%S")
return None
def extract_datetime_from_marker(marker: str) -> datetime | None:
"""
Extract a datetime from a marker string in the exact format: YYYYMMDDHHMMSS.
Returns:
Parsed datetime if the format is fulfilled and values are valid, else None.
"""
if not re.fullmatch(r"\d{14}", marker or ""):
return None
try:
return datetime.strptime(marker, "%Y%m%d%H%M%S")
except ValueError:
return None
def extract_date_from_marker(marker: str) -> date | None:
"""
Extract a date from a marker string in the exact format: YYYYMMDD.
Returns:
Parsed date if the format is fulfilled and values are valid, else None.
"""
if not re.fullmatch(r"\d{8}", marker or ""):
return None
try:
return datetime.strptime(marker, "%Y%m%d").date()
except ValueError:
return None
def extract_time_from_marker(marker: str) -> time | None: # noqa: F821
"""
Extract a time from a marker string in the exact format: HHMMSS.
Returns:
Parsed time if the format is fulfilled and values are valid, else None.
"""
if not re.fullmatch(r"\d{6}", marker or ""):
return None
try:
return datetime.strptime(marker, "%H%M%S").time()
except ValueError:
return None
def extract_datetime_from_marker_list(markers: list[str], inherited_datetime: datetime):
shard_time: time | None = None
shard_date: date | None = None
for marker in markers[::-1]:
if parsed_time := extract_time_from_marker(marker):
shard_time = parsed_time
if parsed_date := extract_date_from_marker(marker):
shard_date = parsed_date
if parsed_datetime := extract_datetime_from_marker(marker):
shard_date = parsed_datetime.date()
shard_time = parsed_datetime.time()
if shard_date and not shard_time:
return datetime.combine(shard_date, time(0, 0, 0))
return datetime.combine(
shard_date or inherited_datetime.date(), shard_time or inherited_datetime.time()
)
__all__ = [
"extract_datetime_from_file_name",
"extract_datetime_from_marker",
"extract_date_from_marker",
"extract_time_from_marker",
"extract_datetime_from_marker_list",
]

View file

@ -1,73 +0,0 @@
from datetime import datetime
from streamd.parse.shard import Shard, StreamFile
from .extract_datetime import (
extract_datetime_from_file_name,
extract_datetime_from_marker_list,
)
from .localized_shard import LocalizedShard
from .repository_configuration import RepositoryConfiguration
def localize_shard(
shard: Shard,
config: RepositoryConfiguration,
propagated: dict[str, str],
moment: datetime,
) -> LocalizedShard:
position = {**propagated}
private_position: dict[str, str] = {}
adjusted_moment: datetime = extract_datetime_from_marker_list(shard.markers, moment)
for marker in shard.markers:
if marker in config.markers:
marker_definition = config.markers[marker]
for placement in marker_definition.placements:
if placement.if_with <= set(shard.markers):
dimension = config.dimensions[placement.dimension]
value = placement.value or marker
if placement.overwrites or (
placement.dimension not in position
and placement.dimension not in private_position
):
if dimension.propagate:
position[placement.dimension] = value
else:
private_position[placement.dimension] = value
children = [
localize_shard(child, config, position, adjusted_moment)
for child in shard.children
]
position.update(private_position)
return LocalizedShard(
markers=shard.markers,
tags=shard.tags,
start_line=shard.start_line,
end_line=shard.end_line,
location=position,
children=children,
moment=adjusted_moment,
)
def localize_stream_file(
stream_file: StreamFile, config: RepositoryConfiguration
) -> LocalizedShard | None:
shard_date = extract_datetime_from_file_name(stream_file.file_name)
if not shard_date or not stream_file.shard:
raise ValueError("Could not extract date")
return localize_shard(
stream_file.shard, config, {"file": stream_file.file_name}, shard_date
)
__all__ = ["localize_stream_file"]

View file

@ -1,14 +0,0 @@
from __future__ import annotations
from datetime import datetime
from streamd.parse.shard import Shard
class LocalizedShard(Shard):
moment: datetime
location: dict[str, str]
children: list[LocalizedShard] = [] # pyright: ignore[reportIncompatibleVariableOverride]
__all__ = ["LocalizedShard"]

View file

@ -1,43 +0,0 @@
from streamd.localize.repository_configuration import (
Dimension,
Marker,
MarkerPlacement,
RepositoryConfiguration,
)
TaskConfiguration = RepositoryConfiguration(
dimensions={
"task": Dimension(
display_name="Task",
comment="If placed, the given shard is a task. The placement determines the state.",
propagate=False,
),
"project": Dimension(
display_name="Project",
comment="Project the task is attached to",
propagate=True,
),
},
markers={
"Task": Marker(
display_name="Task",
placements=[
MarkerPlacement(dimension="task", value="open"),
MarkerPlacement(if_with={"Done"}, dimension="task", value="done"),
MarkerPlacement(if_with={"Waiting"}, dimension="task", value="waiting"),
MarkerPlacement(
if_with={"Cancelled"}, dimension="task", value="cancelled"
),
MarkerPlacement(
if_with={"NotDone"}, dimension="task", value="cancelled"
),
],
),
"WaitingFor": Marker(
display_name="Task",
placements=[
MarkerPlacement(dimension="task", value="waiting"),
],
),
},
)

View file

@ -1,106 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel
class Dimension(BaseModel):
display_name: str
comment: str | None = None
propagate: bool = False
class MarkerPlacement(BaseModel):
if_with: set[str] = set()
dimension: str
value: str | None = None
overwrites: bool = True
class Marker(BaseModel):
display_name: str
placements: list[MarkerPlacement] = []
class RepositoryConfiguration(BaseModel):
dimensions: dict[str, Dimension]
markers: dict[str, Marker]
def merge_single_dimension(base: Dimension, second: Dimension) -> Dimension:
second_fields_set: set[str] = getattr(second, "model_fields_set", set())
return Dimension(
display_name=second.display_name or base.display_name,
comment=base.comment if second.comment is None else second.comment,
propagate=second.propagate
if "propagate" in second_fields_set
else base.propagate,
)
def merge_dimensions(
base: dict[str, Dimension], second: dict[str, Dimension]
) -> dict[str, Dimension]:
merged: dict[str, Dimension] = dict(base)
for key, second_dimension in second.items():
if key in merged:
merged[key] = merge_single_dimension(merged[key], second_dimension)
else:
merged[key] = second_dimension
return merged
def _placement_identity(p: MarkerPlacement) -> tuple[frozenset[str], str]:
return (frozenset(p.if_with), p.dimension)
def merge_single_marker(base: Marker, second: Marker) -> Marker:
merged_display_name = second.display_name or base.display_name
merged_placements: list[MarkerPlacement] = []
seen: dict[tuple[frozenset[str], str], int] = {}
for placement in base.placements:
ident = _placement_identity(placement)
seen[ident] = len(merged_placements)
merged_placements.append(placement)
for placement in second.placements:
ident = _placement_identity(placement)
if ident in seen:
merged_placements[seen[ident]] = placement
else:
seen[ident] = len(merged_placements)
merged_placements.append(placement)
return Marker(display_name=merged_display_name, placements=merged_placements)
def merge_markers(
base: dict[str, Marker], second: dict[str, Marker]
) -> dict[str, Marker]:
merged: dict[str, Marker] = dict(base)
for key, second_marker in second.items():
if key in merged:
merged[key] = merge_single_marker(merged[key], second_marker)
else:
merged[key] = second_marker
return merged
def merge_repository_configuration(
base: RepositoryConfiguration, second: RepositoryConfiguration
) -> RepositoryConfiguration:
return RepositoryConfiguration(
dimensions=merge_dimensions(base.dimensions, second.dimensions),
markers=merge_markers(base.markers, second.markers),
)
__all__ = [
"Dimension",
"Marker",
"MarkerPlacement",
"RepositoryConfiguration",
"merge_repository_configuration",
]

View file

@ -1,4 +0,0 @@
from .shard import Shard, StreamFile
from .parse import parse_markdown_file
__all__ = ["Shard", "StreamFile", "parse_markdown_file"]

View file

@ -1,92 +0,0 @@
import re
from collections.abc import Iterable
from typing import cast
from mistletoe.block_token import BlockToken
from mistletoe.span_token import Emphasis, Link, RawText, Strikethrough, Strong
from mistletoe.token import Token
from .markdown_tag import Tag
def extract_markers_and_tags_from_single_token(
token: Token,
marker_boundary_encountered: bool,
return_at_first_marker: bool = False,
) -> tuple[list[str], list[str], bool]:
result_markers: list[str] = []
result_tags: list[str] = []
result_marker_boundary_encountered = marker_boundary_encountered
if isinstance(token, Tag):
content = cast(str, token.content)
if marker_boundary_encountered:
result_tags.append(content)
else:
result_markers.append(content)
elif isinstance(token, (Emphasis, Strong, Strikethrough, Link)):
children = list(token.children or [])
markers, tags, child_marker_boundary_encountered = (
extract_markers_and_tags_from_tokens(
children,
marker_boundary_encountered,
return_at_first_marker,
)
)
result_markers.extend(markers)
result_tags.extend(tags)
result_marker_boundary_encountered = (
marker_boundary_encountered or child_marker_boundary_encountered
)
elif isinstance(token, RawText):
content_raw = cast(str, token.content)
if not re.match(r"^[\s]*$", content_raw):
result_marker_boundary_encountered = True
else:
result_marker_boundary_encountered = True
return result_markers, result_tags, result_marker_boundary_encountered
def extract_markers_and_tags_from_tokens(
tokens: Iterable[Token],
marker_boundary_encountered: bool,
return_at_first_marker: bool = False,
) -> tuple[list[str], list[str], bool]:
result_markers: list[str] = []
result_tags: list[str] = []
result_marker_boundary_encountered = marker_boundary_encountered
for child in tokens:
markers, tags, child_marker_boundary_encountered = (
extract_markers_and_tags_from_single_token(
child, result_marker_boundary_encountered, return_at_first_marker
)
)
result_markers.extend(markers)
result_tags.extend(tags)
result_marker_boundary_encountered = (
marker_boundary_encountered or child_marker_boundary_encountered
)
if len(result_markers) > 0 and return_at_first_marker:
break
return result_markers, result_tags, result_marker_boundary_encountered
def extract_markers_and_tags(block_token: BlockToken) -> tuple[list[str], list[str]]:
children = list(block_token.children or [])
markers, tags, _ = extract_markers_and_tags_from_tokens(children, False)
return markers, tags
def has_markers(block_token: BlockToken) -> bool:
children = list(block_token.children or [])
markers, _, _ = extract_markers_and_tags_from_tokens(
children, False, return_at_first_marker=True
)
return len(markers) > 0
__all__ = ["extract_markers_and_tags", "has_markers"]

View file

@ -1,13 +0,0 @@
from itertools import pairwise
from typing import TypeVar
A = TypeVar("A")
def split_at(list_to_be_split: list[A], positions: list[int]):
positions = sorted(set([0, *positions, len(list_to_be_split)]))
return [list_to_be_split[left:right] for left, right in pairwise(positions)]
__all__ = ["split_at"]

View file

@ -1,23 +0,0 @@
import re
from typing import cast
from mistletoe.markdown_renderer import Fragment, MarkdownRenderer
from mistletoe.span_token import SpanToken
class Tag(SpanToken):
parse_inner: bool = False
pattern: re.Pattern[str] = re.compile(r"@([^\s*\x60~\[\]]+)")
class TagMarkdownRenderer(MarkdownRenderer):
def __init__(self) -> None:
super().__init__(Tag) # pyright: ignore[reportUnknownMemberType]
def render_tag(self, token: Tag):
content = cast(str, token.content)
yield Fragment("@")
yield Fragment(content)
__all__ = ["Tag", "TagMarkdownRenderer"]

View file

@ -1,258 +0,0 @@
from collections import Counter
from typing import cast
from mistletoe.block_token import (
BlockToken,
Document,
Heading,
List,
ListItem,
Paragraph,
)
from .extract_tag import extract_markers_and_tags, has_markers
from .list import split_at
from .markdown_tag import TagMarkdownRenderer
from .shard import Shard, StreamFile
def get_line_number(block_token: BlockToken) -> int:
return cast(int, block_token.line_number) # pyright: ignore[reportAttributeAccessIssue]
def build_shard(
start_line: int,
end_line: int,
markers: list[str] | None = None,
tags: list[str] | None = None,
children: list[Shard] | None = None,
) -> Shard:
markers = markers or []
tags = tags or []
children = children or []
if (
len(children) == 1
and len(tags) == 0
and len(markers) == 0
and children[0].start_line == start_line
and children[0].end_line == end_line
):
return children[0]
return Shard(
markers=markers,
tags=tags,
children=children,
start_line=start_line,
end_line=end_line,
)
def merge_into_first_shard(
shards: list[Shard],
start_line: int,
end_line: int,
additional_tags: list[str] | None = None,
) -> Shard:
return shards[0].model_copy(
update={
"start_line": start_line,
"end_line": end_line,
"children": shards[1:],
"tags": shards[0].tags + (additional_tags or []),
}
)
def find_paragraph_shard_positions(block_tokens: list[BlockToken]) -> list[int]:
return [
index
for index, block_token in enumerate(block_tokens)
if isinstance(block_token, Paragraph) and has_markers(block_token)
]
def _heading_level(heading: Heading) -> int:
return cast(int, heading.level)
def find_headings_by_level(
block_tokens: list[BlockToken], header_level: int
) -> list[int]:
return [
index
for index, block_token in enumerate(block_tokens)
if isinstance(block_token, Heading)
and _heading_level(block_token) == header_level
]
def calculate_heading_level_for_next_split(
block_tokens: list[BlockToken],
) -> int | None:
"""
If there is no marker in any heading, then return None.
If only the first token is a heading with a marker, then return None.
Otherwise: Return the heading level with the lowest level (h1 < h2), of which there are two or which has a marker (and doesn't stem from first)
"""
level_of_headings_without_first_with_marker: list[int] = [
_heading_level(token)
for token in block_tokens[1:]
if isinstance(token, Heading) and has_markers(token)
]
if len(level_of_headings_without_first_with_marker) == 0:
return None
heading_level_counter: Counter[int] = Counter(
[_heading_level(token) for token in block_tokens if isinstance(token, Heading)]
)
return min(
[level for level, count in heading_level_counter.items() if count >= 2]
+ level_of_headings_without_first_with_marker
)
def parse_single_block_shards(
block_token: BlockToken, start_line: int, end_line: int
) -> tuple[Shard | None, list[str]]:
markers: list[str] = []
tags: list[str] = []
children: list[Shard] = []
if isinstance(block_token, List):
list_items: list[ListItem] = ( # pyright: ignore[reportAssignmentType]
list(block_token.children) if block_token.children is not None else []
)
for index, list_item in enumerate(list_items):
list_item_start_line = get_line_number(list_item)
list_item_end_line = (
get_line_number(list_items[index + 1]) - 1
if index + 1 < len(list_items)
else end_line
)
list_item_shard, list_item_tags = parse_multiple_block_shards(
list_item.children, # pyright: ignore[reportArgumentType]
list_item_start_line,
list_item_end_line,
)
if list_item_shard is not None:
children.append(list_item_shard)
tags.extend(list_item_tags)
elif isinstance(block_token, (Paragraph, Heading)):
markers, tags = extract_markers_and_tags(block_token)
if len(markers) == 0 and len(children) == 0:
return None, tags
return build_shard(
start_line, end_line, markers=markers, tags=tags, children=children
), []
def parse_multiple_block_shards(
block_tokens: list[BlockToken],
start_line: int,
end_line: int,
enforce_shard: bool = False,
) -> tuple[Shard | None, list[str]]:
is_first_block_heading = isinstance(block_tokens[0], Heading) and has_markers(
block_tokens[0]
)
paragraph_positions = find_paragraph_shard_positions(block_tokens)
children: list[Shard] = []
tags: list[str] = []
is_first_block_only_with_marker = False
for i, token in enumerate(block_tokens):
if i in paragraph_positions:
is_first_block_only_with_marker = i == 0
child_start_line = get_line_number(token)
child_end_line = (
get_line_number(block_tokens[i + 1]) - 1
if i + 1 < len(block_tokens)
else end_line
)
child_shard, child_tags = parse_single_block_shards(
token, child_start_line, child_end_line
)
if child_shard is not None:
children.append(child_shard)
if len(child_tags) > 0:
tags.extend(child_tags)
if len(children) == 0 and not enforce_shard:
return None, tags
if is_first_block_heading or is_first_block_only_with_marker:
return merge_into_first_shard(children, start_line, end_line, tags), []
else:
return build_shard(start_line, end_line, tags=tags, children=children), []
def parse_header_shards(
block_tokens: list[BlockToken],
start_line: int,
end_line: int,
use_first_child_as_header: bool = False,
) -> Shard | None:
if len(block_tokens) == 0:
return build_shard(start_line, end_line)
split_at_heading_level = calculate_heading_level_for_next_split(block_tokens)
if split_at_heading_level is None:
return parse_multiple_block_shards(
block_tokens, start_line, end_line, enforce_shard=True
)[0]
heading_positions = find_headings_by_level(block_tokens, split_at_heading_level)
block_tokens_split_by_heading = split_at(block_tokens, heading_positions)
children: list[Shard] = []
for i, child_blocks in enumerate(block_tokens_split_by_heading):
child_start_line = get_line_number(child_blocks[0])
child_end_line = (
get_line_number(block_tokens_split_by_heading[i + 1][0]) - 1
if i + 1 < len(block_tokens_split_by_heading)
else end_line
)
if child_shard := parse_header_shards(
child_blocks,
child_start_line,
child_end_line,
use_first_child_as_header=i > 0 or 0 in heading_positions,
):
children.append(child_shard)
if use_first_child_as_header and len(children) > 0:
return merge_into_first_shard(children, start_line, end_line)
else:
return build_shard(start_line, end_line, children=children)
def parse_markdown_file(file_name: str, file_content: str) -> StreamFile:
shard = build_shard(1, max([len(file_content.splitlines()), 1]))
with TagMarkdownRenderer():
ast = Document(file_content)
block_tokens: list[BlockToken] = ast.children # pyright: ignore[reportAssignmentType]
if len(block_tokens) > 0:
if parsed_shard := parse_header_shards(
block_tokens, shard.start_line, shard.end_line
):
shard = parsed_shard
return StreamFile(shard=shard, file_name=file_name)
__all__ = ["Shard", "StreamFile", "parse_markdown_file"]

View file

@ -1,19 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel
class Shard(BaseModel):
markers: list[str] = []
tags: list[str] = []
start_line: int
end_line: int
children: list[Shard] = []
class StreamFile(BaseModel):
file_name: str
shard: Shard | None = None
__all__ = ["Shard", "StreamFile"]

View file

@ -1,3 +0,0 @@
from .find import find_shard, find_shard_by_position
__all__ = ["find_shard_by_position", "find_shard"]

View file

@ -1,36 +0,0 @@
from typing import Callable
from streamd.localize import LocalizedShard
def find_shard(
shards: list[LocalizedShard], query_function: Callable[[LocalizedShard], bool]
) -> list[LocalizedShard]:
found_shards: list[LocalizedShard] = []
for shard in shards:
if query_function(shard):
found_shards.append(shard)
found_shards.extend(find_shard(shard.children, query_function))
return found_shards
def find_shard_by_position(
shards: list[LocalizedShard], dimension: str, value: str
) -> list[LocalizedShard]:
return find_shard(
shards,
lambda shard: (
dimension in shard.location and shard.location[dimension] == value
),
)
def find_shard_by_set_dimension(
shards: list[LocalizedShard], dimension: str
) -> list[LocalizedShard]:
return find_shard(shards, lambda shard: dimension in shard.location)
__all__ = ["find_shard_by_position", "find_shard", "find_shard_by_set_dimension"]

View file

@ -1,38 +0,0 @@
import os
from typing import ClassVar, override
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
YamlConfigSettingsSource,
)
from xdg_base_dirs import xdg_config_home
SETTINGS_FILE = xdg_config_home() / "streamd" / "config.yaml"
class Settings(BaseSettings):
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_file_encoding="utf-8"
)
base_folder: str = os.getcwd()
@classmethod
@override
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
YamlConfigSettingsSource(settings_cls, yaml_file=SETTINGS_FILE),
dotenv_settings,
env_settings,
file_secret_settings,
)

View file

@ -1,115 +0,0 @@
from enum import StrEnum
from streamd.localize import RepositoryConfiguration
from streamd.localize.repository_configuration import (
Dimension,
Marker,
MarkerPlacement,
)
TIMESHEET_TAG = "Timesheet"
TIMESHEET_DIMENSION_NAME = "timesheet"
class TimesheetPointType(StrEnum):
Card = "CARD"
SickLeave = "SICK_LEAVE"
Vacation = "VACATION"
Undertime = "UNDERTIME"
Holiday = "HOLIDAY"
Break = "BREAK"
BasicTimesheetConfiguration = RepositoryConfiguration(
dimensions={
TIMESHEET_DIMENSION_NAME: Dimension(
display_name="Timesheet",
comment="Used by Timesheet-Subcommand to create Timecards",
propagate=False,
)
},
markers={
TIMESHEET_TAG: Marker(
display_name="A default time card",
placements=[
MarkerPlacement(
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Card.value,
overwrites=False,
)
],
),
"VacationDay": Marker(
display_name="Vacation Day",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Vacation.value,
)
],
),
"Break": Marker(
display_name="Break",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Break.value,
)
],
),
"LunchBreak": Marker(
display_name="Break",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Break.value,
)
],
),
"Feierabend": Marker(
display_name="Break",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Break.value,
)
],
),
"Holiday": Marker(
display_name="Offical Holiday",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Holiday.value,
)
],
),
"SickLeave": Marker(
display_name="Sick Leave",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.SickLeave.value,
)
],
),
"UndertimeDay": Marker(
display_name="Undertime Leave",
placements=[
MarkerPlacement(
if_with={TIMESHEET_TAG},
dimension=TIMESHEET_DIMENSION_NAME,
value=TimesheetPointType.Undertime.value,
)
],
),
},
)
__all__ = ["BasicTimesheetConfiguration", "TIMESHEET_TAG", "TIMESHEET_DIMENSION_NAME"]

View file

@ -1,113 +0,0 @@
from datetime import datetime
from itertools import groupby
from pydantic import BaseModel
from streamd.localize import LocalizedShard
from streamd.query.find import find_shard_by_set_dimension
from .configuration import TIMESHEET_DIMENSION_NAME, TimesheetPointType
from .timecard import SpecialDayType, Timecard, Timesheet
class TimesheetPoint(BaseModel):
moment: datetime
type: TimesheetPointType
def shard_to_timesheet_point(shard: LocalizedShard) -> TimesheetPoint:
return TimesheetPoint(
moment=shard.moment,
type=TimesheetPointType(shard.location[TIMESHEET_DIMENSION_NAME]),
)
def shards_to_timesheet_points(shards: list[LocalizedShard]) -> list[TimesheetPoint]:
return list(
map(
shard_to_timesheet_point,
find_shard_by_set_dimension(shards, TIMESHEET_DIMENSION_NAME),
)
)
def aggregate_timecard_day(points: list[TimesheetPoint]) -> Timesheet | None:
sorted_points = sorted(points, key=lambda point: point.moment)
is_sick_leave = False
special_day_type = None
card_date = sorted_points[0].moment.date()
# We expect timesheet points to alternate between "Card" (start work) and
# "Break" (end work). Starting in "break" means we are not currently in a
# work block until we see the first Card.
last_is_break = True
last_time = sorted_points[0].moment.time()
timecards: list[Timecard] = []
for point in sorted_points:
if point.moment.date() != card_date:
raise ValueError("Dates of all given timesheet days should be consistent")
point_time = point.moment.time()
match point.type:
case TimesheetPointType.Holiday:
if special_day_type is not None:
raise ValueError(
f"{card_date} is both {point.type} and {special_day_type}"
)
special_day_type = SpecialDayType.Holiday
case TimesheetPointType.Vacation:
if special_day_type is not None:
raise ValueError(
f"{card_date} is both {point.type} and {special_day_type}"
)
special_day_type = SpecialDayType.Vacation
case TimesheetPointType.Undertime:
if special_day_type is not None:
raise ValueError(
f"{card_date} is both {point.type} and {special_day_type}"
)
special_day_type = SpecialDayType.Undertime
case TimesheetPointType.SickLeave:
is_sick_leave = True
case TimesheetPointType.Break:
if not last_is_break:
timecards.append(Timecard(from_time=last_time, to_time=point_time))
last_is_break = True
last_time = point_time
case TimesheetPointType.Card:
if last_is_break:
last_is_break = False
last_time = point_time
if not last_is_break:
raise ValueError(f"Last Timecard of {card_date} is not a break!")
if len(timecards) == 0 and not is_sick_leave and special_day_type is None:
return None
return Timesheet(
date=card_date,
is_sick_leave=is_sick_leave,
special_day_type=special_day_type,
timecards=timecards,
)
def aggregate_timecards(points: list[TimesheetPoint]) -> list[Timesheet]:
day_timecards = [
aggregate_timecard_day(list(timecard))
for _date, timecard in groupby(points, key=lambda point: point.moment.date())
]
return [timecard for timecard in day_timecards if timecard is not None]
def extract_timesheets(shards: list[LocalizedShard]) -> list[Timesheet]:
points = shards_to_timesheet_points(shards)
return aggregate_timecards(points)
__all__ = ["extract_timesheets"]

View file

@ -1,23 +0,0 @@
from datetime import date, time
from enum import StrEnum
from pydantic import BaseModel
class SpecialDayType(StrEnum):
Vacation = "VACATION"
Undertime = "UNDERTIME"
Holiday = "HOLIDAY"
Weekend = "WEEKEND"
class Timecard(BaseModel):
from_time: time
to_time: time
class Timesheet(BaseModel):
date: date
is_sick_leave: bool = False
special_day_type: SpecialDayType | None = None
timecards: list[Timecard]

View file

@ -0,0 +1,84 @@
use once_cell::sync::Lazy;
use crate::models::{Dimension, Marker, MarkerPlacement, RepositoryConfiguration};
use super::TimesheetPointType;
pub const TIMESHEET_TAG: &str = "Timesheet";
pub const TIMESHEET_DIMENSION_NAME: &str = "timesheet";
/// Pre-configured repository configuration for timesheet tracking.
#[allow(non_upper_case_globals)]
pub static BasicTimesheetConfiguration: Lazy<RepositoryConfiguration> = Lazy::new(|| {
RepositoryConfiguration::new()
.with_dimension(
TIMESHEET_DIMENSION_NAME,
Dimension::new("Timesheet")
.with_comment("Used by Timesheet-Subcommand to create Timecards")
.with_propagate(false),
)
.with_marker(
TIMESHEET_TAG,
Marker::new("A default time card").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_value(TimesheetPointType::Card.as_str())
.with_overwrites(false)]),
)
.with_marker(
"VacationDay",
Marker::new("Vacation Day").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Vacation.as_str())]),
)
.with_marker(
"Break",
Marker::new("Break").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Break.as_str())]),
)
.with_marker(
"LunchBreak",
Marker::new("Break").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Break.as_str())]),
)
.with_marker(
"Feierabend",
Marker::new("Break").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Break.as_str())]),
)
.with_marker(
"Holiday",
Marker::new("Official Holiday").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Holiday.as_str())]),
)
.with_marker(
"SickLeave",
Marker::new("Sick Leave").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::SickLeave.as_str())]),
)
.with_marker(
"UndertimeDay",
Marker::new("Undertime Leave").with_placements(vec![MarkerPlacement::new(
TIMESHEET_DIMENSION_NAME,
)
.with_if_with(vec![TIMESHEET_TAG])
.with_value(TimesheetPointType::Undertime.as_str())]),
)
});

537
src/timesheet/extract.rs Normal file
View file

@ -0,0 +1,537 @@
use chrono::{DateTime, Utc};
use itertools::Itertools;
use crate::error::StreamdError;
use crate::models::{LocalizedShard, SpecialDayType, Timecard, Timesheet};
use crate::query::find_shard_by_set_dimension;
use super::configuration::TIMESHEET_DIMENSION_NAME;
use super::TimesheetPointType;
/// A point in time with an associated timesheet type.
#[derive(Debug, Clone)]
struct TimesheetPoint {
moment: DateTime<Utc>,
point_type: TimesheetPointType,
}
/// Convert a localized shard to a timesheet point.
fn shard_to_timesheet_point(shard: &LocalizedShard) -> Option<TimesheetPoint> {
let type_str = shard.location.get(TIMESHEET_DIMENSION_NAME)?;
let point_type = type_str.parse::<TimesheetPointType>().ok()?;
Some(TimesheetPoint {
moment: shard.moment,
point_type,
})
}
/// Convert localized shards to timesheet points.
fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec<TimesheetPoint> {
find_shard_by_set_dimension(shards, TIMESHEET_DIMENSION_NAME)
.iter()
.filter_map(shard_to_timesheet_point)
.collect()
}
/// Aggregate timesheet points for a single day into a Timesheet.
fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>, StreamdError> {
if points.is_empty() {
return Ok(None);
}
let sorted_points: Vec<_> = {
let mut pts = points.to_vec();
pts.sort_by_key(|p| p.moment);
pts
};
let card_date = sorted_points[0].moment.date_naive();
let mut is_sick_leave = false;
let mut special_day_type: Option<SpecialDayType> = None;
// State machine: starting in "break" mode (not working)
let mut last_is_break = true;
let mut last_time = sorted_points[0].moment.time();
let mut timecards: Vec<Timecard> = Vec::new();
for point in &sorted_points {
if point.moment.date_naive() != card_date {
return Err(StreamdError::TimesheetError(
"Dates of all given timesheet days should be consistent".to_string(),
));
}
let point_time = point.moment.time();
match point.point_type {
TimesheetPointType::Holiday => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Holiday);
}
TimesheetPointType::Vacation => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Vacation);
}
TimesheetPointType::Undertime => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Undertime);
}
TimesheetPointType::SickLeave => {
is_sick_leave = true;
}
TimesheetPointType::Break => {
if !last_is_break {
timecards.push(Timecard::new(last_time, point_time));
last_is_break = true;
last_time = point_time;
}
}
TimesheetPointType::Card => {
if last_is_break {
last_is_break = false;
last_time = point_time;
}
}
}
}
// Check that we ended in break mode
if !last_is_break {
return Err(StreamdError::TimesheetError(format!(
"Last Timecard of {} is not a break!",
card_date
)));
}
// Only return a timesheet if there's meaningful data
if timecards.is_empty() && !is_sick_leave && special_day_type.is_none() {
return Ok(None);
}
Ok(Some(Timesheet {
date: card_date,
is_sick_leave,
special_day_type,
timecards,
}))
}
/// Aggregate timesheet points into timesheets, grouped by day.
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
let mut timesheets = Vec::new();
// Group by date
for (_date, group) in &points.iter().chunk_by(|p| p.moment.date_naive()) {
let day_points: Vec<_> = group.cloned().collect();
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
timesheets.push(timesheet);
}
}
Ok(timesheets)
}
/// Extract timesheets from localized shards.
pub fn extract_timesheets(shards: &[LocalizedShard]) -> Result<Vec<Timesheet>, StreamdError> {
let points = shards_to_timesheet_points(shards);
aggregate_timecards(&points)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveTime, TimeZone};
use indexmap::IndexMap;
fn point(at: DateTime<Utc>, point_type: TimesheetPointType) -> LocalizedShard {
let mut location = IndexMap::new();
location.insert(
TIMESHEET_DIMENSION_NAME.to_string(),
point_type.as_str().to_string(),
);
location.insert("file".to_string(), "dummy.md".to_string());
LocalizedShard {
moment: at,
markers: vec!["Timesheet".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
location,
}
}
#[test]
fn test_single_work_block() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 30, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].date, day.date_naive());
assert!(!result[0].is_sick_leave);
assert!(result[0].special_day_type.is_none());
assert_eq!(result[0].timecards.len(), 1);
assert_eq!(
result[0].timecards[0].from_time,
NaiveTime::from_hms_opt(9, 0, 0).unwrap()
);
assert_eq!(
result[0].timecards[0].to_time,
NaiveTime::from_hms_opt(17, 30, 0).unwrap()
);
}
#[test]
fn test_three_work_blocks_separated_by_breaks() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].timecards.len(), 3);
assert_eq!(
result[0].timecards[0].from_time,
NaiveTime::from_hms_opt(7, 15, 0).unwrap()
);
assert_eq!(
result[0].timecards[0].to_time,
NaiveTime::from_hms_opt(12, 0, 0).unwrap()
);
}
#[test]
fn test_input_order_is_not_required_within_a_day() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].timecards.len(), 3);
}
#[test]
fn test_groups_by_day() {
let day1 = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let day2 = Utc.with_ymd_and_hms(2026, 2, 2, 0, 0, 0).unwrap();
let shards = vec![
point(
day1.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day1.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day2.with_time(NaiveTime::from_hms_opt(10, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day2.with_time(NaiveTime::from_hms_opt(18, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].date, day1.date_naive());
assert_eq!(result[1].date, day2.date_naive());
}
#[test]
fn test_day_with_only_special_day_type_vacation() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Vacation,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation));
assert!(result[0].timecards.is_empty());
}
#[test]
fn test_day_with_only_special_day_type_holiday() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Holiday,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday));
}
#[test]
fn test_day_with_only_special_day_type_undertime() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Undertime,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime));
}
#[test]
fn test_day_with_sick_leave_and_timecards() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(7, 30, 0).unwrap())
.unwrap(),
TimesheetPointType::SickLeave,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_sick_leave);
assert_eq!(result[0].timecards.len(), 1);
}
#[test]
fn test_day_with_sick_leave_only() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::SickLeave,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_sick_leave);
assert!(result[0].timecards.is_empty());
}
#[test]
fn test_empty_input() {
let result = extract_timesheets(&[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_day_with_only_cards_and_no_break_is_invalid() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
];
let result = extract_timesheets(&shards);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not a break"));
}
#[test]
fn test_two_special_day_types_same_day_is_invalid() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Vacation,
),
point(
day.with_time(NaiveTime::from_hms_opt(8, 5, 0).unwrap())
.unwrap(),
TimesheetPointType::Holiday,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is both"));
}
#[test]
fn test_day_with_only_breaks_is_ignored() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(13, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert!(result.is_empty());
}
}

7
src/timesheet/mod.rs Normal file
View file

@ -0,0 +1,7 @@
mod configuration;
mod extract;
mod point_types;
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
pub use extract::extract_timesheets;
pub use point_types::TimesheetPointType;

View file

@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// Type of timesheet point for time tracking.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimesheetPointType {
#[serde(rename = "CARD")]
Card,
#[serde(rename = "SICK_LEAVE")]
SickLeave,
#[serde(rename = "VACATION")]
Vacation,
#[serde(rename = "UNDERTIME")]
Undertime,
#[serde(rename = "HOLIDAY")]
Holiday,
#[serde(rename = "BREAK")]
Break,
}
impl TimesheetPointType {
pub fn as_str(&self) -> &'static str {
match self {
TimesheetPointType::Card => "CARD",
TimesheetPointType::SickLeave => "SICK_LEAVE",
TimesheetPointType::Vacation => "VACATION",
TimesheetPointType::Undertime => "UNDERTIME",
TimesheetPointType::Holiday => "HOLIDAY",
TimesheetPointType::Break => "BREAK",
}
}
}
impl std::fmt::Display for TimesheetPointType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl FromStr for TimesheetPointType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"CARD" => Ok(TimesheetPointType::Card),
"SICK_LEAVE" => Ok(TimesheetPointType::SickLeave),
"VACATION" => Ok(TimesheetPointType::Vacation),
"UNDERTIME" => Ok(TimesheetPointType::Undertime),
"HOLIDAY" => Ok(TimesheetPointType::Holiday),
"BREAK" => Ok(TimesheetPointType::Break),
_ => Err(format!("Unknown timesheet point type: {}", s)),
}
}
}