1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct BookmarkId(pub String);
18
19impl BookmarkId {
20 #[must_use]
22 pub fn generate() -> Self {
23 Self(ulid::Ulid::generate().to_string())
24 }
25}
26
27impl std::fmt::Display for BookmarkId {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str(&self.0)
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(transparent)]
36pub struct CollectionId(pub String);
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Bookmark {
44 pub id: BookmarkId,
46 pub original_url: String,
48 pub canonical_url: String,
50 pub title: String,
52 pub description: Option<String>,
54 pub tags: Vec<String>,
56 pub collection: Option<String>,
58 pub created_at: DateTime<Utc>,
60 pub updated_at: DateTime<Utc>,
62 pub source: SourceRef,
64 pub content_type: Option<String>,
66 pub archived: bool,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SourceRef {
73 pub kind: SourceKind,
75 pub external_id: Option<String>,
77 pub imported_at: DateTime<Utc>,
79 pub raw: Option<serde_json::Value>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum SourceKind {
88 Chromium,
90 Firefox,
92 Netscape,
94 Pinboard,
96 Linkwarden,
98 Manual,
100}
101
102impl SourceKind {
103 #[must_use]
105 pub fn as_cli_str(&self) -> &'static str {
106 match self {
107 Self::Chromium => "chrome",
108 Self::Firefox => "firefox",
109 Self::Netscape => "netscape",
110 Self::Pinboard => "pinboard",
111 Self::Linkwarden => "linkwarden",
112 Self::Manual => "manual",
113 }
114 }
115
116 pub fn from_cli_str(s: &str) -> Option<Self> {
118 match s.to_ascii_lowercase().as_str() {
119 "chrome" | "chromium" | "brave" | "edge" | "arc" | "vivaldi" | "opera" => {
120 Some(Self::Chromium)
121 }
122 "firefox" => Some(Self::Firefox),
123 "netscape" | "html" => Some(Self::Netscape),
124 "pinboard" => Some(Self::Pinboard),
125 "linkwarden" => Some(Self::Linkwarden),
126 "manual" => Some(Self::Manual),
127 _ => None,
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Collection {
135 pub id: CollectionId,
137 pub name: String,
139 pub parent: Option<CollectionId>,
141 pub source: SourceKind,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(transparent)]
148pub struct Tag(pub String);
149
150impl Tag {
151 #[must_use]
154 pub fn new(raw: &str) -> Option<Self> {
155 let trimmed = raw.trim().to_ascii_lowercase();
156 if trimmed.is_empty() {
157 None
158 } else {
159 Some(Self(trimmed))
160 }
161 }
162}
163
164impl std::fmt::Display for Tag {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str(&self.0)
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn source_kind_cli_round_trip() {
176 for kind in [
177 SourceKind::Chromium,
178 SourceKind::Firefox,
179 SourceKind::Netscape,
180 SourceKind::Pinboard,
181 SourceKind::Linkwarden,
182 SourceKind::Manual,
183 ] {
184 let s = kind.as_cli_str();
185 let back = SourceKind::from_cli_str(s).expect("round-trip");
186 assert_eq!(back, kind);
187 }
188 }
189
190 #[test]
191 fn source_kind_accepts_browser_aliases() {
192 for alias in [
193 "chrome", "chromium", "brave", "edge", "arc", "vivaldi", "opera",
194 ] {
195 assert_eq!(SourceKind::from_cli_str(alias), Some(SourceKind::Chromium));
196 }
197 }
198
199 #[test]
200 fn source_kind_rejects_unknown() {
201 assert_eq!(SourceKind::from_cli_str("bogus"), None);
202 }
203
204 #[test]
205 fn tag_normalizes_lowercase_and_trim() {
206 let tag = Tag::new(" Rust ").unwrap();
207 assert_eq!(tag.0, "rust");
208 }
209
210 #[test]
211 fn tag_rejects_empty() {
212 assert!(Tag::new(" ").is_none());
213 assert!(Tag::new("").is_none());
214 }
215
216 #[test]
217 fn bookmark_id_generates_unique() {
218 let a = BookmarkId::generate();
219 let b = BookmarkId::generate();
220 assert_ne!(a, b);
221 assert_eq!(a.0.len(), 26);
223 }
224}