use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MemoId(pub Uuid);
impl MemoId {
pub fn now() -> Self {
Self(Uuid::now_v7())
}
pub fn parse(s: &str) -> Result<Self> {
let u = Uuid::parse_str(s.trim()).map_err(|_| CoreError::InvalidMemoId(s.into()))?;
Ok(Self(u))
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl std::fmt::Display for MemoId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.hyphenated())
}
}
impl std::str::FromStr for MemoId {
type Err = CoreError;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MemoHash(pub String);
impl MemoHash {
pub const ALGO: &'static str = "b3";
pub fn new(hex: impl Into<String>) -> Self {
Self(format!("{}:{}", Self::ALGO, hex.into()))
}
pub fn from_stored(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for MemoHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub const DEFAULT_CATEGORY: &str = "inbox";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cursor {
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
pub id: MemoId,
}
impl Cursor {
pub fn parse(s: &str) -> Result<Self> {
serde_json::from_str(s).map_err(|e| CoreError::Other(format!("invalid cursor: {e}")))
}
pub fn sort_key(&self) -> (OffsetDateTime, MemoId) {
(self.updated_at, self.id)
}
}
impl PartialOrd for Cursor {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Cursor {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.sort_key().cmp(&other.sort_key())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Memo {
pub id: MemoId,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
pub hash: MemoHash,
pub favorite: bool,
#[serde(default = "default_category")]
pub category: String,
pub tags: Vec<String>,
pub body: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "time::serde::rfc3339::option"
)]
pub deleted_at: Option<OffsetDateTime>,
}
pub fn default_category() -> String {
DEFAULT_CATEGORY.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoSummary {
pub id: MemoId,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
pub hash: MemoHash,
pub favorite: bool,
#[serde(default = "default_category")]
pub category: String,
pub tags: Vec<String>,
pub preview: String,
pub deleted: bool,
}
impl MemoSummary {
pub const PREVIEW_MAX: usize = 280;
}
impl From<Memo> for MemoSummary {
fn from(n: Memo) -> Self {
let deleted = n.deleted_at.is_some();
Self {
id: n.id,
created_at: n.created_at,
updated_at: n.updated_at,
hash: n.hash,
favorite: n.favorite,
category: n.category,
tags: n.tags,
preview: make_preview(&n.body),
deleted,
}
}
}
pub fn make_preview(body: &str) -> String {
let joined: String = body
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
truncate_chars(&joined, MemoSummary::PREVIEW_MAX)
}
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('\u{2026}');
out
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MemoFilter {
pub include_tags: Vec<String>,
pub exclude_tags: Vec<String>,
pub match_all: bool,
pub categories: Vec<String>,
pub favorites_only: bool,
pub include_deleted: bool,
}
impl MemoFilter {
pub fn matches(&self, s: &MemoSummary) -> bool {
if !self.include_deleted && s.deleted {
return false;
}
if self.favorites_only && !s.favorite {
return false;
}
if !self.categories.is_empty() && !self.categories.iter().any(|c| c == &s.category) {
return false;
}
if !self.exclude_tags.is_empty()
&& self
.exclude_tags
.iter()
.any(|t| s.tags.iter().any(|x| x.eq_ignore_ascii_case(t)))
{
return false;
}
if !self.include_tags.is_empty() {
let hit = |t: &String| s.tags.iter().any(|x| x.eq_ignore_ascii_case(t));
let ok = if self.match_all {
self.include_tags.iter().all(hit)
} else {
self.include_tags.iter().any(hit)
};
if !ok {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IndexStats {
pub memos: u64,
pub trashed_memos: u64,
pub added: u64,
pub updated: u64,
pub unchanged: u64,
pub failed: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoStats {
pub memos: u64,
pub favorites: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Facets {
pub tags: Vec<(String, u32)>,
pub categories: Vec<(String, u32)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_preserves_linebreaks_and_truncates() {
let body = "first line\n\nsecond line\n".to_string();
assert_eq!(make_preview(&body), "first line\nsecond line");
let big = "a\n".repeat(400);
let pv = make_preview(&big);
assert!(pv.chars().count() <= MemoSummary::PREVIEW_MAX);
assert!(pv.ends_with('\u{2026}'));
}
}
#[cfg(test)]
mod filter_tests {
use super::*;
use time::OffsetDateTime;
fn sum(tags: &[&str], category: &str, favorite: bool) -> MemoSummary {
MemoSummary {
id: MemoId::now(),
created_at: OffsetDateTime::now_utc(),
updated_at: OffsetDateTime::now_utc(),
hash: MemoHash::new("h"),
favorite,
category: category.to_string(),
tags: tags.iter().map(|t| t.to_string()).collect(),
preview: String::new(),
deleted: false,
}
}
#[test]
fn include_or_and_exclude() {
let f = MemoFilter {
include_tags: vec!["a".into(), "b".into()],
exclude_tags: vec!["x".into()],
match_all: false,
..Default::default()
};
assert!(f.matches(&sum(&["a"], "inbox", false)));
assert!(f.matches(&sum(&["b"], "inbox", false)));
assert!(!f.matches(&sum(&["c"], "inbox", false)));
assert!(!f.matches(&sum(&["a", "x"], "inbox", false)));
}
#[test]
fn include_and_requires_all() {
let f = MemoFilter {
include_tags: vec!["a".into(), "b".into()],
match_all: true,
..Default::default()
};
assert!(f.matches(&sum(&["a", "b"], "inbox", false)));
assert!(!f.matches(&sum(&["a"], "inbox", false)));
}
#[test]
fn category_membership() {
let f = MemoFilter {
categories: vec!["todo".into()],
..Default::default()
};
assert!(f.matches(&sum(&[], "todo", false)));
assert!(!f.matches(&sum(&[], "idea", false)));
assert!(MemoFilter::default().matches(&sum(&[], "inbox", false)));
}
}