use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct BookmarkId(pub String);
impl BookmarkId {
#[must_use]
pub fn generate() -> Self {
Self(ulid::Ulid::generate().to_string())
}
}
impl std::fmt::Display for BookmarkId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CollectionId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bookmark {
pub id: BookmarkId,
pub original_url: String,
pub canonical_url: String,
pub title: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub collection: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub source: SourceRef,
pub content_type: Option<String>,
pub archived: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub external_id: Option<String>,
pub imported_at: DateTime<Utc>,
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceKind {
Chromium,
Firefox,
Netscape,
Pinboard,
Linkwarden,
Manual,
}
impl SourceKind {
#[must_use]
pub fn as_cli_str(&self) -> &'static str {
match self {
Self::Chromium => "chrome",
Self::Firefox => "firefox",
Self::Netscape => "netscape",
Self::Pinboard => "pinboard",
Self::Linkwarden => "linkwarden",
Self::Manual => "manual",
}
}
pub fn from_cli_str(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"chrome" | "chromium" | "brave" | "edge" | "arc" | "vivaldi" | "opera" => {
Some(Self::Chromium)
}
"firefox" => Some(Self::Firefox),
"netscape" | "html" => Some(Self::Netscape),
"pinboard" => Some(Self::Pinboard),
"linkwarden" => Some(Self::Linkwarden),
"manual" => Some(Self::Manual),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Collection {
pub id: CollectionId,
pub name: String,
pub parent: Option<CollectionId>,
pub source: SourceKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tag(pub String);
impl Tag {
#[must_use]
pub fn new(raw: &str) -> Option<Self> {
let trimmed = raw.trim().to_ascii_lowercase();
if trimmed.is_empty() {
None
} else {
Some(Self(trimmed))
}
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_kind_cli_round_trip() {
for kind in [
SourceKind::Chromium,
SourceKind::Firefox,
SourceKind::Netscape,
SourceKind::Pinboard,
SourceKind::Linkwarden,
SourceKind::Manual,
] {
let s = kind.as_cli_str();
let back = SourceKind::from_cli_str(s).expect("round-trip");
assert_eq!(back, kind);
}
}
#[test]
fn source_kind_accepts_browser_aliases() {
for alias in [
"chrome", "chromium", "brave", "edge", "arc", "vivaldi", "opera",
] {
assert_eq!(SourceKind::from_cli_str(alias), Some(SourceKind::Chromium));
}
}
#[test]
fn source_kind_rejects_unknown() {
assert_eq!(SourceKind::from_cli_str("bogus"), None);
}
#[test]
fn tag_normalizes_lowercase_and_trim() {
let tag = Tag::new(" Rust ").unwrap();
assert_eq!(tag.0, "rust");
}
#[test]
fn tag_rejects_empty() {
assert!(Tag::new(" ").is_none());
assert!(Tag::new("").is_none());
}
#[test]
fn bookmark_id_generates_unique() {
let a = BookmarkId::generate();
let b = BookmarkId::generate();
assert_ne!(a, b);
assert_eq!(a.0.len(), 26);
}
}