36 Commits

Author SHA1 Message Date
587a0be9cf fixing artifact upload
All checks were successful
Test / test (push) Successful in 40s
Build / test (pull_request) Successful in 1m26s
2026-04-15 01:41:53 -04:00
7c499c1e93 fixing artifact upload
Some checks failed
Test / test (push) Successful in 40s
Build / test (pull_request) Failing after 3s
2026-04-15 01:28:30 -04:00
03e830575d fixing artifact upload
Some checks failed
Test / test (push) Successful in 40s
Build / test (pull_request) Failing after 11s
2026-04-15 01:26:26 -04:00
4733dd12ed fixing artifact upload
Some checks failed
Test / test (push) Successful in 39s
Build / test (pull_request) Failing after 1m27s
2026-04-15 01:03:27 -04:00
72973791db upload artifact
Some checks failed
Test / test (push) Successful in 41s
Build / test (pull_request) Failing after 1m41s
2026-04-15 00:59:10 -04:00
59fba3ef7a change workflow target
All checks were successful
Test / test (push) Successful in 38s
Build / test (pull_request) Successful in 1m26s
2026-04-15 00:49:03 -04:00
807d75ac31 fixing workflow
All checks were successful
Test / test (push) Successful in 39s
2026-04-15 00:46:22 -04:00
e861bf99f7 more build actions testing
Some checks failed
Test / test (push) Has been cancelled
2026-04-15 00:42:58 -04:00
887eb954a8 build workflow
All checks were successful
Test / test (push) Successful in 40s
2026-04-15 00:39:11 -04:00
ce03c9c171 moved to comrak 0.52
All checks were successful
Test / test (push) Successful in 47s
2026-04-14 23:44:52 -04:00
181be6c4e3 actions test
Some checks failed
Test / test (release) (push) Failing after 10m32s
2026-04-14 14:22:44 -04:00
84d7ba45d3 moved to comrak v0.24.1 2024-10-10 20:08:26 -04:00
9cc32fe65a todo list 2024-05-24 14:14:01 -04:00
319cbbef4d cleanup 2024-05-02 20:43:16 -04:00
754ae271d7 increment version number 2024-04-19 21:43:25 -04:00
2643957c91 pre-release cleanup 2024-04-19 21:43:06 -04:00
5cd03b713c specifiing dates 2024-04-19 20:26:33 -04:00
4e26695532 updated readme 2024-04-16 09:49:57 -04:00
7453bb8cae more documentation 2024-04-10 13:21:45 -04:00
c92bb3b31b documenting code 2024-04-10 12:38:40 -04:00
51574e1403 a bit of cleanup 2024-04-09 20:05:58 -04:00
d910f78afd test coverage 2024-04-02 14:21:25 -04:00
a8e6572a53 logging 2024-04-02 10:58:49 -04:00
fc7e45cd3f test coverage 2024-04-02 00:07:09 -04:00
9fe6ae5eb8 listing all files 2024-04-01 22:00:40 -04:00
ddd620a021 code cleanup
mostly dead code removal
some formating
2024-04-01 13:25:04 -04:00
48df1fd9e0 v0.1.2 2024-03-18 22:02:46 -04:00
cfe9065243 using new file selection logic 2024-03-18 22:02:24 -04:00
c92da26ee7 cleanup 2024-03-18 20:20:18 -04:00
d3218ad907 getting latest file with window 2024-03-18 20:17:38 -04:00
3b1485d2c0 getting closest file to date 2024-03-06 14:50:03 -05:00
e4be0fb471 cli arguments 2024-03-06 14:31:53 -05:00
bce8f1e4b8 refactor file operations 2024-03-04 14:59:49 -05:00
e0c81885c8 path handling and unwraps 2024-02-28 17:20:55 -05:00
1c51c53eef some error handeling 2024-02-28 15:12:19 -05:00
875ea1e53e implementing arguments for configs 2024-02-22 02:16:58 -05:00
13 changed files with 1105 additions and 323 deletions

View File

@@ -0,0 +1,21 @@
name: Build
on:
release:
types:
- published
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --release
- name: Run build
run: cargo build --release --target x86_64-unknown-linux-gnu
- uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with:
name: rusty-task
path: target/x86_64-unknown-linux-gnu/release/rusty-tasks

View File

@@ -0,0 +1,11 @@
name: Test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test

2
.gitignore vendored
View File

@@ -21,4 +21,4 @@ Cargo.lock
# config file # config file
.rusty_task.json .rusty_task.json
Notes/

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rusty-tasks" name = "rusty-tasks"
version = "0.1.1" version = "0.1.3"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -8,8 +8,12 @@ edition = "2021"
[dependencies] [dependencies]
chrono = "0.4.26" chrono = "0.4.26"
clap = { version = "4.5.1", features = ["derive"] } clap = { version = "4.5.1", features = ["derive"] }
comrak = "0.18.0" comrak = "~0.52.0"
figment = { version = "0.10.10", features = ["env", "serde_json", "json"] } figment = { version = "0.10.10", features = ["env", "serde_json", "json"] }
regex = "1.8.4" regex = "1.8.4"
serde = { version = "1.0.164", features = ["serde_derive"] } serde = { version = "1.0.164", features = ["serde_derive"] }
serde_json = "1.0.97" serde_json = "1.0.97"
resolve-path = "0.1.0"
simple_logger = "4.3.3"
log = "0.4.21"
indexmap = "2.2.6"

111
README.md
View File

@@ -1,26 +1,111 @@
# Rusty-tasks # Rusty-tasks
A rewrite of an older CLI todo system without python and regex
## Goals A personal task management system built on plain text and works in your favorite editor
- [ ] BLAZING FAST!
- [ ] replace python with rust
- [ ] replace regex with a markdown parsing library
- [ ] learn some rust along the way
## Ideas ## The General Idea
If you've used the bullet journal technique before, think if this as the If you've used the bullet journal technique before, think of this as the
digital, automated version of that. digital, automated version of that.
- Accessible from anywhere in the terminal (TMUX, bare shell, etc) - Accessible from anywhere in the terminal (TMUX, bare shell, etc.)
* works really well with a drop-down terminal * works really well with a drop-down terminal
- Takes advantage of default editor (vim, emacs, etc) - Take advantage of your favorite editor (vim, Emacs, etc.)
- Take advantage of Markdown for formatting - Take advantage of plain text formats (Markdown)
- Organize tasks at different levels of urgency - Organize tasks at different levels of urgency
* Daily - Must get done now * Daily - Must get done now
* Weekly - Must get done in the near future * Weekly - Must get done in the near future
* Monthly - I'm going to forget if I don't write it down but It's a ways away * Monthly - I'm going to forget if I don't write it down, but It's a ways away
- Every day start a new file - Every day start a new file
* files should be dated * files should be dated
- Carry over uncompleted tasks from previous day - Carry over uncompleted tasks from the previous day
## Installing
```bash
git clone https://github.com/andrei-stoica/rusty-tasks.git
cd rusty-task cargo install --path .
```
Alternatively, there is a binary download for AMD64 Linux machines available
on the [releases page](https://github.com/andrei-stoica/rusty-tasks/releases).
Just drop that anywhere on your PATH. I recommend adding `~/bin` to your PATH
and dropping the executable there.
If you are not on an AMD64 Linux machine, you will need to build from source.
I have not tested this on other platforms, so I hesitate to provide binaries
for them.
## Usage
***WARNING:*** *This documentation can be ahead of the releases on the GH release page*
```help
Usage: rusty-tasks [OPTIONS]
Options:
-c, --config <FILE> set config file to use
-C, --current-config show current config file
-d, --date <DATE> view a specific date's file (format: YYYY-MM-DD)
-p, --previous <PREVIOUS> view previous day's notes [default: 0]
-l, --list list closest files to date
-n, --number <NUMBER> number of files to list [default: 5]
-L, --list-all list closest files to date
-v, --verbose... increase logging level
-h, --help Print help
-V, --version Print version
```
Just use `rust-task` to access today's notes file.
Use `rust-task -p <n>` to access a previous day's file where `<n>` is the number
of days back you want to go. If a file does not exist for that day, it will
default to the closest to that date. A value of 0 represents today's file.
Alternatively, use `--date` or `-d` to specify a date specifically. Preferably
in the format year-month-day, padding with zero is optional. However, if the
year, or, year and month are omitted they will be filled in with the current
date's year and month. For example, If the current date is `2024-2-30`, the
string `4` will resolve to `2024-2-4`, and `1-4` will resolve to `2024-1-4`.
Specify a custom config location with `-c`, otherwise, it will scan for a config
in the locations specified in the [config section](#config). If no config
exists it will create one. To see what config is being loaded you can use `-C`.
To list your existing notes you can use `-L`. For a subset of these use
`-l` combined with `-n` to specify the number of files to list. This will be
the closest `n` files to the specified date, which is today by default. Specify
the target date using the `-p` as mentioned earlier
## Config
The config should be located in the following locations:
- `~/.config/rusty_task.json`
- `~/.rusty_task.json`
- `$PWD/.rusty_task.json`
If there is no config it will be created at `~/.config/rusty_task.json`.
Example config:
```
{
"editor": "nano",
"sections": [
"Daily",
"Weekly",
"Monthly"
],
"notes_dir": "~/notes"
}
```
- `editor` is the executable that will be launched to modify the notes file
- `sections` is a list of Sections that will be carried over from the previous
day's notes
* only uncompleted tasks are carried over
* You can use other sections for scratch space and other journaling tasks
- `notes_dir` is the directory that stores your daily notes
* this could be set to your obsidian vault if you want it to work with
all of your other notes (I recommend checking out [obsidian.nvim](https://github.com/epwalsh/obsidian.nvim)
if you want to interact with an obsidian vault in neovim)

8
TODO.md Normal file
View File

@@ -0,0 +1,8 @@
- [ ] Obsidian properties
- [ ] encoding in YAML (using Serde)
- [ ] config for default properties
- [ ] formatting for properties such as dates
- [x] update rendering to use comrak (it's been update)

View File

@@ -1,4 +1,5 @@
use clap::{Parser, Subcommand}; use chrono::{Datelike, NaiveDate};
use clap::Parser;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about)] #[command(version, about)]
@@ -6,8 +7,98 @@ pub struct Args {
/// set config file to use /// set config file to use
#[arg(short, long, value_name = "FILE")] #[arg(short, long, value_name = "FILE")]
pub config: Option<String>, pub config: Option<String>,
/// show current config file /// show current config file
#[arg(short = 'C', long)] #[arg(short = 'C', long)]
pub current_config: bool, pub current_config: bool,
/// view a specific date's file (YYYY-MM-DD)
#[arg(short, long)]
pub date: Option<String>,
/// view previous day's notes
#[arg(short = 'p', long, default_value_t = 0)]
pub previous: u16,
/// list closest files to date
#[arg(short, long)]
pub list: bool,
/// number of files to list
#[arg(short, long, default_value_t = 5)]
pub number: usize,
/// list closest files to date
#[arg(short = 'L', long)]
pub list_all: bool,
/// increase logging level
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
}
pub fn smart_parse_date(date_str: &str, cur_date: &NaiveDate) -> Option<NaiveDate> {
let full_date_fmt = "%Y-%m-%d";
if let Ok(date) = NaiveDate::parse_from_str(date_str, &full_date_fmt) {
return Some(date);
}
let parts: Vec<&str> = date_str.split('-').collect();
match parts.len() {
1 => cur_date.with_day(parts[0].parse().unwrap_or(cur_date.day())),
2 => cur_date
.with_day(parts[1].parse().unwrap_or(cur_date.day()))?
.with_month(parts[0].parse().unwrap_or(cur_date.month())),
3 => NaiveDate::from_ymd_opt(
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
),
_ => None,
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_smart_parse_date() {
let good_date = NaiveDate::from_ymd_opt(2024, 01, 03).expect("Invalid date specified");
let good_date_str = good_date.format("%Y-%m-%d").to_string();
assert_eq!(
Some(good_date),
smart_parse_date(&good_date_str, &good_date)
);
let no_padding_date_str = "2024-1-3";
assert_eq!(
Some(good_date),
smart_parse_date(no_padding_date_str, &good_date)
);
let bad_day_str = "2024-01-99";
assert_eq!(None, smart_parse_date(bad_day_str, &good_date));
let no_day_str = "2024-01";
assert_eq!(None, smart_parse_date(no_day_str, &good_date));
let bad_month_str = "2024-25-01";
assert_eq!(None, smart_parse_date(bad_month_str, &good_date));
let no_month_str = "2024-14";
assert_eq!(None, smart_parse_date(no_month_str, &good_date));
let no_year_str = "01-03";
assert_eq!(Some(good_date), smart_parse_date(no_year_str, &good_date));
let bad_month_no_year_str = "25-01";
assert_eq!(None, smart_parse_date(bad_month_no_year_str, &good_date));
let bad_day_no_year_str = "01-35";
assert_eq!(None, smart_parse_date(bad_day_no_year_str, &good_date));
let no_year_month_str = "03";
assert_eq!(
Some(good_date),
smart_parse_date(no_year_month_str, &good_date)
);
let bad_day_no_year_month_str = "35";
assert_eq!(
None,
smart_parse_date(bad_day_no_year_month_str, &good_date)
);
}
} }

View File

@@ -11,17 +11,17 @@ use std::path::PathBuf;
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct Config { pub struct Config {
pub editor: Option<String>, pub editor: String,
pub sections: Option<Vec<String>>, pub sections: Vec<String>,
pub notes_dir: Option<String>, pub notes_dir: String,
} }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Config { Config {
editor: Some("nano".into()), editor: "nano".into(),
sections: Some(vec!["Daily".into(), "Weekly".into(), "Monthly".into()]), sections: vec!["Daily".into(), "Weekly".into(), "Monthly".into()],
notes_dir: Some("Notes".into()), notes_dir: "~/Notes".into(),
} }
} }
} }
@@ -30,7 +30,7 @@ impl Default for Config {
pub enum ConfigError { pub enum ConfigError {
IOError(&'static str), IOError(&'static str),
ParseError(&'static str), ParseError(&'static str),
EnvError(&'static str) EnvError(&'static str),
} }
impl Config { impl Config {
@@ -43,20 +43,31 @@ impl Config {
} }
pub fn write_default(cfg_file: &str) -> Result<(), ConfigError> { pub fn write_default(cfg_file: &str) -> Result<(), ConfigError> {
let buf = serde_json::to_string_pretty(&Self::default()) let buf = serde_json::to_string_pretty(&Self::default()).or_else(|_| {
.or_else(|_| return Err(ConfigError::ParseError("could not serialize default config")))?; Err(ConfigError::ParseError(
"could not serialize default config",
))
})?;
let mut f = File::create(cfg_file).or_else(|_| Err(ConfigError::IOError("Could not open config file")))?; let mut f = File::create(cfg_file)
f.write_all(&buf.as_bytes()) .or_else(|_| Err(ConfigError::IOError("Could not open config file")))?;
.or_else(|_| return Err(ConfigError::IOError("could not write default config to file")))?; f.write_all(&buf.as_bytes()).or_else(|_| {
Err(ConfigError::IOError(
"could not write default config to file",
))
})?;
Ok(()) Ok(())
} }
pub fn expected_locations() -> Result<Vec<PathBuf>, ConfigError> { pub fn expected_locations() -> Result<Vec<PathBuf>, ConfigError> {
let cfg_name = "rusty_task.json"; let cfg_name = "rusty_task.json";
let home = var("HOME").or(Err(ConfigError::EnvError("$HOME environment variable not set")))?; let home = var("HOME").or(Err(ConfigError::EnvError(
let pwd = var("PWD").or(Err(ConfigError::EnvError("$PWD environment variable not set")))?; "$HOME environment variable not set",
)))?;
let pwd = var("PWD").or(Err(ConfigError::EnvError(
"$PWD environment variable not set",
)))?;
let mut home_config_cfg = PathBuf::from(home.clone()); let mut home_config_cfg = PathBuf::from(home.clone());
home_config_cfg.push(".config"); home_config_cfg.push(".config");

543
src/file/mod.rs Normal file
View File

@@ -0,0 +1,543 @@
use crate::todo::{File as TodoFile, Status as TaskStatus};
use crate::NaiveDate;
use crate::TaskGroup;
use chrono::Datelike;
use comrak::nodes::{Ast, AstNode, LineColumn, NodeHeading, NodeTaskItem, NodeValue};
use comrak::options::{Extension, Parse};
use comrak::{parse_document, Arena, Options};
use indexmap::IndexMap;
use regex::Regex;
use std::collections::HashMap;
use std::fs::{read, File};
use std::io::Write;
use std::path::PathBuf;
use std::str;
#[derive(Debug)]
pub enum FileNameParseError {
TypeConversionError(&'static str),
ParseError(chrono::ParseError),
}
pub fn get_filepath(data_dir: &PathBuf, date: &NaiveDate) -> PathBuf {
let file_name = format!("{}-{:02}-{:02}.md", date.year(), date.month(), date.day());
let mut file_path = data_dir.clone();
file_path.push(file_name);
file_path
}
/// generate strings from TaskGroups and date
pub fn generate_file_content(data: &Vec<TaskGroup>, date: &NaiveDate) -> String {
// TODO: This should be a type and then I can implement it with From<>
let mut content = format!(
"# Today's tasks {}-{:02}-{:02}\n",
date.year(),
date.month(),
date.day()
);
data.iter()
.for_each(|task_group| content.push_str(format!("\n{}", task_group.to_string()).as_str()));
content
}
pub fn write_file(path: &PathBuf, content: &String) {
let mut new_file = File::create(&path).expect("Could not open today's file: {today_file_path}");
write!(new_file, "{}", content).expect("Could not write to file: {today_file_path}");
}
/// Load in text file as String
pub fn load_file(file: &TodoFile) -> String {
// TODO: This could be a TryFrom<>
let contents_utf8 = read(file.file.clone())
.expect(format!("Could not read file {}", file.file.to_string_lossy()).as_str());
str::from_utf8(&contents_utf8)
.expect(
format!(
"failed to convert contents of file to string: {}",
file.file.to_string_lossy()
)
.as_str(),
)
.to_string()
}
/// Parse contents of markdown file with Comrak ( relaxed tasklist matching is enabled)
pub fn parse_todo_file<'a>(contents: &String, arena: &'a Arena<'a>) -> &'a AstNode<'a> {
let mut extension_options = Extension::default();
extension_options.tasklist = true;
let mut parse_options = Parse::default();
parse_options.relaxed_tasklist_matching = true;
let options = &Options {
extension: extension_options,
parse: parse_options,
..Options::default()
};
parse_document(arena, contents, options)
}
pub fn extract_secitons<'a>(
root: &'a AstNode<'a>,
sections: &Vec<String>,
) -> HashMap<String, TaskGroup> {
let mut groups: HashMap<String, TaskGroup> = HashMap::new();
for node in root.reverse_children() {
let node_ref = &node.data.borrow();
if let NodeValue::Heading(heading) = node_ref.value {
if heading.level < 2 {
continue;
}
let first_child_ref = &node.first_child();
let first_child = if let Some(child) = first_child_ref {
child
} else {
continue;
};
let data_ref = &first_child.data.borrow();
let title = if let NodeValue::Text(value) = &data_ref.value {
value
} else {
continue;
};
if sections.iter().any(|section| section.eq(title)) {
if let Ok(mut group) = TaskGroup::try_from(node) {
group.tasks = group
.tasks
.into_iter()
.filter(|task| !matches!(task.status, TaskStatus::Done(_)))
.collect();
groups.insert(title.to_string(), group);
}
}
};
}
groups
}
fn remove_heading<'a>(node: &'a AstNode<'a>, level: u8) {
let mut following = node.following_siblings();
let _ = following.next().unwrap();
for sib in following {
let node_ref = sib.data.borrow();
if let NodeValue::Heading(heading) = node_ref.value {
if heading.level == level {
break;
}
} else {
sib.detach();
}
}
node.detach();
}
/// recursively removes nodes from List
fn remove_task_nodes<'a>(root: &'a AstNode<'a>) {
for node in root.children() {
for child_node in node.children() {
remove_task_nodes(child_node)
}
match node.data.borrow().value {
NodeValue::TaskItem(NodeTaskItem {
symbol: Some(status),
symbol_sourcepos: _,
}) if status == 'x' || status == 'X' => node.detach(),
_ => continue,
}
}
}
fn create_title<'a>(arena: &'a Arena<'a>, date: &str) -> &'a AstNode<'a> {
let mut text = String::new();
text.push_str("Today's tasks ");
text.push_str(date);
create_heading(arena, 1, &text)
}
fn create_heading<'a>(arena: &'a Arena<'a>, level: u8, text: &str) -> &'a AstNode<'a> {
let heading_node = arena.alloc(AstNode::new(
Ast::new(
NodeValue::Heading(NodeHeading {
level,
setext: false,
closed: false,
}),
LineColumn { line: 0, column: 0 },
)
.into(),
));
let text_node = arena.alloc(AstNode::new(
Ast::new(
NodeValue::Text(text.to_string().into()),
LineColumn { line: 0, column: 2 },
)
.into(),
));
heading_node.append(text_node);
heading_node
}
pub fn create_new_doc<'a>(
arena: &'a Arena<'a>,
new_date: &str,
sections: IndexMap<String, Option<Vec<&'a AstNode<'a>>>>,
) -> &'a AstNode<'a> {
let doc = arena.alloc(AstNode::new(
Ast::new(NodeValue::Document, LineColumn { line: 0, column: 0 }).into(),
));
let title = create_title(&arena, new_date);
doc.append(title);
for (section, value) in sections.iter() {
let heading = create_heading(arena, 2, &section);
doc.append(heading);
match value {
Some(nodes) => {
for node in nodes.iter() {
doc.append(node);
}
}
_ => (),
}
}
doc
}
pub fn extract_sections<'a>(
root: &'a AstNode<'a>,
sections: &Vec<String>,
) -> IndexMap<String, Option<Vec<&'a AstNode<'a>>>> {
let mut section_map: IndexMap<String, Option<Vec<&'a AstNode<'a>>>> = IndexMap::new();
sections.iter().for_each(|section| {
section_map.insert(section.to_string(), None);
});
for node in root.reverse_children() {
let node_ref = node.data.borrow();
match node_ref.value {
NodeValue::Heading(heading) => {
let heading_content_node = if let Some(child) = node.first_child() {
child
} else {
continue;
};
let mut heading_content_ref = heading_content_node.data.borrow_mut();
if let NodeValue::Text(text) = &mut heading_content_ref.value {
if sections.contains(&text.to_string()) {
let mut content = Vec::new();
let mut following = node.following_siblings();
let _ = following.next().unwrap();
for sib in following {
remove_task_nodes(sib);
let node_ref = sib.data.borrow();
if let NodeValue::Heading(inner_heading) = node_ref.value {
if heading.level == inner_heading.level {
break;
}
} else {
content.push(sib);
}
}
section_map.insert(text.to_string(), Some(content));
remove_heading(node, heading.level);
};
}
}
_ => continue,
}
}
section_map
}
pub fn process_doc_tree<'a>(root: &'a AstNode<'a>, new_date: &str, sections: &Vec<String>) {
for node in root.reverse_children() {
let node_ref = node.data.borrow();
match node_ref.value {
NodeValue::Heading(heading) => {
let heading_content_node = if let Some(child) = node.first_child() {
child
} else {
continue;
};
let mut heading_content_ref = heading_content_node.data.borrow_mut();
if let NodeValue::Text(text) = &mut heading_content_ref.value {
let re = Regex::new(r"Today's tasks \d+-\d+-\d+")
.expect("title regex is not parsable");
if matches!(re.find(text), Some(_)) {
let text_mut = text.to_mut();
text_mut.clear();
text_mut.push_str("Today's tasks ");
text_mut.push_str(new_date);
} else if !sections.contains(&text.to_string()) {
remove_heading(node, heading.level);
};
}
}
NodeValue::List(_list) => remove_task_nodes(node),
_ => continue,
}
}
eprintln!("{:#?}", root);
}
#[cfg(test)]
mod test {
use super::*;
use crate::todo::Status;
use crate::todo::Task;
use comrak::format_commonmark;
#[test]
fn test_extract_sections() {
let test_md = "\
# Test
## Content
- [ ] something
- [x] done
- [!] other
## Unused
### Sub section
- [ ] task
## Unrealated Stuff
- [ ] something else
+ [ ] subtask";
let arena = Arena::new();
let root = parse_todo_file(&test_md.to_string(), &arena);
let result = extract_secitons(root, &vec![]);
assert_eq!(result.keys().count(), 0);
let result = extract_secitons(root, &vec!["Not There".to_string()]);
assert_eq!(result.keys().count(), 0);
let sections = vec!["Unused".to_string()];
let result = extract_secitons(root, &sections);
assert_eq!(result.keys().count(), 0);
let sections = vec!["Sub section".to_string()];
let result = extract_secitons(root, &sections);
assert_eq!(result.keys().count(), 1);
assert!(result.get(sections.first().unwrap()).is_some());
assert_eq!(result.get(sections.first().unwrap()).unwrap().level, 3);
let sections = vec!["Content".to_string()];
let result = extract_secitons(root, &sections);
assert_eq!(result.keys().count(), 1);
assert!(result.get(sections.first().unwrap()).is_some());
assert_eq!(
result
.get(sections.first().unwrap())
.expect("No Value for \"Content\""),
&TaskGroup {
name: sections.first().unwrap().clone(),
tasks: vec![
Task {
status: TaskStatus::Empty,
text: "something".to_string(),
subtasks: None
},
Task {
status: TaskStatus::Todo('!'),
text: "other".to_string(),
subtasks: None
},
],
level: 2
}
);
let sections = vec!["Unrealated Stuff".to_string()];
let result = extract_secitons(root, &sections);
assert_eq!(result.keys().count(), 1);
assert!(result.get(sections.first().unwrap()).is_some());
assert_eq!(
result
.get(sections.first().unwrap())
.expect("No Value for \"Content\""),
&TaskGroup {
name: sections.first().unwrap().clone(),
tasks: vec![Task {
status: TaskStatus::Empty,
text: "something else".to_string(),
subtasks: Some(vec![Task {
status: TaskStatus::Empty,
text: "subtask".to_string(),
subtasks: None
}]),
}],
level: 2
}
);
let result = extract_secitons(
root,
&vec!["Content".to_string(), "Sub section".to_string()],
);
assert_eq!(result.keys().count(), 2);
}
#[test]
fn test_generate_file_content() {
let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let mut content: Vec<TaskGroup> = vec![];
let result = generate_file_content(&content, &date);
let expected = "# Today's tasks 2024-01-01\n";
assert_eq!(result, expected);
content.push(TaskGroup {
name: "Empty".into(),
tasks: vec![],
level: 2,
});
let result = generate_file_content(&content, &date);
let expected = "# Today's tasks 2024-01-01\n\n## Empty\n";
assert_eq!(result, expected);
content.push(TaskGroup {
name: "Subgroup".into(),
tasks: vec![],
level: 3,
});
let result = generate_file_content(&content, &date);
let expected = "# Today's tasks 2024-01-01\n\n## Empty\n\n### Subgroup\n";
assert_eq!(result, expected);
content.push(TaskGroup {
name: "Tasks".into(),
tasks: vec![
Task {
status: Status::Empty,
text: "task 1".into(),
subtasks: None,
},
Task {
status: Status::Done('x'),
text: "task 2".into(),
subtasks: None,
},
Task {
status: Status::Todo('>'),
text: "task 3".into(),
subtasks: None,
},
],
level: 2,
});
let result = generate_file_content(&content, &date);
let expected = "\
# Today's tasks 2024-01-01
## Empty
### Subgroup
## Tasks
- [ ] task 1
- [x] task 2
- [>] task 3
";
assert_eq!(result, expected);
}
#[test]
fn test_node_removal() {
let md = "
# Today's tasks 2024-01-01
## Tasks
- [ ] task 1
- [X] task 2
- [x] task 2
- [>] task 3
- [!] task 3
## Long Term
- [ ] task 1
- [X] task 2
- [ ] all of these subtasks should be removed
- [x] subtasks
- [x] sub task to remove
- [!] task 3
- [ ] sub task to keep
- [x] sub task to remove
## Todays Notes
- some notes here
- these can go
";
let new_date = "2024-01-02";
let groups = vec![
"Tasks".to_string(),
"Other".to_string(),
"Long Term".to_string(),
"Last".to_string(),
];
let arena = Arena::new();
let mut extension_options = Extension::default();
extension_options.tasklist = true;
let mut parse_options = Parse::default();
parse_options.relaxed_tasklist_matching = true;
let options = &Options {
extension: extension_options,
parse: parse_options,
..Options::default()
};
let ast = parse_document(&arena, md, options);
let sections = extract_sections(ast, &groups);
let new_doc = create_new_doc(&arena, new_date, sections);
process_doc_tree(ast, new_date, &groups);
let mut output = String::new(); //BufWriter::new(Vec::new());
assert!(format_commonmark(new_doc, options, &mut output).is_ok());
assert_eq!(
"\
# Today's tasks 2024-01-02
## Tasks
- [ ] task 1
- [>] task 3
- [!] task 3
## Other
## Long Term
- [ ] task 1
- [!] task 3
- [ ] sub task to keep
## Last
",
output
);
}
}

26
src/logging/mod.rs Normal file
View File

@@ -0,0 +1,26 @@
use log::Level;
pub fn get_logging_level(verbose_level: u8) -> Level {
match verbose_level {
..=0 => Level::Error,
1 => Level::Warn,
2 => Level::Info,
3 => Level::Debug,
4.. => Level::Trace,
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_get_logging_level() {
assert_eq!(get_logging_level(0), Level::Error);
assert_eq!(get_logging_level(1), Level::Warn);
assert_eq!(get_logging_level(2), Level::Info);
assert_eq!(get_logging_level(3), Level::Debug);
assert_eq!(get_logging_level(4), Level::Trace);
assert_eq!(get_logging_level(5), Level::Trace);
}
}

View File

@@ -1,271 +1,202 @@
mod cli; mod cli;
mod config; mod config;
mod file;
mod logging;
mod todo; mod todo;
use crate::cli::Args;
use clap::Parser;
use crate::config::Config;
use crate::todo::File as TodoFile;
use crate::todo::{Status as TaskStatus, TaskGroup};
use chrono::naive::NaiveDate; use chrono::naive::NaiveDate;
use chrono::{Datelike, Local}; use chrono::{Datelike, Local, TimeDelta};
use comrak::nodes::{AstNode, NodeValue}; use clap::Parser;
use comrak::{parse_document, Arena}; use cli::Args;
use comrak::{ComrakExtensionOptions, ComrakOptions, ComrakParseOptions}; use comrak::options::{Extension, Parse};
use std::borrow::Borrow; use comrak::{format_commonmark, Arena, Options};
use std::collections::HashMap; use config::Config;
use std::fs::{create_dir_all, metadata, read, read_dir, File}; use log;
use std::io::{self, Write}; use logging::get_logging_level;
use std::path::{Path, PathBuf}; use resolve_path::PathResolveExt;
use simple_logger::init_with_level;
use std::fs;
use std::path::Path;
use std::process::Command; use std::process::Command;
use std::{env, str}; use todo::{File as TodoFile, TaskGroup};
//TODO handle unwraps and errors more uniformly use crate::file::{extract_sections, process_doc_tree};
//TODO refactor creating new file
//TODO clean up verbose printing
//TODO create custom errors for better error handling
//TODO Default path for note_dir should start with curent path not home
#[derive(Debug)] fn main() {
enum ExitError { // setup
ConfigError(String), let args = Args::parse();
IOError(String, io::Error), let _logger = init_with_level(get_logging_level(args.verbose)).unwrap();
} log::debug!("{:?}", args);
fn main() -> Result<(), ExitError> { // getting config location
let expected_cfg_files = Config::expected_locations().unwrap(); let expected_cfg_files = match Config::expected_locations() {
println!("{:#?}", expected_cfg_files); Ok(cfg_files) => cfg_files,
Err(e) => panic!("{:?}", e),
};
// getting exising config files
let cfg_files: Vec<&Path> = expected_cfg_files let cfg_files: Vec<&Path> = expected_cfg_files
.iter() .iter()
.map(|file| Path::new(file)) .map(|file| Path::new(file))
.filter(|file| file.exists()) .filter(|file| file.exists())
.collect(); .collect();
println!("{:#?}", cfg_files);
if cfg_files.len() <= 0 { // writing default config if non exist
let status = Config::write_default(expected_cfg_files[0].to_str().unwrap()); if cfg_files.len() <= 0 && args.config.is_none() {
if let Err(e) = status { if let Err(e) = Config::write_default(match expected_cfg_files[0].to_str() {
return Err(ExitError::ConfigError(format!( Some(s) => s,
"Could not write to default cfg location: {:#?}", None => panic!("Could not resolve expected cfg file paths"),
e }) {
))); panic!("Could not write config: {:?}", e);
} }
} }
let cfg_file = match cfg_files.last() { // set witch config file to load
None => expected_cfg_files[0].to_str().unwrap(), let cfg_file = match args.config {
Some(file) => file.to_str().unwrap(), Some(file) => file,
None => match cfg_files.last() {
None => expected_cfg_files[0].to_string_lossy().to_string(),
Some(file) => file.to_string_lossy().to_string(),
},
}; };
let cfg = Config::load(cfg_file).unwrap(); // show current config file or just log it based on args
if args.current_config {
println!("{:#?}", cfg); print!("{}", &cfg_file);
let data_dir = match &cfg.notes_dir { return;
Some(dir) => get_data_dir(dir), } else {
_ => { log::debug!("config file: {}", &cfg_file);
return Err(ExitError::ConfigError(
"Could not get notes dir from config".to_string(),
))
} }
// load config file
let cfg = match Config::load(&cfg_file) {
Ok(cfg) => cfg,
Err(_e) => panic!("could not load config: {}", cfg_file),
}; };
log::debug!("{:#?}", cfg);
if !metadata(&data_dir).is_ok() { // resolve data directory and create it if it does not exisit
match create_dir_all(&data_dir) { let data_dir = cfg.notes_dir.resolve().to_path_buf();
Err(e) => { if !fs::metadata(&data_dir).is_ok() {
return Err(ExitError::IOError( match fs::create_dir_all(&data_dir) {
format!( Err(_e) => panic!("Could not create default directory: {:?}", &data_dir),
"Could not create defult directory: {}", _ => log::info!("created dir {}", &data_dir.to_string_lossy()),
&data_dir.to_str().unwrap(),
),
e,
))
}
_ => (),
}; };
} }
println!("dir = {}", data_dir.to_str().unwrap());
let latest_file = get_latest_file(&data_dir); // get file paths of notes
println!("Latest file: {:?}", latest_file); let files = fs::read_dir(&data_dir)
.expect(format!("Could not find notes folder: {:?}", &data_dir).as_str())
.filter_map(|f| f.ok())
.map(|file| file.path());
// list all notes
if args.list_all {
files
.into_iter()
.for_each(|f| println!("{}", f.canonicalize().unwrap().to_string_lossy()));
return ();
}
let now = Local::now(); // get clossest files to specified date
let today = NaiveDate::from_ymd_opt(now.year(), now.month(), now.day()).unwrap(); let today = Local::now().date_naive();
let target = if let Some(date_str) = args.date {
cli::smart_parse_date(&date_str, &today).expect("Could not parse date")
} else {
today - TimeDelta::try_days(args.previous.into()).unwrap()
};
let closest_files = TodoFile::get_closest_files(files.collect(), target, args.number);
// list files
if args.list {
println!("Today - n\tFile");
closest_files.into_iter().for_each(|f| {
println!(
"{}\t\t{}",
(today - f.date).num_days(),
f.file.canonicalize().unwrap().to_string_lossy(),
)
});
return ();
}
// TODO: If the user did not pick a date that exist they should have the
// option to updated their choice
let latest_file = closest_files.first();
let current_file = match latest_file { let current_file = match latest_file {
Ok(todo_file) if todo_file.date < today => { // copy old file if the user specifies today's notes but it does not exist
println!("Today's file does not exist, creating"); Some(todo_file) if todo_file.date < today && args.previous == 0 => {
let mut extension_options = Extension::default();
extension_options.tasklist = true;
let mut parse_options = Parse::default();
parse_options.relaxed_tasklist_matching = true;
let options = &Options {
extension: extension_options,
parse: parse_options,
..Options::default()
};
let sections = &cfg.sections;
log::info!("looking for sections: {:?}", sections);
let arena = Arena::new(); let arena = Arena::new();
// attempt to load file
let root = { let root = {
let contents = load_file(&todo_file); log::info!(
let root = parse_todo_file(&contents, &arena); "loading and parsing file: {}",
todo_file.file.to_string_lossy()
);
let contents = file::load_file(&todo_file);
let root = comrak::parse_document(&arena, &contents, options);
root root
}; };
log::trace!("file loaded");
println!("{:#?}", root); let sect = extract_sections(root, &sections);
println!("======================================================="); let date = format!("{}-{:02}-{:02}", today.year(), today.month(), today.day());
let sections = &cfg.sections.unwrap(); // generate string for new file and write to filesystem
let groups = extract_secitons(root, sections); let new_doc = file::create_new_doc(&arena, &date, sect);
println!("{:#?}", groups);
let level = groups.values().map(|group| group.level).min().unwrap_or(2); process_doc_tree(root, &date, &sections);
let data = sections let mut new_content = String::new();
.iter() format_commonmark(new_doc, options, &mut new_content);
.map(|section| match groups.get(section) {
Some(group) => group.clone(),
None => TaskGroup::empty(section.to_string(), level),
})
.collect();
// let new_file = write_file(&data_dir, &today, &data); let file_path = file::get_filepath(&data_dir, &today);
log::info!("writing to file: {}", file_path.to_string_lossy());
let content = generate_file_content(&data, &today); file::write_file(&file_path, &new_content);
let file_path = get_filepath(&data_dir, &today); // return file name
write_file(&file_path, &content);
file_path file_path
} }
Err(_) => { // returning the selected file
println!("No files in dir: {:}", cfg.notes_dir.unwrap()); Some(todo_file) => todo_file.file.to_owned(),
let sections = &cfg.sections.unwrap(); // no note files exist creating based on template from config
None => {
// generate empty file
let sections = &cfg.sections;
log::info!("creating new empty file with sections: {:?}", sections);
let data = sections let data = sections
.iter() .iter()
.map(|sec| TaskGroup::empty(sec.clone(), 2)) .map(|sec| TaskGroup::empty(sec.clone(), 2))
.collect(); .collect();
let content = file::generate_file_content(&data, &today);
let content = generate_file_content(&data, &today); let file_path = file::get_filepath(&data_dir, &today);
let file_path = get_filepath(&data_dir, &today); file::write_file(&file_path, &content);
write_file(&file_path, &content); log::info!("writing to file: {}", file_path.to_string_lossy());
// return file name
file_path file_path
} }
Ok(todo_file) => {
println!("Today's file was created");
todo_file.file.path()
}
}; };
Command::new(cfg.editor.expect("Could not resolve editor from config")) // opening file
log::info!(
"Opening {} in {}",
current_file.to_string_lossy(),
cfg.editor
);
Command::new(&cfg.editor)
.args([current_file]) .args([current_file])
.status() .status()
.expect(format!("failed to launch editor {}", "vim").as_str()); .expect(format!("failed to launch editor {}", &cfg.editor).as_str());
Ok(())
}
fn get_filepath(data_dir: &PathBuf, date: &NaiveDate) -> PathBuf {
let file_name = format!("{}-{:02}-{:02}.md", date.year(), date.month(), date.day());
let mut file_path = data_dir.clone();
file_path.push(file_name);
file_path
}
fn generate_file_content(data: &Vec<TaskGroup>, date: &NaiveDate) -> String {
let mut content = format!(
"# Today's tasks {}-{:02}-{:02}\n",
date.year(),
date.month(),
date.day()
);
data.iter()
.for_each(|task_group| content.push_str(format!("\n{}", task_group.to_string()).as_str()));
content
}
fn write_file(path: &PathBuf, content: &String) {
let mut new_file = File::create(&path).expect("Could not open today's file: {today_file_path}");
write!(new_file, "{}", content).expect("Could not write to file: {today_file_path}");
}
fn load_file(file: &TodoFile) -> String {
let contents_utf8 = read(file.file.path())
.expect(format!("Could not read file {}", file.file.path().to_string_lossy()).as_str());
str::from_utf8(&contents_utf8)
.expect(
format!(
"failed to convert contents of file to string: {}",
file.file.path().to_string_lossy()
)
.as_str(),
)
.to_string()
}
fn parse_todo_file<'a>(contents: &String, arena: &'a Arena<AstNode<'a>>) -> &'a AstNode<'a> {
let options = &ComrakOptions {
extension: ComrakExtensionOptions {
tasklist: true,
..ComrakExtensionOptions::default()
},
parse: ComrakParseOptions {
relaxed_tasklist_matching: true,
..ComrakParseOptions::default()
},
..ComrakOptions::default()
};
parse_document(arena, contents, options)
}
fn extract_secitons<'a>(
root: &'a AstNode<'a>,
sections: &Vec<String>,
) -> HashMap<String, TaskGroup> {
let mut groups: HashMap<String, TaskGroup> = HashMap::new();
for node in root.reverse_children() {
let node_ref = &node.data.borrow();
if let NodeValue::Heading(heading) = node_ref.value {
if heading.level < 2 {
continue;
}
let first_child_ref = &node.first_child();
let first_child = if let Some(child) = first_child_ref.borrow() {
child
} else {
continue;
};
let data_ref = &first_child.data.borrow();
let title = if let NodeValue::Text(value) = &data_ref.value {
value
} else {
continue;
};
println!("Attempting to parse {}", title);
if sections.iter().any(|section| section.eq(title)) {
if let Ok(mut group) = TaskGroup::try_from(node) {
group.tasks = group
.tasks
.into_iter()
.filter(|task| !matches!(task.status, TaskStatus::Done(_)))
.collect();
groups.insert(title.to_string(), group);
}
}
};
}
groups
}
fn get_data_dir(dir_name: &str) -> PathBuf {
let mut dir = match env::var("HOME") {
Ok(home) => {
let mut x = PathBuf::new();
x.push(home);
x
}
_ => env::current_dir().expect("PWD environment variable not set"),
};
dir = dir.join(dir_name);
dir
}
fn get_latest_file(dir: &Path) -> Result<TodoFile, String> {
let dir = read_dir(dir).expect(format!("Could not find notes folder: {:?}", dir).as_str());
dir.filter_map(|f| f.ok())
.filter_map(|file| TodoFile::try_from(file).ok())
.reduce(|a, b| TodoFile::latest_file(a, b))
.ok_or("Could not reduce items".to_string())
} }

View File

@@ -1,69 +1,116 @@
use chrono::naive::NaiveDate; use chrono::naive::NaiveDate;
use regex::Regex; use std::cmp::min;
use std::convert::TryFrom; use std::convert::TryFrom;
use std::fs::DirEntry; use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug)] use crate::file::FileNameParseError;
#[derive(Debug, Clone, PartialEq)]
pub struct File { pub struct File {
pub file: DirEntry, pub file: PathBuf,
pub date: NaiveDate, pub date: NaiveDate,
} }
pub enum FileError{ fn try_get_date(file: &PathBuf) -> Result<NaiveDate, FileNameParseError> {
//IOError(&'static str), let file_name = file
ParseError(&'static str) .file_name()
.ok_or(FileNameParseError::TypeConversionError(
"Could not get filename from path: {:?}",
))?
.to_str()
.ok_or(FileNameParseError::TypeConversionError(
"Could not get filename from path: {:?}",
))?;
NaiveDate::parse_from_str(file_name, "%Y-%m-%d.md")
.or_else(|e| Err(FileNameParseError::ParseError(e)))
} }
impl TryFrom<PathBuf> for File {
type Error = FileNameParseError;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
Ok(Self {
date: try_get_date(&path)?,
file: path.into(),
})
}
}
impl File { impl File {
fn capture_as_number<T: FromStr>(capture: &regex::Captures, name: &str) -> Result<T, String> { pub fn get_closest_files(files: Vec<PathBuf>, target: NaiveDate, n: usize) -> Vec<File> {
Ok(capture let mut dated_files = files
.name(name) .into_iter()
.unwrap() .filter_map(|file| File::try_from(file).ok())
.as_str() .collect::<Vec<_>>();
.parse::<T>() dated_files.sort_by_cached_key(|dated_file| (dated_file.date - target).num_days().abs());
.ok()
.ok_or("Something went wrong".to_owned())?)
}
pub fn latest_file(a: File, b: File) -> File { let count = min(n, dated_files.len());
if a.date > b.date { dated_files[..count].to_vec()
a
} else {
b
} }
} }
fn get_file_regex() -> Regex { #[cfg(test)]
//TODO This would ideally be configurable mod test {
Regex::new(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}).md") use super::*;
.expect("could not create regex") use chrono::NaiveDate;
} use std::path::PathBuf;
}
#[test]
impl TryFrom<DirEntry> for File { fn test_get_closest_date() {
type Error = FileError; let files = vec![
PathBuf::from("./2024-01-01.md"),
fn try_from(direntry: DirEntry) -> Result<Self, Self::Error> { PathBuf::from("./2024-01-02.md"),
let re = File::get_file_regex(); PathBuf::from("./2024-01-03.md"),
// println!("{:?}", re); PathBuf::from("./2024-02-01.md"),
let file_name = direntry.file_name(); PathBuf::from("./2024-03-01.md"),
let file_name_str = match file_name.to_str() { PathBuf::from("./2024-04-01.md"),
Some(name) => name, PathBuf::from("./2024-04-02.md"),
_ => "", PathBuf::from("./2024-04-03.md"),
}; PathBuf::from("./2024-04-04.md"),
// println!("{:?}", file_name_str); ];
if let Some(caps) = re.captures(file_name_str) { let res = File::get_closest_files(
let year: i32 = Self::capture_as_number(&caps, "year").unwrap(); files.clone(),
let month: u32 = Self::capture_as_number(&caps, "month").unwrap(); NaiveDate::from_ymd_opt(2023, 12, 30).unwrap(),
let day: u32 = Self::capture_as_number(&caps, "day").unwrap(); 3,
);
return Ok(Self { let expected_res = vec![
file: direntry, File::try_from(PathBuf::from("./2024-01-01.md")).unwrap(),
date: NaiveDate::from_ymd_opt(year, month, day).unwrap(), File::try_from(PathBuf::from("./2024-01-02.md")).unwrap(),
}); File::try_from(PathBuf::from("./2024-01-03.md")).unwrap(),
}; ];
Err(FileError::ParseError("Could not parse file name")) assert_eq!(res, expected_res);
let res = File::get_closest_files(
files.clone(),
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
3,
);
let expected_res = vec![
File::try_from(PathBuf::from("./2024-02-01.md")).unwrap(),
File::try_from(PathBuf::from("./2024-01-03.md")).unwrap(),
File::try_from(PathBuf::from("./2024-03-01.md")).unwrap(),
];
assert_eq!(res, expected_res);
let res = File::get_closest_files(
files.clone(),
NaiveDate::from_ymd_opt(2024, 5, 2).unwrap(),
3,
);
let expected_res = vec![
File::try_from(PathBuf::from("./2024-04-04.md")).unwrap(),
File::try_from(PathBuf::from("./2024-04-03.md")).unwrap(),
File::try_from(PathBuf::from("./2024-04-02.md")).unwrap(),
];
assert_eq!(res, expected_res);
let res = File::get_closest_files(
files[..1].to_vec(),
NaiveDate::from_ymd_opt(2023, 12, 30).unwrap(),
3,
);
let expected_res = vec![File::try_from(PathBuf::from("./2024-01-01.md")).unwrap()];
assert_eq!(res, expected_res);
} }
} }

View File

@@ -1,9 +1,8 @@
use std::borrow::Borrow; use std::borrow::Borrow;
use comrak::nodes::AstNode; use comrak::nodes::{AstNode, NodeTaskItem, NodeValue};
use comrak::nodes::NodeValue;
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct TaskGroup { pub struct TaskGroup {
pub name: String, pub name: String,
pub tasks: Vec<Task>, pub tasks: Vec<Task>,
@@ -11,7 +10,7 @@ pub struct TaskGroup {
} }
// This does not support subtasks, need to figure out best path forward // This does not support subtasks, need to figure out best path forward
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct Task { pub struct Task {
pub status: Status, pub status: Status,
pub text: String, pub text: String,
@@ -46,7 +45,7 @@ impl Task {
for child in node.children() { for child in node.children() {
let child_data_ref = child.data.borrow(); let child_data_ref = child.data.borrow();
let t = match &child_data_ref.borrow().value { let t = match &child_data_ref.borrow().value {
NodeValue::Text(contents) => contents.clone(), NodeValue::Text(contents) => contents.to_string(),
NodeValue::Emph if child.first_child().is_some() => { NodeValue::Emph if child.first_child().is_some() => {
format!("*{}*", Self::extract_text(child.first_child().unwrap())?) format!("*{}*", Self::extract_text(child.first_child().unwrap())?)
} }
@@ -92,6 +91,7 @@ impl ToString for Task {
impl<'a> TryFrom<&'a AstNode<'a>> for Task { impl<'a> TryFrom<&'a AstNode<'a>> for Task {
type Error = TaskError; type Error = TaskError;
fn try_from(node: &'a AstNode<'a>) -> Result<Self, Self::Error> { fn try_from(node: &'a AstNode<'a>) -> Result<Self, Self::Error> {
let data_ref = &node.data.borrow(); let data_ref = &node.data.borrow();
if let NodeValue::TaskItem(ch) = data_ref.value { if let NodeValue::TaskItem(ch) = data_ref.value {
@@ -100,8 +100,14 @@ impl<'a> TryFrom<&'a AstNode<'a>> for Task {
.ok_or(TaskError::ParsingError("No childern of node found"))?, .ok_or(TaskError::ParsingError("No childern of node found"))?,
)?; )?;
let status = match ch { let status = match ch {
Some(c) if c == 'x' || c == 'X' => Status::Done(c), NodeTaskItem {
Some(c) => Status::Todo(c), symbol: Some(c),
symbol_sourcepos: _,
} if c == 'x' || c == 'X' => Status::Done(c),
NodeTaskItem {
symbol: Some(c),
symbol_sourcepos: _,
} => Status::Todo(c),
_ => Status::Empty, _ => Status::Empty,
}; };
let subtasks = node let subtasks = node
@@ -158,25 +164,23 @@ impl ToString for TaskGroup {
impl<'a> TryFrom<&'a AstNode<'a>> for TaskGroup { impl<'a> TryFrom<&'a AstNode<'a>> for TaskGroup {
type Error = TaskError; type Error = TaskError;
fn try_from(node: &'a AstNode<'a>) -> Result<Self, Self::Error> { fn try_from(node: &'a AstNode<'a>) -> Result<Self, Self::Error> {
let node_ref = &node.data.borrow(); let node_ref = &node.data.borrow();
if let NodeValue::Heading(heading) = node_ref.value { if let NodeValue::Heading(heading) = node_ref.value {
let level = heading.level; let level = heading.level;
let first_child_ref = &node.first_child(); let first_child = node
let first_child = if let Some(child) = first_child_ref.borrow() { .first_child()
child .ok_or(TaskError::ParsingError("Node has no children"))?;
} else {
return Err(TaskError::ParsingError("Node has no children"));
};
let data_ref = &first_child.data.borrow(); let data_ref = first_child.data.borrow();
let name = if let NodeValue::Text(value) = &data_ref.value { let name = if let NodeValue::Text(value) = &data_ref.value {
value.to_string() Ok(value.to_string())
} else { } else {
return Err(TaskError::ParsingError( Err(TaskError::ParsingError(
"Could not get title from heading node", "Could not get title from heading node",
)); ))
}; }?;
let next_sib = node let next_sib = node
.next_sibling() .next_sibling()