Skip to main content

qualia_client_core/wellfair/
bookmarks.rs

1//! Hypermedia bookmarks (QLink index + Library purpose filter).
2//!
3//! Bookmarks are dual-written:
4//! 1. `{storage}/qlinks/{uuid}.json` — always (offline-safe JSON-LD Bookmark)
5//! 2. Hypermedia library entry with `purposes: ["bookmark"]` when the vault host can ingest
6//!
7//! Listing prefers the qlinks directory (complete for browser saves) and merges
8//! library entries that already carry purpose `bookmark`.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use super::hypermedia_store::{FacetFilter, HypermediaStore, LibrarySort};
17
18pub const QLINKS_DIR: &str = "qlinks";
19pub const PURPOSE_BOOKMARK: &str = "bookmark";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct BookmarkRecord {
23    pub id: String,
24    pub url: String,
25    pub name: String,
26    pub description: String,
27    pub date_created: String,
28    pub ingested_to_library: bool,
29    pub source: String,
30    /// Path relative to storage root when from qlinks JSON.
31    pub path: Option<String>,
32}
33
34fn qlinks_dir(storage_root: &Path) -> PathBuf {
35    storage_root.join(QLINKS_DIR)
36}
37
38/// List bookmark JSON files under `{storage}/qlinks/`.
39pub fn list_qlink_files(storage_root: &Path) -> Result<Vec<BookmarkRecord>, String> {
40    let dir = qlinks_dir(storage_root);
41    if !dir.is_dir() {
42        return Ok(Vec::new());
43    }
44    let mut out = Vec::new();
45    let rd = fs::read_dir(&dir).map_err(|e| e.to_string())?;
46    for ent in rd.flatten() {
47        let path = ent.path();
48        if path.extension().and_then(|e| e.to_str()) != Some("json") {
49            continue;
50        }
51        let id = path
52            .file_stem()
53            .and_then(|s| s.to_str())
54            .unwrap_or("")
55            .to_string();
56        let raw = match fs::read_to_string(&path) {
57            Ok(s) => s,
58            Err(_) => continue,
59        };
60        let v: Value = match serde_json::from_str(&raw) {
61            Ok(v) => v,
62            Err(_) => continue,
63        };
64        let url = v
65            .get("url")
66            .and_then(|x| x.as_str())
67            .unwrap_or("")
68            .to_string();
69        if url.is_empty() {
70            continue;
71        }
72        let name = v
73            .get("name")
74            .and_then(|x| x.as_str())
75            .unwrap_or(&url)
76            .to_string();
77        let description = v
78            .get("description")
79            .and_then(|x| x.as_str())
80            .unwrap_or("")
81            .to_string();
82        let date_created = v
83            .get("dateCreated")
84            .and_then(|x| x.as_str())
85            .unwrap_or("")
86            .to_string();
87        let ingested = v
88            .get("ingestedToLibrary")
89            .and_then(|x| x.as_bool())
90            .unwrap_or(false);
91        let rel = path
92            .strip_prefix(storage_root)
93            .ok()
94            .map(|p| p.to_string_lossy().replace('\\', "/"));
95        out.push(BookmarkRecord {
96            id,
97            url,
98            name,
99            description,
100            date_created,
101            ingested_to_library: ingested,
102            source: "qlinks".into(),
103            path: rel,
104        });
105    }
106    out.sort_by(|a, b| b.date_created.cmp(&a.date_created));
107    Ok(out)
108}
109
110/// Library entries with purpose `bookmark` (faceted query).
111pub fn list_library_bookmarks(storage_root: &Path) -> Result<Vec<BookmarkRecord>, String> {
112    let store = HypermediaStore::open(storage_root).map_err(|e| e.to_string())?;
113    let filter = FacetFilter {
114        purposes: vec![PURPOSE_BOOKMARK.into()],
115        ..Default::default()
116    };
117    let entries = store
118        .query_faceted(&filter, LibrarySort::Newest)
119        .map_err(|e| e.to_string())?;
120    let mut out = Vec::new();
121    for e in entries {
122        out.push(BookmarkRecord {
123            id: e.asset_uri.clone(),
124            url: e.asset_uri.clone(),
125            name: if e.excerpt.is_empty() {
126                e.asset_uri.clone()
127            } else {
128                e.excerpt.chars().take(80).collect()
129            },
130            description: e.excerpt.clone(),
131            date_created: e
132                .occurred_at
133                .map(|t| {
134                    chrono::DateTime::from_timestamp(t, 0)
135                        .map(|d| d.to_rfc3339())
136                        .unwrap_or_default()
137                })
138                .unwrap_or_else(|| {
139                    chrono::DateTime::from_timestamp(e.ingested_unix as i64, 0)
140                        .map(|d| d.to_rfc3339())
141                        .unwrap_or_default()
142                }),
143            ingested_to_library: true,
144            source: "library".into(),
145            path: None,
146        });
147    }
148    Ok(out)
149}
150
151/// Merge qlinks files + library purpose=bookmark (dedupe by URL, prefer qlinks metadata).
152pub fn list_all_bookmarks(storage_root: &Path) -> Result<Vec<BookmarkRecord>, String> {
153    let mut by_url: std::collections::BTreeMap<String, BookmarkRecord> =
154        std::collections::BTreeMap::new();
155    for b in list_library_bookmarks(storage_root)? {
156        by_url.insert(b.url.clone(), b);
157    }
158    for b in list_qlink_files(storage_root)? {
159        by_url.insert(b.url.clone(), b);
160    }
161    let mut out: Vec<_> = by_url.into_values().collect();
162    out.sort_by(|a, b| b.date_created.cmp(&a.date_created));
163    Ok(out)
164}
165
166/// Persist a qlink JSON document (always succeeds if disk allows).
167pub fn write_qlink_json(
168    storage_root: &Path,
169    url: &str,
170    name: &str,
171    description: &str,
172    ingested_to_library: bool,
173    context_assertions: Option<Vec<Value>>,
174) -> Result<(String, PathBuf), String> {
175    let dir = qlinks_dir(storage_root);
176    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
177    let id = uuid::Uuid::new_v4().to_string();
178    let mut doc = serde_json::json!({
179        "@context": ["http://schema.org", "http://www.w3.org/ns/anno.jsonld"],
180        "@type": "Bookmark",
181        "url": url,
182        "name": name,
183        "description": description,
184        "dateCreated": chrono::Utc::now().to_rfc3339(),
185        "ingestedToLibrary": ingested_to_library,
186        "purpose": PURPOSE_BOOKMARK,
187    });
188    if let Some(assertions) = context_assertions {
189        if let Some(obj) = doc.as_object_mut() {
190            obj.insert("cml:contextAssertions".into(), Value::Array(assertions));
191        }
192    }
193    let path = dir.join(format!("{id}.json"));
194    let json_str = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
195    fs::write(&path, json_str).map_err(|e| e.to_string())?;
196    Ok((id, path))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn write_and_list_qlink() {
205        let dir = tempfile::tempdir().unwrap();
206        let (id, path) = write_qlink_json(
207            dir.path(),
208            "https://example.org/a",
209            "Example",
210            "desc",
211            false,
212            None,
213        )
214        .unwrap();
215        assert!(path.exists());
216        assert!(!id.is_empty());
217        let list = list_qlink_files(dir.path()).unwrap();
218        assert_eq!(list.len(), 1);
219        assert_eq!(list[0].url, "https://example.org/a");
220        assert_eq!(list[0].name, "Example");
221        assert!(!list[0].ingested_to_library);
222    }
223
224    #[test]
225    fn list_empty_ok() {
226        let dir = tempfile::tempdir().unwrap();
227        assert!(list_all_bookmarks(dir.path()).unwrap().is_empty());
228    }
229}