use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Entry {
pub id: Uuid,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub hlc: Hlc,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
pub tags: Vec<String>,
pub starred: bool,
pub kind: EntryKind,
pub deleted: bool,
pub version: u64,
}
impl Entry {
pub fn new_journal(body: String, explicit_tags: &[String], starred: bool, hlc: Hlc) -> Self {
Self::new_journal_with_attachments(body, explicit_tags, starred, Vec::new(), hlc)
}
pub fn new_journal_with_attachments(
body: String,
explicit_tags: &[String],
starred: bool,
attachments: Vec<Attachment>,
hlc: Hlc,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::now_v7(),
created_at: now,
modified_at: now,
hlc,
tags: merged_tags(&body, explicit_tags),
body,
attachments,
starred,
kind: EntryKind::Journal,
deleted: false,
version: 1,
}
}
pub fn new_todo(
body: String,
explicit_tags: &[String],
priority: Priority,
due: Option<NaiveDate>,
hlc: Hlc,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::now_v7(),
created_at: now,
modified_at: now,
hlc,
tags: merged_tags(&body, explicit_tags),
body,
attachments: Vec::new(),
starred: false,
kind: EntryKind::Todo(TodoMeta {
completed: false,
completed_at: None,
priority,
due,
}),
deleted: false,
version: 1,
}
}
pub fn touch(&mut self, hlc: Hlc) {
self.modified_at = Utc::now();
self.hlc = hlc;
self.version = self.version.saturating_add(1);
self.tags = merged_tags(&self.body, &[]);
}
pub fn short_id(&self) -> String {
self.id.to_string().chars().take(8).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attachment {
pub id: String,
pub file_name: String,
pub media_type: String,
pub data: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", content = "meta", rename_all = "snake_case")]
pub enum EntryKind {
Journal,
Todo(TodoMeta),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TodoMeta {
pub completed: bool,
pub completed_at: Option<DateTime<Utc>>,
pub priority: Priority,
pub due: Option<NaiveDate>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
Medium,
High,
Urgent,
}
impl Default for Priority {
fn default() -> Self {
Self::Medium
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Urgent => "urgent",
};
f.write_str(value)
}
}
impl FromStr for Priority {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"low" | "l" => Ok(Self::Low),
"medium" | "med" | "m" => Ok(Self::Medium),
"high" | "h" => Ok(Self::High),
"urgent" | "u" => Ok(Self::Urgent),
other => Err(format!("unknown priority: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Hlc {
pub wall_ms: u64,
pub counter: u32,
pub node_id: u64,
}
impl Hlc {
pub fn new(node_id: u64) -> Self {
Self {
wall_ms: now_ms(),
counter: 0,
node_id,
}
}
pub fn tick(self) -> Self {
let now = now_ms();
if now > self.wall_ms {
Self {
wall_ms: now,
counter: 0,
node_id: self.node_id,
}
} else {
Self {
wall_ms: self.wall_ms,
counter: self.counter.saturating_add(1),
node_id: self.node_id,
}
}
}
pub fn observe(self, remote: Hlc) -> Self {
let now = now_ms();
let max_wall = self.wall_ms.max(remote.wall_ms).max(now);
let counter = if max_wall == self.wall_ms && max_wall == remote.wall_ms {
self.counter.max(remote.counter).saturating_add(1)
} else if max_wall == self.wall_ms {
self.counter.saturating_add(1)
} else if max_wall == remote.wall_ms {
remote.counter.saturating_add(1)
} else {
0
};
Self {
wall_ms: max_wall,
counter,
node_id: self.node_id,
}
}
}
impl Ord for Hlc {
fn cmp(&self, other: &Self) -> Ordering {
(self.wall_ms, self.counter, self.node_id).cmp(&(
other.wall_ms,
other.counter,
other.node_id,
))
}
}
impl PartialOrd for Hlc {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HlcState {
pub current: Hlc,
}
impl HlcState {
pub fn new(node_id: u64) -> Self {
Self {
current: Hlc::new(node_id),
}
}
pub fn next(&mut self) -> Hlc {
self.current = self.current.tick();
self.current
}
pub fn observe(&mut self, remote: Hlc) {
self.current = self.current.observe(remote);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManifestEntry {
pub version: u64,
pub hlc: Hlc,
pub checksum: String,
}
pub type Manifest = BTreeMap<Uuid, ManifestEntry>;
pub fn extract_tags(body: &str) -> Vec<String> {
let mut tags = BTreeSet::new();
for token in
body.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == ')' || c == '(')
{
let Some(stripped) = token.strip_prefix('#') else {
continue;
};
let tag: String = stripped
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>()
.to_ascii_lowercase();
if !tag.is_empty() {
tags.insert(tag);
}
}
tags.into_iter().collect()
}
pub fn normalize_tag(tag: &str) -> Option<String> {
let tag = tag.trim().trim_start_matches('#').to_ascii_lowercase();
if tag.is_empty() {
None
} else {
Some(tag)
}
}
fn merged_tags(body: &str, explicit_tags: &[String]) -> Vec<String> {
let mut tags: BTreeSet<String> = extract_tags(body).into_iter().collect();
for tag in explicit_tags {
if let Some(tag) = normalize_tag(tag) {
tags.insert(tag);
}
}
tags.into_iter().collect()
}
fn now_ms() -> u64 {
Utc::now().timestamp_millis().max(0) as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hlc_orders_by_wall_counter_node() {
let a = Hlc {
wall_ms: 10,
counter: 1,
node_id: 1,
};
let b = Hlc {
wall_ms: 10,
counter: 2,
node_id: 0,
};
let c = Hlc {
wall_ms: 10,
counter: 2,
node_id: 9,
};
assert!(a < b);
assert!(b < c);
}
#[test]
fn extracts_hashtags_stably() {
assert_eq!(
extract_tags("hello #Work and #work, #rust-lang."),
vec!["rust-lang".to_string(), "work".to_string()]
);
}
}