gardesk/garfield / 14dc68e

Browse files

recents: add xbel write support

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
14dc68e5d63f5a81b5e1686b9434272c8b3483ed
Parents
787e118
Tree
d356cca

1 changed file

StatusFile+-
M garfield/src/core/recents.rs 210 10
garfield/src/core/recents.rsmodified
@@ -3,13 +3,14 @@
33
 //! Parses and manages `~/.local/share/recently-used.xbel` (XBEL format).
44
 //! This provides system-wide recently accessed files from any application.
55
 
6
-use std::fs;
7
-use std::io::BufReader;
6
+use std::fs::{self, File};
7
+use std::io::{BufReader, BufWriter, Write};
88
 use std::path::{Path, PathBuf};
99
 use std::time::SystemTime;
1010
 
11
-use quick_xml::events::{BytesStart, Event};
12
-use quick_xml::Reader;
11
+use chrono::{DateTime, Utc};
12
+use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
13
+use quick_xml::{Reader, Writer};
1314
 use thiserror::Error;
1415
 
1516
 /// Maximum number of recent entries to track.
@@ -173,12 +174,184 @@ impl RecentsManager {
173174
     /// Add an entry when garfield opens a file (writes to xbel).
174175
     /// This makes garfield a good citizen of the XDG ecosystem.
175176
     pub fn add_entry(&mut self, path: &Path, mime_type: &str) -> Result<(), RecentsError> {
176
-        // For now, just reload after external tools write
177
-        // Full write support can be added later
178
-        let _ = (path, mime_type);
177
+        // Read existing bookmarks
178
+        let mut bookmarks = self.read_all_bookmarks()?;
179
+
180
+        // Create file URI
181
+        let uri = format!("file://{}", percent_encode(&path.to_string_lossy()));
182
+        let now = Utc::now();
183
+        let now_str = now.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
184
+
185
+        // Check if entry already exists
186
+        if let Some(existing) = bookmarks.iter_mut().find(|b| b.href == uri) {
187
+            // Update existing entry
188
+            existing.visited = now_str.clone();
189
+            existing.modified = now_str;
190
+            existing.app_count += 1;
191
+        } else {
192
+            // Add new entry
193
+            bookmarks.push(StoredBookmark {
194
+                href: uri,
195
+                added: now_str.clone(),
196
+                modified: now_str.clone(),
197
+                visited: now_str,
198
+                mime_type: mime_type.to_string(),
199
+                app_count: 1,
200
+            });
201
+        }
202
+
203
+        // Write back to file
204
+        self.write_bookmarks(&bookmarks)?;
205
+
206
+        // Reload cache
179207
         self.load()
180208
     }
181209
 
210
+    /// Read all bookmarks from the xbel file (for rewriting).
211
+    fn read_all_bookmarks(&self) -> Result<Vec<StoredBookmark>, RecentsError> {
212
+        let mut bookmarks = Vec::new();
213
+
214
+        if !self.xbel_path.exists() {
215
+            return Ok(bookmarks);
216
+        }
217
+
218
+        let file = fs::File::open(&self.xbel_path)?;
219
+        let reader = BufReader::new(file);
220
+        let mut xml = Reader::from_reader(reader);
221
+        xml.config_mut().trim_text(true);
222
+
223
+        let mut buf = Vec::new();
224
+        let mut current: Option<StoredBookmark> = None;
225
+        let mut in_metadata = false;
226
+
227
+        loop {
228
+            match xml.read_event_into(&mut buf) {
229
+                Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
230
+                    match e.name().as_ref() {
231
+                        b"bookmark" => {
232
+                            let href = get_attr(e, b"href").unwrap_or_default();
233
+                            let added = get_attr(e, b"added").unwrap_or_default();
234
+                            let modified = get_attr(e, b"modified").unwrap_or_default();
235
+                            let visited = get_attr(e, b"visited").unwrap_or_default();
236
+                            current = Some(StoredBookmark {
237
+                                href,
238
+                                added,
239
+                                modified,
240
+                                visited,
241
+                                mime_type: String::new(),
242
+                                app_count: 1,
243
+                            });
244
+                        }
245
+                        b"mime:mime-type" if current.is_some() && in_metadata => {
246
+                            if let Some(ref mut bm) = current {
247
+                                bm.mime_type = get_attr(e, b"type").unwrap_or_default();
248
+                            }
249
+                        }
250
+                        b"bookmark:application" if current.is_some() => {
251
+                            if let Some(ref mut bm) = current {
252
+                                if let Some(count_str) = get_attr(e, b"count") {
253
+                                    bm.app_count = count_str.parse().unwrap_or(1);
254
+                                }
255
+                            }
256
+                        }
257
+                        b"metadata" => {
258
+                            in_metadata = true;
259
+                        }
260
+                        _ => {}
261
+                    }
262
+                }
263
+                Ok(Event::End(ref e)) => match e.name().as_ref() {
264
+                    b"bookmark" => {
265
+                        if let Some(bm) = current.take() {
266
+                            // Keep all bookmarks, not just file:// ones
267
+                            bookmarks.push(bm);
268
+                        }
269
+                    }
270
+                    b"metadata" => {
271
+                        in_metadata = false;
272
+                    }
273
+                    _ => {}
274
+                },
275
+                Ok(Event::Eof) => break,
276
+                Err(e) => return Err(RecentsError::Xml(e)),
277
+                _ => {}
278
+            }
279
+            buf.clear();
280
+        }
281
+
282
+        Ok(bookmarks)
283
+    }
284
+
285
+    /// Write bookmarks back to the xbel file.
286
+    fn write_bookmarks(&self, bookmarks: &[StoredBookmark]) -> Result<(), RecentsError> {
287
+        let file = File::create(&self.xbel_path)?;
288
+        let mut writer = Writer::new_with_indent(BufWriter::new(file), b' ', 2);
289
+
290
+        // XML declaration
291
+        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
292
+
293
+        // Root element with namespaces
294
+        let mut xbel = BytesStart::new("xbel");
295
+        xbel.push_attribute(("version", "1.0"));
296
+        xbel.push_attribute(("xmlns:bookmark", "http://www.freedesktop.org/standards/desktop-bookmarks"));
297
+        xbel.push_attribute(("xmlns:mime", "http://www.freedesktop.org/standards/shared-mime-info"));
298
+        writer.write_event(Event::Start(xbel))?;
299
+
300
+        // Write each bookmark
301
+        for bookmark in bookmarks {
302
+            self.write_bookmark(&mut writer, bookmark)?;
303
+        }
304
+
305
+        // Close root
306
+        writer.write_event(Event::End(BytesEnd::new("xbel")))?;
307
+
308
+        writer.into_inner().flush()?;
309
+        Ok(())
310
+    }
311
+
312
+    /// Write a single bookmark element.
313
+    fn write_bookmark<W: Write>(&self, writer: &mut Writer<W>, bookmark: &StoredBookmark) -> Result<(), RecentsError> {
314
+        let mut elem = BytesStart::new("bookmark");
315
+        elem.push_attribute(("href", bookmark.href.as_str()));
316
+        elem.push_attribute(("added", bookmark.added.as_str()));
317
+        elem.push_attribute(("modified", bookmark.modified.as_str()));
318
+        elem.push_attribute(("visited", bookmark.visited.as_str()));
319
+        writer.write_event(Event::Start(elem))?;
320
+
321
+        // <info>
322
+        writer.write_event(Event::Start(BytesStart::new("info")))?;
323
+
324
+        // <metadata>
325
+        let mut metadata = BytesStart::new("metadata");
326
+        metadata.push_attribute(("owner", "http://freedesktop.org"));
327
+        writer.write_event(Event::Start(metadata))?;
328
+
329
+        // <mime:mime-type>
330
+        if !bookmark.mime_type.is_empty() {
331
+            let mut mime = BytesStart::new("mime:mime-type");
332
+            mime.push_attribute(("type", bookmark.mime_type.as_str()));
333
+            writer.write_event(Event::Empty(mime))?;
334
+        }
335
+
336
+        // <bookmark:applications>
337
+        writer.write_event(Event::Start(BytesStart::new("bookmark:applications")))?;
338
+
339
+        // <bookmark:application>
340
+        let mut app = BytesStart::new("bookmark:application");
341
+        app.push_attribute(("name", "garfield"));
342
+        app.push_attribute(("exec", "garfield %u"));
343
+        app.push_attribute(("modified", bookmark.modified.as_str()));
344
+        app.push_attribute(("count", bookmark.app_count.to_string().as_str()));
345
+        writer.write_event(Event::Empty(app))?;
346
+
347
+        writer.write_event(Event::End(BytesEnd::new("bookmark:applications")))?;
348
+        writer.write_event(Event::End(BytesEnd::new("metadata")))?;
349
+        writer.write_event(Event::End(BytesEnd::new("info")))?;
350
+        writer.write_event(Event::End(BytesEnd::new("bookmark")))?;
351
+
352
+        Ok(())
353
+    }
354
+
182355
     /// Clear all cached entries (does not modify the file).
183356
     pub fn clear_cache(&mut self) {
184357
         self.entries.clear();
@@ -191,6 +364,16 @@ impl Default for RecentsManager {
191364
     }
192365
 }
193366
 
367
+/// A bookmark as stored in the xbel file (for rewriting).
368
+struct StoredBookmark {
369
+    href: String,
370
+    added: String,
371
+    modified: String,
372
+    visited: String,
373
+    mime_type: String,
374
+    app_count: u32,
375
+}
376
+
194377
 /// Partial bookmark being parsed.
195378
 struct PartialBookmark {
196379
     href: Option<String>,
@@ -245,6 +428,26 @@ fn get_attr(e: &BytesStart, name: &[u8]) -> Option<String> {
245428
         .and_then(|a| String::from_utf8(a.value.to_vec()).ok())
246429
 }
247430
 
431
+/// Percent-encode a path for use in file:// URIs.
432
+fn percent_encode(s: &str) -> String {
433
+    let mut result = String::with_capacity(s.len() * 3);
434
+    for c in s.chars() {
435
+        match c {
436
+            // Safe characters (unreserved in RFC 3986 + / for paths)
437
+            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | '/' => {
438
+                result.push(c);
439
+            }
440
+            // Everything else gets percent-encoded
441
+            _ => {
442
+                for byte in c.to_string().as_bytes() {
443
+                    result.push_str(&format!("%{:02X}", byte));
444
+                }
445
+            }
446
+        }
447
+    }
448
+    result
449
+}
450
+
248451
 /// Percent-decode a URL path component.
249452
 fn percent_decode(s: &str) -> String {
250453
     let mut result = String::with_capacity(s.len());
@@ -274,9 +477,6 @@ fn percent_decode(s: &str) -> String {
274477
 /// Parse ISO 8601 timestamp to SystemTime.
275478
 fn parse_iso8601(s: &str) -> Option<SystemTime> {
276479
     // Format: 2026-01-12T11:08:52.375556Z
277
-    // We'll use chrono for parsing
278
-    use chrono::{DateTime, Utc};
279
-
280480
     let dt: DateTime<Utc> = s.parse().ok()?;
281481
     Some(SystemTime::from(dt))
282482
 }