1use crate::errors::CoreError;
28use crate::migrator;
29use crate::model::{Bookmark, BookmarkId, SourceKind, SourceRef, Tag};
30use crate::storage;
31use chrono::{DateTime, TimeZone, Utc};
32use rusqlite::{params, Connection, OptionalExtension, Row};
33use std::path::Path;
34
35pub fn open(path: &Path) -> Result<Store, CoreError> {
41 if let Some(parent) = path.parent() {
42 if !parent.as_os_str().is_empty() {
43 std::fs::create_dir_all(parent)?;
44 }
45 }
46 let conn = storage::open(path)?;
47 migrator::migrate(&conn)?;
48 Ok(Store { conn })
49}
50
51pub fn open_in_memory() -> Result<Store, CoreError> {
53 let conn = storage::open_in_memory()?;
54 migrator::migrate(&conn)?;
55 Ok(Store { conn })
56}
57
58pub struct Store {
60 conn: Connection,
61}
62
63impl std::fmt::Debug for Store {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("Store").finish_non_exhaustive()
66 }
67}
68
69impl Store {
70 pub fn connection(&self) -> &Connection {
73 &self.conn
74 }
75
76 pub fn upsert(&mut self, bookmark: &Bookmark) -> Result<BookmarkId, CoreError> {
84 let canonical = bookmark.canonical_url.clone();
85
86 let existing_id: Option<String> = self
87 .conn
88 .query_row(
89 "SELECT id FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
90 params![canonical],
91 |row| row.get::<_, String>(0),
92 )
93 .optional()
94 .map_err(|e| CoreError::Storage(format!("lookup canonical: {e}")))?;
95
96 let now = unix_now_secs();
97 let persisted_id = match existing_id {
98 Some(id) => {
99 self.update_existing(&BookmarkId(id), bookmark, now)?;
100 BookmarkId(
101 self.conn
103 .query_row(
104 "SELECT id FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
105 params![canonical],
106 |row| row.get::<_, String>(0),
107 )
108 .map_err(|e| CoreError::Storage(format!("refetch id: {e}")))?,
109 )
110 }
111 None => {
112 let id = if bookmark.id.0.is_empty() {
113 BookmarkId::generate()
114 } else {
115 bookmark.id.clone()
116 };
117 self.insert_new(&id, bookmark, now)?;
118 id
119 }
120 };
121
122 self.set_tags(&persisted_id, &bookmark.tags)?;
123 Ok(persisted_id)
124 }
125
126 pub fn by_canonical(&self, canonical: &str) -> Result<Option<Bookmark>, CoreError> {
128 let row = self
129 .conn
130 .query_row(
131 "\
132 SELECT id, original_url, canonical_url, title, description, collection, \
133 source_kind, source_id, external_id, added_at, last_seen_at, raw, archived \
134 FROM bookmarks WHERE canonical_url = ?1 AND archived = 0",
135 params![canonical],
136 row_to_bookmark,
137 )
138 .optional()
139 .map_err(|e| CoreError::Storage(format!("by_canonical query: {e}")))?;
140 match row {
141 Some(mut b) => {
142 b.tags = self.tags_for(&b.id)?;
143 Ok(Some(b))
144 }
145 None => Ok(None),
146 }
147 }
148
149 pub fn list(&self, limit: usize, offset: usize) -> Result<Vec<Bookmark>, CoreError> {
152 let limit = limit.min(i64::MAX as usize) as i64;
153 let offset = offset.min(i64::MAX as usize) as i64;
154 let mut stmt = self
155 .conn
156 .prepare_cached(SQL_LIST_ACTIVE)
157 .map_err(|e| CoreError::Storage(format!("prepare list: {e}")))?;
158 let rows = stmt
159 .query_map(params![limit, offset], row_to_bookmark)
160 .map_err(|e| CoreError::Storage(format!("list query: {e}")))?;
161 let mut out = Vec::new();
162 for r in rows {
163 let mut b = r.map_err(|e| CoreError::Storage(format!("list row: {e}")))?;
164 b.tags = self.tags_for(&b.id)?;
165 out.push(b);
166 }
167 Ok(out)
168 }
169
170 pub fn count(&self) -> Result<i64, CoreError> {
172 self.conn
173 .query_row(SQL_COUNT_ACTIVE, [], |row| row.get::<_, i64>(0))
174 .map_err(|e| CoreError::Storage(format!("count: {e}")))
175 }
176
177 pub fn count_all(&self) -> Result<i64, CoreError> {
179 self.conn
180 .query_row(SQL_COUNT_ALL, [], |row| row.get::<_, i64>(0))
181 .map_err(|e| CoreError::Storage(format!("count_all: {e}")))
182 }
183
184 pub fn delete(&mut self, id: &BookmarkId) -> Result<(), CoreError> {
188 let affected = self
189 .conn
190 .execute(SQL_ARCHIVE_BY_ID, params![id.0])
191 .map_err(|e| CoreError::Storage(format!("delete: {e}")))?;
192 if affected == 0 {
193 return Err(CoreError::Storage(format!(
194 "delete: no active row with id {}",
195 id.0
196 )));
197 }
198 Ok(())
199 }
200
201 pub fn set_tags(&mut self, id: &BookmarkId, tags: &[String]) -> Result<(), CoreError> {
205 let tx = self
206 .conn
207 .unchecked_transaction()
208 .map_err(|e| CoreError::Storage(format!("begin tags tx: {e}")))?;
209 tx.execute(SQL_DELETE_TAGS_FOR, params![id.0])
210 .map_err(|e| CoreError::Storage(format!("delete tags: {e}")))?;
211 for raw in tags {
212 if let Some(tag) = Tag::new(raw) {
213 tx.execute(SQL_INSERT_TAG, params![id.0, tag.0])
214 .map_err(|e| CoreError::Storage(format!("insert tag: {e}")))?;
215 }
216 }
217 tx.commit()
218 .map_err(|e| CoreError::Storage(format!("commit tags tx: {e}")))?;
219 Ok(())
220 }
221
222 pub fn tags_for(&self, id: &BookmarkId) -> Result<Vec<String>, CoreError> {
224 let mut stmt = self
225 .conn
226 .prepare_cached(SQL_SELECT_TAGS_FOR)
227 .map_err(|e| CoreError::Storage(format!("prepare tags_for: {e}")))?;
228 let rows = stmt
229 .query_map(params![id.0], |row| row.get::<_, String>(0))
230 .map_err(|e| CoreError::Storage(format!("tags_for query: {e}")))?;
231 let mut out = Vec::new();
232 for r in rows {
233 out.push(r.map_err(|e| CoreError::Storage(format!("tag row: {e}")))?);
234 }
235 Ok(out)
236 }
237
238 fn insert_new(&self, id: &BookmarkId, b: &Bookmark, now: i64) -> Result<(), CoreError> {
241 let added_at = if b.created_at.timestamp() > 0 {
242 b.created_at.timestamp()
243 } else {
244 now
245 };
246 let last_seen = if b.updated_at.timestamp() > 0 {
247 b.updated_at.timestamp()
248 } else {
249 now
250 };
251 let raw_str = b
252 .source
253 .raw
254 .as_ref()
255 .map(|v| serde_json::to_string(v).unwrap_or_default());
256 self.conn
257 .execute(
258 SQL_INSERT_BOOKMARK,
259 params![
260 id.0,
261 b.original_url,
262 b.canonical_url,
263 b.title,
264 b.description,
265 b.collection,
266 b.source.kind.as_cli_str(),
267 Option::<String>::None, b.source.external_id,
269 added_at,
270 last_seen,
271 raw_str,
272 if b.archived { 1i64 } else { 0i64 },
273 ],
274 )
275 .map_err(|e| CoreError::Storage(format!("insert bookmark: {e}")))?;
276 Ok(())
277 }
278
279 fn update_existing(&self, id: &BookmarkId, b: &Bookmark, _now: i64) -> Result<(), CoreError> {
280 let last_seen = if b.updated_at.timestamp() > 0 {
284 b.updated_at.timestamp()
285 } else {
286 _now
287 };
288 let raw_str = b
289 .source
290 .raw
291 .as_ref()
292 .map(|v| serde_json::to_string(v).unwrap_or_default());
293 self.conn
294 .execute(
295 SQL_UPDATE_BOOKMARK,
296 params![
297 b.original_url,
298 b.title,
299 b.description,
300 b.collection,
301 b.source.kind.as_cli_str(),
302 b.source.external_id,
303 last_seen,
304 raw_str,
305 id.0,
306 ],
307 )
308 .map_err(|e| CoreError::Storage(format!("update bookmark: {e}")))?;
309 Ok(())
310 }
311}
312
313const SQL_LIST_ACTIVE: &str = "\
316 SELECT id, original_url, canonical_url, title, description, collection, \
317 source_kind, source_id, external_id, added_at, last_seen_at, raw, archived \
318 FROM bookmarks WHERE archived = 0 \
319 ORDER BY last_seen_at DESC, id ASC LIMIT ?1 OFFSET ?2";
320
321const SQL_COUNT_ACTIVE: &str = "SELECT COUNT(*) FROM bookmarks WHERE archived = 0";
322const SQL_COUNT_ALL: &str = "SELECT COUNT(*) FROM bookmarks";
323
324const SQL_SELECT_TAGS_FOR: &str = "SELECT tag FROM tags WHERE bookmark_id = ?1 ORDER BY tag ASC";
325const SQL_DELETE_TAGS_FOR: &str = "DELETE FROM tags WHERE bookmark_id = ?1";
326const SQL_INSERT_TAG: &str = "INSERT OR IGNORE INTO tags (bookmark_id, tag) VALUES (?1, ?2)";
327
328const SQL_ARCHIVE_BY_ID: &str = "UPDATE bookmarks SET archived = 1 WHERE id = ?1 AND archived = 0";
329
330const SQL_INSERT_BOOKMARK: &str = "\
331 INSERT INTO bookmarks \
332 (id, original_url, canonical_url, title, description, collection, \
333 source_kind, source_id, external_id, added_at, last_seen_at, raw, archived) \
334 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)";
335
336const SQL_UPDATE_BOOKMARK: &str = "\
337 UPDATE bookmarks SET \
338 original_url = ?1, title = ?2, description = ?3, collection = ?4, \
339 source_kind = ?5, external_id = ?6, last_seen_at = ?7, raw = ?8 \
340 WHERE id = ?9";
341
342fn row_to_bookmark(row: &Row<'_>) -> rusqlite::Result<Bookmark> {
343 let id: String = row.get(0)?;
344 let original_url: String = row.get(1)?;
345 let canonical_url: String = row.get(2)?;
346 let title: String = row.get(3)?;
347 let description: Option<String> = row.get(4)?;
348 let collection: Option<String> = row.get(5)?;
349 let source_kind_str: String = row.get(6)?;
350 let _source_id: Option<String> = row.get(7)?;
351 let external_id: Option<String> = row.get(8)?;
352 let added_at: i64 = row.get(9)?;
353 let last_seen_at: i64 = row.get(10)?;
354 let raw_str: Option<String> = row.get(11)?;
355 let archived: i64 = row.get(12)?;
356
357 let kind = SourceKind::from_cli_str(&source_kind_str).unwrap_or(SourceKind::Manual);
358 let raw = raw_str.and_then(|s| serde_json::from_str(&s).ok());
359 let source = SourceRef {
360 kind,
361 external_id,
362 imported_at: Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now),
363 raw,
364 };
365
366 Ok(Bookmark {
367 id: BookmarkId(id),
368 original_url,
369 canonical_url,
370 title,
371 description,
372 tags: Vec::new(), collection,
374 created_at: epoch_to_utc(added_at),
375 updated_at: epoch_to_utc(last_seen_at),
376 source,
377 content_type: None, archived: archived != 0,
379 })
380}
381
382fn epoch_to_utc(secs: i64) -> DateTime<Utc> {
383 Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now)
384}
385
386#[inline]
387fn unix_now_secs() -> i64 {
388 std::time::SystemTime::now()
389 .duration_since(std::time::UNIX_EPOCH)
390 .map(|d| d.as_secs() as i64)
391 .unwrap_or(0)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::model::SourceKind;
398 use chrono::TimeZone;
399
400 fn mk(canonical: &str, title: &str, source: SourceKind, secs: i64) -> Bookmark {
401 Bookmark {
402 id: BookmarkId::generate(),
403 original_url: format!("https://example.com/{title}"),
404 canonical_url: canonical.into(),
405 title: title.into(),
406 description: None,
407 tags: vec!["rust".into(), "cli".into()],
408 collection: None,
409 created_at: Utc.timestamp_opt(secs, 0).unwrap(),
410 updated_at: Utc.timestamp_opt(secs, 0).unwrap(),
411 source: SourceRef {
412 kind: source,
413 external_id: Some(format!("ext-{title}")),
414 imported_at: Utc.timestamp_opt(secs, 0).unwrap(),
415 raw: Some(serde_json::json!({"marker": title})),
416 },
417 content_type: None,
418 archived: false,
419 }
420 }
421
422 #[test]
423 fn insert_new_returns_id() {
424 let mut s = open_in_memory().unwrap();
425 let b = mk("https://example.com/a", "A", SourceKind::Chromium, 1_000);
426 let id = s.upsert(&b).unwrap();
427 assert_eq!(s.count().unwrap(), 1);
428 let fetched = s.by_canonical("https://example.com/a").unwrap().unwrap();
429 assert_eq!(fetched.id, id);
430 assert_eq!(fetched.title, "A");
431 }
432
433 #[test]
434 fn by_canonical_returns_none_when_missing() {
435 let s = open_in_memory().unwrap();
436 assert!(s
437 .by_canonical("https://example.com/missing")
438 .unwrap()
439 .is_none());
440 }
441
442 #[test]
443 fn list_pagination_orders_by_last_seen_then_id() {
444 let mut s = open_in_memory().unwrap();
445 let b1 = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
446 let b2 = mk("https://example.com/b", "B", SourceKind::Chromium, 200);
447 let b3 = mk("https://example.com/c", "C", SourceKind::Chromium, 200);
448 s.upsert(&b1).unwrap();
449 s.upsert(&b2).unwrap();
450 s.upsert(&b3).unwrap();
451
452 let all = s.list(10, 0).unwrap();
453 assert_eq!(all.len(), 3);
454 let last_seens: Vec<i64> = all.iter().map(|b| b.updated_at.timestamp()).collect();
455 assert_eq!(last_seens, vec![200, 200, 100]);
456
457 let page1 = s.list(2, 0).unwrap();
458 let page2 = s.list(2, 2).unwrap();
459 assert_eq!(page1.len(), 2);
460 assert_eq!(page2.len(), 1);
461 }
462
463 #[test]
464 fn count_reflects_upserts_and_deletes() {
465 let mut s = open_in_memory().unwrap();
466 assert_eq!(s.count().unwrap(), 0);
467 let b1 = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
468 let b2 = mk("https://example.com/b", "B", SourceKind::Chromium, 200);
469 let id1 = s.upsert(&b1).unwrap();
470 let _id2 = s.upsert(&b2).unwrap();
471 assert_eq!(s.count().unwrap(), 2);
472 s.delete(&id1).unwrap();
473 assert_eq!(s.count().unwrap(), 1);
474 assert_eq!(s.count_all().unwrap(), 2);
475 }
476
477 #[test]
478 fn tags_set_and_replace() {
479 let mut s = open_in_memory().unwrap();
480 let mut b = mk("https://example.com/a", "A", SourceKind::Chromium, 100);
481 b.tags = vec!["Rust".into(), " CLI ".into(), "".into(), "Rust".into()];
482 let id = s.upsert(&b).unwrap();
483 let tags = s.tags_for(&id).unwrap();
484 assert_eq!(tags, vec!["cli".to_string(), "rust".to_string()]);
485
486 s.set_tags(&id, &["new".into(), "another".into()]).unwrap();
487 let tags = s.tags_for(&id).unwrap();
488 assert_eq!(tags, vec!["another".to_string(), "new".to_string()]);
489
490 s.set_tags(&id, &[]).unwrap();
491 let tags = s.tags_for(&id).unwrap();
492 assert!(tags.is_empty());
493 }
494}