use caseless::Caseless;
use chrono::Local;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
pub const SCHEMA_VERSION: u32 = 3;
pub const MAX_TASK_COUNT: usize = 1024;
pub const MAX_IMPORTANCE: u8 = 3;
pub const MAX_TITLE_LEN: usize = 256;
pub const MAX_NOTES_LINE_LEN: usize = 512;
pub const MAX_DESCRIPTION_LINES: usize = 256;
pub const MAX_CATEGORY_NAME_LEN: usize = 64;
pub const MAX_CATEGORY_DESC_LINE_LEN: usize = 256;
pub const MAX_CATEGORY_DESC_LINES: usize = 64;
pub const MAX_CATEGORY_COUNT: usize = 128;
pub const fn text_byte_limit(max_graphemes: usize) -> usize {
let scaled = max_graphemes.saturating_mul(16);
if scaled < 256 { 256 } else { scaled }
}
pub fn caseless_key(value: &str) -> String {
value.nfkc().default_case_fold().nfkc().collect()
}
pub const ALL_CATEGORY: &str = "";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
#[serde(default, alias = "body")]
pub description: Vec<Block>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub due: String,
#[serde(default)]
pub created: String,
#[serde(default)]
pub done: bool,
#[serde(default)]
pub importance: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category_id: Option<String>,
}
impl Task {
pub fn new(title: &str, importance: u8, category_id: Option<String>, due: &str) -> Self {
Self {
id: new_uuid(),
title: title.to_string(),
description: Vec::new(),
due: due.to_string(),
created: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
done: false,
importance: importance.min(MAX_IMPORTANCE),
category_id,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Block {
Text {
#[serde(default)]
text: String,
},
Todo {
#[serde(default)]
text: String,
#[serde(default)]
done: bool,
},
Bullet {
#[serde(default)]
text: String,
},
Number {
#[serde(default)]
text: String,
},
Link {
#[serde(default)]
url: String,
},
Image {
#[serde(alias = "path")]
attachment_id: String,
},
}
impl Block {
pub fn text(text: &str) -> Self {
Self::Text {
text: text.to_string(),
}
}
pub fn todo(text: &str, done: bool) -> Self {
Self::Todo {
text: text.to_string(),
done,
}
}
pub fn bullet(text: &str) -> Self {
Self::Bullet {
text: text.to_string(),
}
}
pub fn number(text: &str) -> Self {
Self::Number {
text: text.to_string(),
}
}
pub fn link(url: &str) -> Self {
Self::Link {
url: url.to_string(),
}
}
pub fn image(reference: &str) -> Self {
Self::Image {
attachment_id: reference.to_string(),
}
}
pub fn is_empty(&self) -> bool {
match self {
Self::Text { text }
| Self::Todo { text, .. }
| Self::Bullet { text }
| Self::Number { text }
| Self::Link { url: text } => text.trim().is_empty(),
Self::Image { .. } => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Category {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
}
impl Category {
pub fn new(name: &str) -> Self {
Self {
id: new_uuid(),
name: name.to_string(),
description: String::new(),
}
}
pub fn all_tasks() -> Self {
Self {
id: ALL_CATEGORY.to_string(),
name: "All tasks".to_string(),
description: String::new(),
}
}
pub fn is_all(&self) -> bool {
self.id == ALL_CATEGORY
}
}
pub fn new_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
pub fn importance_marks(importance: u8) -> String {
"⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
}
pub const fn next_importance(importance: u8) -> u8 {
(importance + 1) % (MAX_IMPORTANCE + 1)
}
pub fn category_name_key(value: &str) -> String {
caseless_key(value.trim())
}
pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
let mut done = 0usize;
let mut total = 0usize;
for b in &task.description {
if let Block::Todo { done: d, .. } = b {
total += 1;
if *d {
done += 1;
}
}
}
(total > 0).then_some((done, total))
}
pub fn has_prose_or_image(task: &Task) -> bool {
task.description
.iter()
.any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
}