refactor: rewrite in rust
This commit is contained in:
parent
20a3e8b437
commit
ed493cff29
72 changed files with 5684 additions and 3688 deletions
40
src/cli/args.rs
Normal file
40
src/cli/args.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
11
src/cli/commands/completions.rs
Normal file
11
src/cli/commands/completions.rs
Normal 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
73
src/cli/commands/edit.rs
Normal 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
5
src/cli/commands/mod.rs
Normal 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
60
src/cli/commands/new.rs
Normal 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(())
|
||||
}
|
||||
52
src/cli/commands/timesheet.rs
Normal file
52
src/cli/commands/timesheet.rs
Normal 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
56
src/cli/commands/todo.rs
Normal 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
4
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod args;
|
||||
pub mod commands;
|
||||
|
||||
pub use args::{Cli, Commands};
|
||||
44
src/config.rs
Normal file
44
src/config.rs
Normal 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
25
src/error.rs
Normal 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
5
src/extract/mod.rs
Normal 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
739
src/extract/parser.rs
Normal 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![],
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
219
src/extract/tag_extraction.rs
Normal file
219
src/extract/tag_extraction.rs
Normal 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
14
src/lib.rs
Normal 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,
|
||||
};
|
||||
448
src/localize/configuration.rs
Normal file
448
src/localize/configuration.rs
Normal 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
365
src/localize/datetime.rs
Normal 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
15
src/localize/mod.rs
Normal 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};
|
||||
46
src/localize/preconfigured.rs
Normal file
46
src/localize/preconfigured.rs
Normal 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
282
src/localize/shard.rs
Normal 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
19
src/main.rs
Normal 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
42
src/models/dimension.rs
Normal 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
|
||||
}
|
||||
}
|
||||
63
src/models/localized_shard.rs
Normal file
63
src/models/localized_shard.rs
Normal 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
76
src/models/marker.rs
Normal 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
11
src/models/mod.rs
Normal 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
115
src/models/shard.rs
Normal 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
77
src/models/timecard.rs
Normal 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
209
src/query/find.rs
Normal 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
3
src/query/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod find;
|
||||
|
||||
pub use find::{find_shard, find_shard_by_position, find_shard_by_set_dimension};
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from .shard import Shard, StreamFile
|
||||
from .parse import parse_markdown_file
|
||||
|
||||
__all__ = ["Shard", "StreamFile", "parse_markdown_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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from .find import find_shard, find_shard_by_position
|
||||
|
||||
__all__ = ["find_shard_by_position", "find_shard"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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]
|
||||
84
src/timesheet/configuration.rs
Normal file
84
src/timesheet/configuration.rs
Normal 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
537
src/timesheet/extract.rs
Normal 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
7
src/timesheet/mod.rs
Normal 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;
|
||||
54
src/timesheet/point_types.rs
Normal file
54
src/timesheet/point_types.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue