Added Todo parser, file open dialog

This commit is contained in:
2026-03-21 09:51:10 +10:00
committed by professor
parent b9c1315974
commit 40d4ca363b
8 changed files with 10215 additions and 119 deletions
Generated
+9428
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,14 +7,17 @@ description = "A COSMIC todo.txt application"
repository = "https://github.com/professorwaltwood/planit.git" repository = "https://github.com/professorwaltwood/planit.git"
[dependencies] [dependencies]
cosmic-files = { git = "https://github.com/pop-os/cosmic-files.git", version = "1.0.8" }
futures-util = "0.3.31" futures-util = "0.3.31"
i18n-embed = { version = "0.16", features = [ i18n-embed = { version = "0.16", features = [
"fluent-system", "fluent-system",
"desktop-requester", "desktop-requester",
] } ] }
i18n-embed-fl = "0.10" i18n-embed-fl = "0.10"
log = "0.4.29"
open = "5.3.2" open = "5.3.2"
rust-embed = "8.8.0" rust-embed = "8.8.0"
time = { version = "0.3.47", features = ["macros", "parsing"] }
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
[dependencies.libcosmic] [dependencies.libcosmic]
@@ -22,7 +25,7 @@ git = "https://github.com/pop-os/libcosmic.git"
# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features. # See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features.
features = [ features = [
# Accessibility support # Accessibility support
"a11y", #"a11y",
# About widget for the app # About widget for the app
"about", "about",
# Uses cosmic-settings-daemon to watch for config file changes # Uses cosmic-settings-daemon to watch for config file changes
@@ -30,7 +33,7 @@ features = [
# Support creating additional application windows. # Support creating additional application windows.
"multi-window", "multi-window",
# On app startup, focuses an existing instance if the app is already open # On app startup, focuses an existing instance if the app is already open
"single-instance", #"single-instance",
# Uses tokio as the executor for the runtime # Uses tokio as the executor for the runtime
"tokio", "tokio",
# Windowing support for X11, Windows, Mac, & Redox # Windowing support for X11, Windows, Mac, & Redox
+3 -1
View File
@@ -2,6 +2,8 @@ app-title = Planit
about = About about = About
repository = Repository repository = Repository
view = View view = View
welcome = Welcome to COSMIC! ✨ welcome = Welcome to Planit!
page-id = Page { $num } page-id = Page { $num }
git-description = Git commit {$hash} on {$date} git-description = Git commit {$hash} on {$date}
file = File
open = Open File
+20
View File
@@ -0,0 +1,20 @@
test todo list +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
x 2020-01-01 2020-01-02 test having lots of items +planit @tests
2020-01-01 test having lots of items +planit @tests
(B) test having lots of items +planit @tests
x (A) test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests due:2020-02-01
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
test having lots of items +planit @tests
+105 -104
View File
@@ -5,13 +5,19 @@ use crate::fl;
use cosmic::app::context_drawer; use cosmic::app::context_drawer;
use cosmic::cosmic_config::{self, CosmicConfigEntry}; use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::iced::alignment::{Horizontal, Vertical}; use cosmic::iced::alignment::{Horizontal, Vertical};
use cosmic::iced::futures::channel::mpsc::Sender;
use cosmic::iced::{Alignment, Length, Subscription}; use cosmic::iced::{Alignment, Length, Subscription};
use cosmic::widget::{self, about::About, icon, menu, nav_bar}; use cosmic::widget::{self, about::About, menu};
use cosmic::{iced_futures, prelude::*}; use cosmic::{Theme, iced_futures, prelude::*};
use futures_util::SinkExt; use futures_util::SinkExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::path;
use std::time::Duration; use std::time::Duration;
use cosmic_files::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
use crate::todo::{Todo, TodoPart};
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg"); const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg");
@@ -24,8 +30,6 @@ pub struct AppModel {
context_page: ContextPage, context_page: ContextPage,
/// The about page for this app. /// The about page for this app.
about: About, about: About,
/// Contains items assigned to the nav bar panel.
nav: nav_bar::Model,
/// Key bindings for the application's menu bar. /// Key bindings for the application's menu bar.
key_binds: HashMap<menu::KeyBind, MenuAction>, key_binds: HashMap<menu::KeyBind, MenuAction>,
/// Configuration data that persists between application runs. /// Configuration data that persists between application runs.
@@ -34,6 +38,9 @@ pub struct AppModel {
time: u32, time: u32,
/// Toggle the watch subscription /// Toggle the watch subscription
watch_is_active: bool, watch_is_active: bool,
/// Current Todo data
todo: Option<Todo>,
dialog_opt: Option<Dialog<Message>>,
} }
/// Messages emitted by the application and its widgets. /// Messages emitted by the application and its widgets.
@@ -41,9 +48,11 @@ pub struct AppModel {
pub enum Message { pub enum Message {
LaunchUrl(String), LaunchUrl(String),
ToggleContextPage(ContextPage), ToggleContextPage(ContextPage),
ToggleWatch,
UpdateConfig(Config), UpdateConfig(Config),
WatchTick(u32), WatchTick(u32),
OpenFileDialog,
DialogMessage(DialogMessage),
OpenFileResult(DialogResult),
} }
/// Create a COSMIC application from the app model /// Create a COSMIC application from the app model
@@ -58,7 +67,7 @@ impl cosmic::Application for AppModel {
type Message = Message; type Message = Message;
/// Unique identifier in RDNN (reverse domain name notation) format. /// Unique identifier in RDNN (reverse domain name notation) format.
const APP_ID: &'static str = "dev.mmurphy.Test"; const APP_ID: &'static str = "dev.waltwood.planit";
fn core(&self) -> &cosmic::Core { fn core(&self) -> &cosmic::Core {
&self.core &self.core
@@ -73,25 +82,6 @@ impl cosmic::Application for AppModel {
core: cosmic::Core, core: cosmic::Core,
_flags: Self::Flags, _flags: Self::Flags,
) -> (Self, Task<cosmic::Action<Self::Message>>) { ) -> (Self, Task<cosmic::Action<Self::Message>>) {
// Create a nav bar with three page items.
let mut nav = nav_bar::Model::default();
nav.insert()
.text(fl!("page-id", num = 1))
.data::<Page>(Page::Page1)
.icon(icon::from_name("applications-science-symbolic"))
.activate();
nav.insert()
.text(fl!("page-id", num = 2))
.data::<Page>(Page::Page2)
.icon(icon::from_name("applications-system-symbolic"));
nav.insert()
.text(fl!("page-id", num = 3))
.data::<Page>(Page::Page3)
.icon(icon::from_name("applications-games-symbolic"));
// Create the about widget // Create the about widget
let about = About::default() let about = About::default()
.name(fl!("app-title")) .name(fl!("app-title"))
@@ -105,7 +95,6 @@ impl cosmic::Application for AppModel {
core, core,
context_page: ContextPage::default(), context_page: ContextPage::default(),
about, about,
nav,
key_binds: HashMap::new(), key_binds: HashMap::new(),
// Optional configuration file for an application. // Optional configuration file for an application.
config: cosmic_config::Config::new(Self::APP_ID, Config::VERSION) config: cosmic_config::Config::new(Self::APP_ID, Config::VERSION)
@@ -122,6 +111,8 @@ impl cosmic::Application for AppModel {
.unwrap_or_default(), .unwrap_or_default(),
time: 0, time: 0,
watch_is_active: false, watch_is_active: false,
todo: None,
dialog_opt: None,
}; };
// Create a startup command that sets the window title. // Create a startup command that sets the window title.
@@ -132,22 +123,26 @@ impl cosmic::Application for AppModel {
/// Elements to pack at the start of the header bar. /// Elements to pack at the start of the header bar.
fn header_start(&self) -> Vec<Element<'_, Self::Message>> { fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
let menu_bar = menu::bar(vec![menu::Tree::with_children( let menu_bar = menu::bar(vec![
menu::Tree::with_children(
menu::root(fl!("file")).apply(Element::from),
menu::items(
&self.key_binds,
vec![menu::Item::Button(fl!("open"), None, MenuAction::OpenFile)],
),
),
menu::Tree::with_children(
menu::root(fl!("view")).apply(Element::from), menu::root(fl!("view")).apply(Element::from),
menu::items( menu::items(
&self.key_binds, &self.key_binds,
vec![menu::Item::Button(fl!("about"), None, MenuAction::About)], vec![menu::Item::Button(fl!("about"), None, MenuAction::About)],
), ),
)]); ),
]);
vec![menu_bar.into()] vec![menu_bar.into()]
} }
/// Enables the COSMIC application to create a nav bar with this model.
fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav)
}
/// Display a context drawer if the context page is requested. /// Display a context drawer if the context page is requested.
fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> { fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
if !self.core.window.show_context { if !self.core.window.show_context {
@@ -169,62 +164,29 @@ impl cosmic::Application for AppModel {
/// events received by widgets will be passed to the update method. /// events received by widgets will be passed to the update method.
fn view(&self) -> Element<'_, Self::Message> { fn view(&self) -> Element<'_, Self::Message> {
let space_s = cosmic::theme::spacing().space_s; let space_s = cosmic::theme::spacing().space_s;
let content: Element<_> = match self.nav.active_data::<Page>().unwrap() {
Page::Page1 => {
let header = widget::row::with_capacity(2) let header = widget::row::with_capacity(2)
.push(widget::text::title1(fl!("welcome"))) .push(widget::text::title1(fl!("welcome")))
.push(widget::text::title3(fl!("page-id", num = 1)))
.align_y(Alignment::End) .align_y(Alignment::End)
.spacing(space_s); .spacing(space_s);
let counter_label = ["Watch: ", self.time.to_string().as_str()].concat(); let section;
let section = cosmic::widget::settings::section().add(
cosmic::widget::settings::item::builder(counter_label).control(
widget::button::text(if self.watch_is_active {
"Stop"
} else {
"Start"
})
.on_press(Message::ToggleWatch),
),
);
widget::column::with_capacity(2) if let Some(todo) = self.todo.as_ref() {
section = widget::Column::with_children(
todo.todos.iter().map(|item| view_todo(item.get_str())),
)
.spacing(20);
} else {
section = widget::Column::new()
}
let content: Element<_> = widget::column::with_capacity(2)
.push(header) .push(header)
.push(section) .push(section)
.spacing(space_s) .spacing(space_s)
.height(Length::Fill) .height(Length::Fill)
.into() .into();
}
Page::Page2 => {
let header = widget::row::with_capacity(2)
.push(widget::text::title1(fl!("welcome")))
.push(widget::text::title3(fl!("page-id", num = 2)))
.align_y(Alignment::End)
.spacing(space_s);
widget::column::with_capacity(1)
.push(header)
.spacing(space_s)
.height(Length::Fill)
.into()
}
Page::Page3 => {
let header = widget::row::with_capacity(2)
.push(widget::text::title1(fl!("welcome")))
.push(widget::text::title3(fl!("page-id", num = 3)))
.align_y(Alignment::End)
.spacing(space_s);
widget::column::with_capacity(1)
.push(header)
.spacing(space_s)
.height(Length::Fill)
.into()
}
};
widget::container(content) widget::container(content)
.width(600) .width(600)
@@ -260,7 +222,7 @@ impl cosmic::Application for AppModel {
// Conditionally enables a timer that emits a message every second. // Conditionally enables a timer that emits a message every second.
if self.watch_is_active { if self.watch_is_active {
subscriptions.push(Subscription::run(|| { subscriptions.push(Subscription::run(|| {
iced_futures::stream::channel(1, |mut emitter| async move { iced_futures::stream::channel(1, |mut emitter: Sender<Message>| async move {
let mut time = 1; let mut time = 1;
let mut interval = tokio::time::interval(Duration::from_secs(1)); let mut interval = tokio::time::interval(Duration::from_secs(1));
@@ -285,11 +247,6 @@ impl cosmic::Application for AppModel {
Message::WatchTick(time) => { Message::WatchTick(time) => {
self.time = time; self.time = time;
} }
Message::ToggleWatch => {
self.watch_is_active = !self.watch_is_active;
}
Message::ToggleContextPage(context_page) => { Message::ToggleContextPage(context_page) => {
if self.context_page == context_page { if self.context_page == context_page {
// Close the context drawer if the toggled context page is the same. // Close the context drawer if the toggled context page is the same.
@@ -300,39 +257,71 @@ impl cosmic::Application for AppModel {
self.core.window.show_context = true; self.core.window.show_context = true;
} }
} }
Message::UpdateConfig(config) => { Message::UpdateConfig(config) => {
self.config = config; self.config = config;
} }
Message::LaunchUrl(url) => match open::that_detached(&url) { Message::LaunchUrl(url) => match open::that_detached(&url) {
Ok(()) => {} Ok(()) => {}
Err(err) => { Err(err) => {
eprintln!("failed to open {url:?}: {err}"); eprintln!("failed to open {url:?}: {err}");
} }
}, },
Message::OpenFileDialog => {
if self.dialog_opt.is_none() {
let (dialog, command) = Dialog::new(
DialogSettings::new().kind(DialogKind::OpenFile),
Message::DialogMessage,
Message::OpenFileResult,
);
self.dialog_opt = Some(dialog);
return command;
}
}
Message::DialogMessage(dialog_message) => {
if let Some(dialog) = &mut self.dialog_opt {
return dialog.update(dialog_message);
}
}
Message::OpenFileResult(dialog_result) => match dialog_result {
DialogResult::Open(paths) => {
for path in paths {
let cpath = match std::fs::canonicalize(&path) {
Ok(ok) => ok,
Err(err) => match path::absolute(&path) {
Ok(ok) => ok,
Err(_) => {
log::error!("failed to canonicalise path {:?}: {}", path, err);
return Task::none();
}
},
};
let mut todo = Todo::new(cpath);
// Todo: this might need to be more async
if let Err(err) = todo.load() {
log::error!("failed to load file {:?} {:?}", path, err);
}
self.todo = Some(todo);
}
}
DialogResult::Cancel => todo!(),
},
} }
Task::none() Task::none()
} }
/// Called when a nav item is selected. fn view_window(&self, id: cosmic::iced::window::Id) -> Element<'_, Self::Message> {
fn on_nav_select(&mut self, id: nav_bar::Id) -> Task<cosmic::Action<Self::Message>> { match &self.dialog_opt {
// Activate the page in the model. Some(dialog) => dialog.view(id),
self.nav.activate(id); None => widget::text("Unknown window ID").into(),
}
self.update_title()
} }
} }
impl AppModel { impl AppModel {
/// Updates the header and window titles. /// Updates the header and window titles.
pub fn update_title(&mut self) -> Task<cosmic::Action<Message>> { pub fn update_title(&mut self) -> Task<cosmic::Action<Message>> {
let mut window_title = fl!("app-title"); let window_title = fl!("app-title");
if let Some(page) = self.nav.text(self.nav.active()) {
window_title.push_str(" — ");
window_title.push_str(page);
}
if let Some(id) = self.core.main_window_id() { if let Some(id) = self.core.main_window_id() {
self.set_window_title(window_title, id) self.set_window_title(window_title, id)
@@ -342,11 +331,21 @@ impl AppModel {
} }
} }
/// The page to display in the application. fn view_todo<'a>(todo: Vec<TodoPart<'a>>) -> Element<'a, Message> {
pub enum Page { widget::row::with_children(todo.iter().map(|part| view_text(*part).into())).into()
Page1, }
Page2,
Page3, fn view_text<'a>(text: TodoPart<'a>) -> widget::Text<'a, Theme, Renderer> {
let theme = &cosmic::theme::COSMIC_DARK;
let colour = match text {
TodoPart::Context(_) => theme.accent_color(),
TodoPart::Description(_) => theme.on_primary_component_color(),
TodoPart::Project(_) => theme.palette.accent_green,
TodoPart::Tag(_) => theme.palette.accent_purple,
};
let colour = cosmic::iced::Color::from(colour);
let text: &str = text.into();
widget::text(text).class(colour)
} }
/// The context page to display in the context drawer. /// The context page to display in the context drawer.
@@ -359,6 +358,7 @@ pub enum ContextPage {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MenuAction { pub enum MenuAction {
About, About,
OpenFile,
} }
impl menu::action::MenuAction for MenuAction { impl menu::action::MenuAction for MenuAction {
@@ -367,6 +367,7 @@ impl menu::action::MenuAction for MenuAction {
fn message(&self) -> Self::Message { fn message(&self) -> Self::Message {
match self { match self {
MenuAction::About => Message::ToggleContextPage(ContextPage::About), MenuAction::About => Message::ToggleContextPage(ContextPage::About),
MenuAction::OpenFile => Message::OpenFileDialog,
} }
} }
} }
-2
View File
@@ -37,7 +37,6 @@ pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
loader loader
}); });
/// Request a localized string by ID from the i18n/ directory. /// Request a localized string by ID from the i18n/ directory.
#[macro_export] #[macro_export]
macro_rules! fl { macro_rules! fl {
@@ -49,4 +48,3 @@ macro_rules! fl {
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *) i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *)
}}; }};
} }
+1
View File
@@ -3,6 +3,7 @@
mod app; mod app;
mod config; mod config;
mod i18n; mod i18n;
mod todo;
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
// Get the system's preferred languages. // Get the system's preferred languages.
+643
View File
@@ -0,0 +1,643 @@
use std::{
collections::{HashMap, HashSet},
fs,
hash::{BuildHasher, DefaultHasher, Hash, Hasher},
io::{self, BufRead, BufReader},
path::PathBuf,
pin::Pin,
ptr::NonNull,
};
use time::{Date, macros::format_description};
const DATE_FMT: time::format_description::StaticFormatDescription =
format_description!("[year]-[month]-[day]");
pub struct Todo {
/// Location of todo.txt file
location: PathBuf,
/// Vec of todo items (i.e., parsed lines of the todo.txt)
pub todos: Vec<TodoItem>,
}
#[derive(Debug)]
pub enum TodoError {
Parse,
Io(io::Error),
}
impl From<io::Error> for TodoError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
impl Todo {
pub fn new(loc: PathBuf) -> Self {
Self {
location: loc,
todos: Vec::new(),
}
}
pub fn load(&mut self) -> Result<(), TodoError> {
self.reload()
}
pub fn reload(&mut self) -> Result<(), TodoError> {
let file = fs::File::open(&self.location)?;
let reader = BufReader::new(file);
let mut todos = Vec::new();
for line in reader.lines() {
// Pin the string so it can be referenced but cruicially not modified
let line = Pin::new(line?);
todos.push(line.try_into()?);
}
self.todos = todos;
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum TodoPart<'a> {
Description(&'a str),
Project(&'a str),
Context(&'a str),
Tag(&'a str),
}
impl<'a> Into<&'a str> for TodoPart<'a> {
fn into(self) -> &'a str {
match self {
TodoPart::Description(s) => s,
TodoPart::Project(s) => s,
TodoPart::Context(s) => s,
TodoPart::Tag(s) => s,
}
}
}
#[derive(Debug, Clone, Copy)]
enum TodoPartRaw {
Description(NonNull<str>),
Project(NonNull<str>),
Context(NonNull<str>),
Tag(NonNull<str>),
}
impl<'a> Into<TodoPart<'a>> for TodoPartRaw {
fn into<'b>(self) -> TodoPart<'a> {
match self {
TodoPartRaw::Description(s) => TodoPart::Description(unsafe { s.as_ref() }),
TodoPartRaw::Project(s) => TodoPart::Project(unsafe { s.as_ref() }),
TodoPartRaw::Context(s) => TodoPart::Context(unsafe { s.as_ref() }),
TodoPartRaw::Tag(s) => TodoPart::Tag(unsafe { s.as_ref() }),
}
}
}
impl TodoPartRaw {
fn as_str(&self) -> &str {
match self {
TodoPartRaw::Description(s) => unsafe { s.as_ref() },
TodoPartRaw::Project(s) => unsafe { s.as_ref() },
TodoPartRaw::Context(s) => unsafe { s.as_ref() },
TodoPartRaw::Tag(s) => unsafe { s.as_ref() },
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub struct TodoItemKey(u64);
impl From<&str> for TodoItemKey {
fn from(value: &str) -> Self {
let mut s = DefaultHasher::new();
value.hash(&mut s);
Self(s.finish())
}
}
impl Hash for TodoItemKey {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_u64(self.0);
}
}
struct TodoHasher(u64);
impl Hasher for TodoHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, _: &[u8]) {}
fn write_u64(&mut self, i: u64) {
self.0 = i;
}
}
#[derive(Debug, Clone)]
struct TodoBuildHasher;
impl BuildHasher for TodoBuildHasher {
type Hasher = TodoHasher;
fn build_hasher(&self) -> Self::Hasher {
TodoHasher(0)
}
}
#[derive(Debug, Clone)]
pub struct TodoItem {
/// TodoItem owns/stores the whole here
#[allow(unused)]
raw: Pin<String>,
/// Optional - Marks Completion x
done: bool,
/// Optional - Marks Priority (A)
prio: Option<char>,
/// Optional - Completion Date
date_done: Option<Date>,
/// Optional - Creation Date (must be sepecified if completion date is)
date_made: Option<Date>,
/// Description - raw str slice for description
raw_desc: NonNull<str>,
/// Formatted Description - list of description components
desc: Vec<TodoPartRaw>,
/// Project tags
projects: HashSet<TodoItemKey, TodoBuildHasher>,
/// Context tags
contexts: HashSet<TodoItemKey, TodoBuildHasher>,
/// Special Key/Value tags
tags: HashMap<TodoItemKey, NonNull<str>, TodoBuildHasher>,
}
impl TodoItem {
pub fn is_done(&self) -> bool {
self.done
}
pub fn complete(&mut self) {
self.done = true;
}
pub fn get_priority(&self) -> Option<char> {
self.prio
}
pub fn set_priority(&mut self, prio: char) {
self.prio = Some(prio);
}
pub fn clear_priority(&mut self) {
self.prio = None;
}
pub fn get_date_completed(&self) -> Option<Date> {
self.date_done
}
pub fn set_date_completed(&mut self, date: Date) {
self.date_done = Some(date);
}
pub fn clear_date_completed(&mut self) {
self.date_done = None;
}
pub fn get_date_created(&self) -> Option<Date> {
self.date_made
}
pub fn set_date_created(&mut self, date: Date) {
self.date_made = Some(date);
}
pub fn clear_date_created(&mut self) {
self.date_made = None;
}
pub fn get_str<'a>(&'a self) -> Vec<TodoPart<'a>> {
self.desc.iter().map(|p| (*p).into()).collect()
}
fn get_raw_str(&self) -> &str {
// Safety
// ensure that the pointer is convertible to a reference.
// The pointee will always be valid for the pointer's lifetime
// as it points to a pinned string owned by the same object.
// i.e., the String will only be dropped when the pointer is
// Additionally the String is never mutated.
unsafe { self.raw_desc.as_ref() }
}
fn has_project(&self, project: TodoItemKey) -> bool {
self.projects.contains(&project)
}
pub fn get_tag<T>(&self, tag: T) -> Option<&str>
where
T: Into<TodoItemKey>,
{
let tag_pointer = self.tags.get(&tag.into())?;
// Safety: see get_raw_str
Some(unsafe { tag_pointer.as_ref() })
}
}
impl TryFrom<Pin<String>> for TodoItem {
type Error = TodoError;
fn try_from(value: Pin<String>) -> Result<Self, Self::Error> {
let raw = value;
let mut pos = 0;
let done = parse_done(&raw[pos..], &mut pos);
let prio = parse_prio(&raw[pos..], &mut pos);
let (date_done, date_made) = parse_dates(&raw[pos..], &mut pos);
if !raw[pos..].contains(|c: char| !c.is_whitespace()) {
return Err(TodoError::Parse);
}
let mut projects = HashSet::with_hasher(TodoBuildHasher);
let mut contexts = HashSet::with_hasher(TodoBuildHasher);
let mut tags = HashMap::with_hasher(TodoBuildHasher);
let raw_desc = NonNull::from_ref(&raw[pos..]);
let mut desc = Vec::new();
while let Some((size, word)) = get_word(&raw[pos..]) {
if word.starts_with('@') {
if word.len() > 1 {
contexts.insert(word.into());
desc.push(TodoPartRaw::Context(NonNull::from_ref(word)));
}
} else if word.starts_with('+') {
if word.len() > 1 {
projects.insert(word.into());
desc.push(TodoPartRaw::Project(NonNull::from_ref(word)));
}
} else if let Some((k, v)) = word.split_once(':') {
if !(k.contains(':') || v.contains(':') || k.len() == 0 || v.len() == 0) {
tags.insert(k.into(), NonNull::from_ref(v));
desc.push(TodoPartRaw::Tag(NonNull::from_ref(word)));
}
} else {
desc.push(TodoPartRaw::Description(NonNull::from_ref(word)));
}
pos += size;
}
Ok(Self {
raw,
done,
prio,
date_done,
date_made,
raw_desc,
desc,
projects,
contexts,
tags,
})
}
}
fn get_word(raw: &str) -> Option<(usize, &str)> {
let mut chars = raw.char_indices();
let (next_char, _) = chars.find(|(_, c)| !c.is_whitespace())?;
let next_ws = chars.find(|(_, c)| c.is_whitespace());
match next_ws {
Some((end, _)) => {
let next_word = chars.find(|(_, c)| !c.is_whitespace());
match next_word {
Some((nw_start, _)) => Some((nw_start, &raw[next_char..end])),
None => Some((end, &raw[next_char..end])),
}
}
None => Some((raw.len(), &raw[next_char..])),
}
}
fn parse_done(raw: &str, pos: &mut usize) -> bool {
let next_word = get_word(raw);
match next_word {
Some((size, word)) => {
let done = word == "x";
if done {
*pos += size;
}
done
}
None => false,
}
}
fn parse_prio(raw: &str, pos: &mut usize) -> Option<char> {
let next_word = get_word(raw)?;
let (size, word) = next_word;
let mut word = word.chars();
if word.next()? == '('
&& let char = word.next()?
&& char.is_ascii_uppercase()
&& word.next()? == ')'
&& word.next().is_none()
{
*pos += size;
Some(char)
} else {
None
}
}
fn parse_dates(raw: &str, pos: &mut usize) -> (Option<Date>, Option<Date>) {
let mut date_pos = 0;
let first = parse_date(raw, &mut date_pos);
if first.is_some() {
let second = parse_date(&raw[date_pos..], &mut date_pos);
*pos += date_pos;
if second.is_some() {
(first, second)
} else {
(None, first)
}
} else {
(None, None)
}
}
fn parse_date(raw: &str, pos: &mut usize) -> Option<Date> {
let (size, word) = get_word(raw)?;
if let Ok(date) = Date::parse(word, &DATE_FMT) {
*pos += size;
Some(date)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
#[test]
fn parse_from_file() {
let mut todo = Todo::new("samples/todo.txt".into());
todo.load().expect("Error loading todo file");
}
#[test]
fn parse_todo_incorrect_done() {
let todo_str = Pin::new("X (A) 2020-05-01 2020-01-01 help me please".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, false);
assert_eq!(todo.prio, None);
assert_eq!(todo.date_done, None);
assert_eq!(todo.date_made, None);
assert_eq!(
*todo.raw,
"X (A) 2020-05-01 2020-01-01 help me please".to_string()
);
assert_eq!(
todo.get_raw_str(),
"X (A) 2020-05-01 2020-01-01 help me please"
);
assert!(todo.projects.is_empty());
assert!(todo.contexts.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_todo_invalid_tags() {
let todo_str =
Pin::new("x (A) 2020-05-01 2020-01-01 help me please artist: :music".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please artist: :music".to_string()
);
assert_eq!(todo.get_raw_str(), "help me please artist: :music");
assert!(todo.projects.is_empty());
assert!(todo.contexts.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_todo_invalid_projects() {
let todo_str =
Pin::new("x (A) 2020-05-01 2020-01-01 help me please a+beatles +\tmusic".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please a+beatles +\tmusic".to_string()
);
assert_eq!(todo.get_raw_str(), "help me please a+beatles +\tmusic");
assert!(todo.projects.is_empty());
assert!(todo.contexts.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_todo_invalid_contexts() {
let todo_str =
Pin::new("x (A) 2020-05-01 2020-01-01 help me please a@beatles @\tmusic".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please a@beatles @\tmusic".to_string()
);
assert_eq!(todo.get_raw_str(), "help me please a@beatles @\tmusic");
assert!(todo.projects.is_empty());
assert!(todo.contexts.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_todo_valid_tags() {
let todo_str = Pin::new(
"x (A) 2020-05-01 2020-01-01 help me please artist:beatles category:music".to_string(),
);
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please artist:beatles category:music".to_string()
);
assert_eq!(
todo.get_raw_str(),
"help me please artist:beatles category:music"
);
assert!(todo.projects.is_empty());
assert!(todo.contexts.is_empty());
assert_eq!(todo.get_tag("artist").expect("artist in tags"), "beatles");
assert_eq!(todo.get_tag("category").expect("category in tags"), "music");
}
#[test]
fn parse_todo_valid_projects() {
let todo_str =
Pin::new("x (A) 2020-05-01 2020-01-01 help me please +beatles +music".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please +beatles +music".to_string()
);
assert_eq!(todo.get_raw_str(), "help me please +beatles +music");
let mut projects = HashSet::with_hasher(TodoBuildHasher);
projects.insert("+beatles".into());
projects.insert("+music".into());
assert_eq!(todo.projects, projects);
assert!(todo.contexts.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_todo_valid_contexts() {
let todo_str =
Pin::new("x (A) 2020-05-01 2020-01-01 help me please @beatles @music".to_string());
let todo: TodoItem = todo_str.try_into().expect("todo parse");
assert_eq!(todo.done, true);
assert_eq!(todo.prio, Some('A'));
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
assert_eq!(
*todo.raw,
"x (A) 2020-05-01 2020-01-01 help me please @beatles @music".to_string()
);
assert_eq!(todo.get_raw_str(), "help me please @beatles @music");
let mut contexts = HashSet::with_hasher(TodoBuildHasher);
contexts.insert("@beatles".into());
contexts.insert("@music".into());
assert_eq!(todo.contexts, contexts);
assert!(todo.projects.is_empty());
assert!(todo.tags.is_empty());
}
#[test]
fn parse_prio_invalid() {
let todo = "(a) hello".to_string();
let mut pos = 0;
let prio = parse_prio(&todo, &mut pos);
assert_eq!(prio, None);
assert_eq!(pos, 0);
}
#[test]
fn parse_prio_valid() {
let todo = "(A) hello".to_string();
let mut pos = 0;
let prio = parse_prio(&todo, &mut pos);
assert_eq!(prio, Some('A'));
assert_eq!(get_word(&todo[pos..]), Some((5, "hello")));
}
#[test]
fn parse_dates_not_done_invalid() {
let dates = "2020-05-2 hello there".to_string();
let mut pos = 0;
let (completed, created) = parse_dates(&dates, &mut pos);
assert_eq!(completed, None);
assert_eq!(created, None);
assert_eq!(pos, 0);
}
#[test]
fn parse_dates_not_done_valid() {
let dates = "2020-05-02 hello there";
let mut pos = 0;
let (completed, created) = parse_dates(&dates, &mut pos);
assert_eq!(completed, None);
assert_eq!(created, Some(date!(2020 - 05 - 02)));
assert_eq!(get_word(&dates[pos..]), Some((6, "hello")));
}
#[test]
fn parse_dates_done_valid() {
let dates = "2020-05-03 2020-05-02";
let mut pos = 0;
let (completed, created) = parse_dates(&dates, &mut pos);
assert_eq!(completed, Some(date!(2020 - 05 - 03)));
assert_eq!(created, Some(date!(2020 - 05 - 02)));
assert!(get_word(&dates[pos..]).is_none());
}
#[test]
fn parse_dates_done_invalid() {
let dates = "d2020-05-03 2020-05-02";
let mut pos = 0;
let (completed, created) = parse_dates(&dates, &mut pos);
assert_eq!(completed, None);
assert_eq!(created, None);
assert_eq!(pos, 0);
}
#[test]
fn parse_date_valid() {
let date = "2020-05-02";
let mut pos = 0;
let parsed = parse_date(&date, &mut pos);
assert_eq!(parsed, Some(date!(2020 - 05 - 02)));
assert_eq!(get_word(&date[pos..]), None);
}
#[test]
fn parse_date_invalid() {
let date = "2020-05-2";
let mut pos = 0;
let parsed = parse_date(&date, &mut pos);
assert_eq!(parsed, None);
assert_eq!(pos, 0);
}
}