359 lines
11 KiB
Rust
359 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Status {
|
|
Pending,
|
|
Approved,
|
|
Denied,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EntryMeta {
|
|
pub name: String,
|
|
pub date: String,
|
|
#[serde(default)]
|
|
pub website: String,
|
|
#[serde(default)]
|
|
pub drawing: String,
|
|
#[serde(default)]
|
|
pub voice_note: String,
|
|
pub status: Status,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Entry {
|
|
#[cfg_attr(not(feature = "telegram"), allow(dead_code))]
|
|
pub id: String,
|
|
pub meta: EntryMeta,
|
|
pub body: String,
|
|
}
|
|
|
|
impl Entry {
|
|
/// Parse an entry from file contents. `id` is derived from the filename.
|
|
pub fn parse(id: &str, contents: &str) -> Result<Self, String> {
|
|
let contents = contents.trim();
|
|
if !contents.starts_with("+++") {
|
|
return Err("missing opening +++".into());
|
|
}
|
|
let rest = &contents[3..];
|
|
let end = rest.find("+++").ok_or("missing closing +++")?;
|
|
let frontmatter = &rest[..end];
|
|
let body = rest[end + 3..].trim_start_matches('\n').to_string();
|
|
let meta: EntryMeta =
|
|
toml::from_str(frontmatter).map_err(|e| format!("bad frontmatter: {e}"))?;
|
|
Ok(Entry {
|
|
id: id.to_string(),
|
|
meta,
|
|
body,
|
|
})
|
|
}
|
|
|
|
/// Serialize entry back to file format.
|
|
pub fn to_file_contents(&self) -> String {
|
|
let frontmatter = toml::to_string_pretty(&self.meta).unwrap();
|
|
format!("+++\n{frontmatter}+++\n{}", self.body)
|
|
}
|
|
}
|
|
|
|
/// Read all entries from the given directory.
|
|
pub fn read_entries(dir: &Path) -> Vec<Entry> {
|
|
let mut entries = Vec::new();
|
|
let read_dir = match std::fs::read_dir(dir) {
|
|
Ok(rd) => rd,
|
|
Err(_) => return entries,
|
|
};
|
|
for item in read_dir {
|
|
let Ok(item) = item else { continue };
|
|
let path = item.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("txt") {
|
|
continue;
|
|
}
|
|
let id = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let Ok(contents) = std::fs::read_to_string(&path) else {
|
|
continue;
|
|
};
|
|
if let Ok(entry) = Entry::parse(&id, &contents) {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
entries.sort_by(|a, b| b.meta.date.cmp(&a.meta.date));
|
|
entries
|
|
}
|
|
|
|
/// Read entries filtered by status.
|
|
pub fn read_by_status(dir: &Path, status: Status) -> Vec<Entry> {
|
|
read_entries(dir)
|
|
.into_iter()
|
|
.filter(|e| e.meta.status == status)
|
|
.collect()
|
|
}
|
|
|
|
/// Read approved entries only.
|
|
pub fn read_approved(dir: &Path) -> Vec<Entry> {
|
|
read_by_status(dir, Status::Approved)
|
|
}
|
|
|
|
#[cfg(any(feature = "telegram", test))]
|
|
/// Find a single entry by ID.
|
|
pub fn find_entry(dir: &Path, id: &str) -> Result<Entry, String> {
|
|
let path = dir.join(format!("{id}.txt"));
|
|
let contents = std::fs::read_to_string(&path).map_err(|_| "Not found.".to_string())?;
|
|
Entry::parse(id, &contents)
|
|
}
|
|
|
|
#[cfg(any(feature = "telegram", test))]
|
|
/// Delete an entry and its associated media files.
|
|
/// `data_dir` is the parent directory containing entries/, drawings/, and voice_notes/.
|
|
pub fn delete_entry(data_dir: &Path, short_id: &str) -> Result<String, String> {
|
|
let entries_dir = data_dir.join("entries");
|
|
let entry = find_entry(&entries_dir, short_id)?;
|
|
|
|
// Delete entry file
|
|
let entry_path = entries_dir.join(format!("{}.txt", entry.id));
|
|
std::fs::remove_file(&entry_path).map_err(|e| e.to_string())?;
|
|
|
|
// Delete drawing if present
|
|
if !entry.meta.drawing.is_empty() {
|
|
let drawing_path = data_dir.join("drawings").join(&entry.meta.drawing);
|
|
if drawing_path.exists() {
|
|
std::fs::remove_file(&drawing_path).map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
|
|
// Delete voice note if present
|
|
if !entry.meta.voice_note.is_empty() {
|
|
let vn_path = data_dir.join("voice_notes").join(&entry.meta.voice_note);
|
|
if vn_path.exists() {
|
|
std::fs::remove_file(&vn_path).map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
|
|
Ok(entry.meta.name.clone())
|
|
}
|
|
|
|
#[cfg(any(feature = "telegram", test))]
|
|
/// Find an entry file by short ID prefix and update its status.
|
|
pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result<String, String> {
|
|
let mut entry = find_entry(dir, short_id)?;
|
|
entry.meta.status = status;
|
|
let path = dir.join(format!("{}.txt", entry.id));
|
|
std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?;
|
|
Ok(entry.meta.name.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_entry() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-09"
|
|
website = "https://example.com"
|
|
status = "approved"
|
|
+++
|
|
Hello world!"#;
|
|
let entry = Entry::parse("2026-04-09-abcd1234", contents).unwrap();
|
|
assert_eq!(entry.id, "2026-04-09-abcd1234");
|
|
assert_eq!(entry.meta.name, "alice");
|
|
assert_eq!(entry.meta.date, "2026-04-09");
|
|
assert_eq!(entry.meta.website, "https://example.com");
|
|
assert_eq!(entry.meta.status, Status::Approved);
|
|
assert_eq!(entry.body, "Hello world!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_no_website() {
|
|
let contents = r#"+++
|
|
name = "bob"
|
|
date = "2026-04-09"
|
|
status = "pending"
|
|
+++
|
|
Just a message."#;
|
|
let entry = Entry::parse("2026-04-09-ef567890", contents).unwrap();
|
|
assert_eq!(entry.meta.website, "");
|
|
assert_eq!(entry.meta.status, Status::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_with_html() {
|
|
let contents = r#"+++
|
|
name = "carol"
|
|
date = "2026-04-09"
|
|
status = "approved"
|
|
+++
|
|
<b>Bold</b> and <em>italic</em>.
|
|
|
|
<em>-- llywelwyn</em>: Thanks!"#;
|
|
let entry = Entry::parse("2026-04-09-11223344", contents).unwrap();
|
|
assert!(entry.body.contains("<b>Bold</b>"));
|
|
assert!(entry.body.contains("<em>-- llywelwyn</em>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_roundtrip() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-09"
|
|
website = "https://example.com"
|
|
status = "approved"
|
|
+++
|
|
Hello world!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
let serialized = entry.to_file_contents();
|
|
let reparsed = Entry::parse("test", &serialized).unwrap();
|
|
assert_eq!(entry.meta.name, reparsed.meta.name);
|
|
assert_eq!(entry.body, reparsed.body);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_missing_frontmatter() {
|
|
let result = Entry::parse("x", "no frontmatter here");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_with_drawing() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-09"
|
|
status = "approved"
|
|
drawing = "abc123.png"
|
|
+++
|
|
Hello!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
assert_eq!(entry.meta.drawing, "abc123.png");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_without_drawing() {
|
|
let contents = r#"+++
|
|
name = "bob"
|
|
date = "2026-04-09"
|
|
status = "pending"
|
|
+++
|
|
Hi!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
assert_eq!(entry.meta.drawing, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_roundtrip_with_drawing() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-09"
|
|
status = "approved"
|
|
drawing = "abc123.png"
|
|
+++
|
|
Hello!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
let serialized = entry.to_file_contents();
|
|
let reparsed = Entry::parse("test", &serialized).unwrap();
|
|
assert_eq!(reparsed.meta.drawing, "abc123.png");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_with_voice_note() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-10"
|
|
status = "approved"
|
|
voice_note = "1744300800_abcd1234.webm"
|
|
+++
|
|
Hello!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
assert_eq!(entry.meta.voice_note, "1744300800_abcd1234.webm");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_entry_without_voice_note() {
|
|
let contents = r#"+++
|
|
name = "bob"
|
|
date = "2026-04-10"
|
|
status = "pending"
|
|
+++
|
|
Hi!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
assert_eq!(entry.meta.voice_note, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_read_by_status() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let approved = "+++\nname = \"a\"\ndate = \"2026-04-10\"\nstatus = \"approved\"\n+++\nhi";
|
|
let pending = "+++\nname = \"b\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhi";
|
|
let denied = "+++\nname = \"c\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\n+++\nhi";
|
|
std::fs::write(dir.path().join("1_aaa.txt"), approved).unwrap();
|
|
std::fs::write(dir.path().join("2_bbb.txt"), pending).unwrap();
|
|
std::fs::write(dir.path().join("3_ccc.txt"), denied).unwrap();
|
|
|
|
assert_eq!(read_by_status(dir.path(), Status::Approved).len(), 1);
|
|
assert_eq!(read_by_status(dir.path(), Status::Pending).len(), 1);
|
|
assert_eq!(read_by_status(dir.path(), Status::Denied).len(), 1);
|
|
assert_eq!(read_by_status(dir.path(), Status::Approved)[0].meta.name, "a");
|
|
}
|
|
|
|
#[test]
|
|
fn test_roundtrip_with_voice_note() {
|
|
let contents = r#"+++
|
|
name = "alice"
|
|
date = "2026-04-10"
|
|
status = "approved"
|
|
voice_note = "1744300800_abcd1234.webm"
|
|
+++
|
|
Hello!"#;
|
|
let entry = Entry::parse("test", contents).unwrap();
|
|
let serialized = entry.to_file_contents();
|
|
let reparsed = Entry::parse("test", &serialized).unwrap();
|
|
assert_eq!(reparsed.meta.voice_note, "1744300800_abcd1234.webm");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_entry() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"pending\"\n+++\nhello";
|
|
std::fs::write(dir.path().join("ab12.txt"), contents).unwrap();
|
|
|
|
let entry = find_entry(dir.path(), "ab12").unwrap();
|
|
assert_eq!(entry.meta.name, "alice");
|
|
|
|
assert!(find_entry(dir.path(), "nonexistent").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_entry() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let entries_dir = dir.path().join("entries");
|
|
let drawings_dir = dir.path().join("drawings");
|
|
let vn_dir = dir.path().join("voice_notes");
|
|
std::fs::create_dir_all(&entries_dir).unwrap();
|
|
std::fs::create_dir_all(&drawings_dir).unwrap();
|
|
std::fs::create_dir_all(&vn_dir).unwrap();
|
|
|
|
let contents = "+++\nname = \"alice\"\ndate = \"2026-04-10\"\nstatus = \"denied\"\ndrawing = \"ab12.png\"\nvoice_note = \"ab12.webm\"\n+++\nhello";
|
|
std::fs::write(entries_dir.join("ab12.txt"), contents).unwrap();
|
|
std::fs::write(drawings_dir.join("ab12.png"), b"fake png").unwrap();
|
|
std::fs::write(vn_dir.join("ab12.webm"), b"fake webm").unwrap();
|
|
|
|
let name = delete_entry(dir.path(), "ab12").unwrap();
|
|
assert_eq!(name, "alice");
|
|
assert!(!entries_dir.join("ab12.txt").exists());
|
|
assert!(!drawings_dir.join("ab12.png").exists());
|
|
assert!(!vn_dir.join("ab12.webm").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_entry_not_found() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("entries")).unwrap();
|
|
assert!(delete_entry(dir.path(), "nonexistent").is_err());
|
|
}
|
|
|
|
}
|