Skip to main content

linkmarks_core/
model.rs

1//! Domain model for LinkMarks.
2//!
3//! Invariants:
4//! - `original_url` is **never** rewritten. Round-trip fidelity.
5//! - `canonical_url` is the dedupe key, normalized by `canonical`.
6//! - `tags` are sorted, lowercase, deduplicated at the model boundary.
7//! - `collection` is a `/`-separated folder path, normalized.
8//! - Timestamps are UTC ISO 8601.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// Opaque bookmark identifier. ULID by default; may be locally generated
14/// UUIDs in v0. Server-assigned in CRDT mode.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct BookmarkId(pub String);
18
19impl BookmarkId {
20    /// Generate a fresh ULID-backed identifier.
21    #[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/// Opaque collection identifier.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(transparent)]
36pub struct CollectionId(pub String);
37
38/// A normalized bookmark record.
39///
40/// `original_url` is preserved verbatim from the source. `canonical_url`
41/// is normalized for dedupe (see `canonical::canonicalize`).
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Bookmark {
44    /// Stable identifier.
45    pub id: BookmarkId,
46    /// Raw URL as imported (never rewritten).
47    pub original_url: String,
48    /// Normalized URL; the dedupe key.
49    pub canonical_url: String,
50    /// Display title, trimmed.
51    pub title: String,
52    /// Optional human-readable description.
53    pub description: Option<String>,
54    /// Sorted, lowercase, deduplicated tags.
55    pub tags: Vec<String>,
56    /// Folder path, `/`-separated.
57    pub collection: Option<String>,
58    /// First-seen timestamp (source clock or import time).
59    pub created_at: DateTime<Utc>,
60    /// Last-modified timestamp.
61    pub updated_at: DateTime<Utc>,
62    /// Provenance — where this record came from.
63    pub source: SourceRef,
64    /// Sniffed or declared MIME type, if known.
65    pub content_type: Option<String>,
66    /// Soft-delete marker (preserves history).
67    pub archived: bool,
68}
69
70/// Source provenance.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SourceRef {
73    /// Kind of source.
74    pub kind: SourceKind,
75    /// External (provider-side) identifier, if any.
76    pub external_id: Option<String>,
77    /// When this record was imported into LinkMarks.
78    pub imported_at: DateTime<Utc>,
79    /// Original payload for audit. Bridges may populate; CLI does
80    /// not require it.
81    pub raw: Option<serde_json::Value>,
82}
83
84/// Enumerates the source kinds supported by core + bridges.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum SourceKind {
88    /// Chromium-family browser JSON (Chrome, Brave, Edge, Arc, Vivaldi, Opera).
89    Chromium,
90    /// Firefox places.sqlite + jsonlz4 backups.
91    Firefox,
92    /// Netscape HTML interchange format.
93    Netscape,
94    /// Pinboard REST API.
95    Pinboard,
96    /// Linkwarden REST API.
97    Linkwarden,
98    /// Manually entered by a user.
99    Manual,
100}
101
102impl SourceKind {
103    /// Lowercase identifier used on the CLI (`--source=chrome`).
104    #[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    /// Parse from the CLI flag value.
117    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/// A collection (folder) grouping bookmarks.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct Collection {
135    /// Stable identifier.
136    pub id: CollectionId,
137    /// Human-readable name.
138    pub name: String,
139    /// Parent collection, if nested.
140    pub parent: Option<CollectionId>,
141    /// Source kind that produced this collection.
142    pub source: SourceKind,
143}
144
145/// Tag newtype. Validates lowercase + non-empty on construction.
146#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(transparent)]
148pub struct Tag(pub String);
149
150impl Tag {
151    /// Construct a tag, normalizing to lowercase and trimming
152    /// whitespace. Returns `None` for empty input.
153    #[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        // ULID is 26 chars
222        assert_eq!(a.0.len(), 26);
223    }
224}