qualia_client_core/wellfair/
bookmarks.rs1use 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 pub path: Option<String>,
32}
33
34fn qlinks_dir(storage_root: &Path) -> PathBuf {
35 storage_root.join(QLINKS_DIR)
36}
37
38pub 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
110pub 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
151pub 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
166pub 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}