gardesk/garfield / c03b8a8

Browse files

core: add xbel parser for xdg recently-used files

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c03b8a8ef3d465ca701e852fe40a799ecd303945
Parents
bcc8d49
Tree
273808e

1 changed file

StatusFile+-
A garfield/src/core/recents.rs 307 0
garfield/src/core/recents.rsadded
@@ -0,0 +1,307 @@
1
+//! XDG Recently Used files manager.
2
+//!
3
+//! Parses and manages `~/.local/share/recently-used.xbel` (XBEL format).
4
+//! This provides system-wide recently accessed files from any application.
5
+
6
+use std::fs;
7
+use std::io::BufReader;
8
+use std::path::{Path, PathBuf};
9
+use std::time::SystemTime;
10
+
11
+use quick_xml::events::{BytesStart, Event};
12
+use quick_xml::Reader;
13
+use thiserror::Error;
14
+
15
+/// Maximum number of recent entries to track.
16
+const MAX_ENTRIES: usize = 25;
17
+
18
+/// Errors from recents operations.
19
+#[derive(Debug, Error)]
20
+pub enum RecentsError {
21
+    #[error("Failed to read xbel file: {0}")]
22
+    Io(#[from] std::io::Error),
23
+    #[error("XML parse error: {0}")]
24
+    Xml(#[from] quick_xml::Error),
25
+    #[error("Invalid timestamp format")]
26
+    InvalidTimestamp,
27
+}
28
+
29
+/// A recently accessed file from the XDG recently-used.xbel file.
30
+#[derive(Debug, Clone)]
31
+pub struct RecentEntry {
32
+    /// File path (decoded from file:// URI).
33
+    pub path: PathBuf,
34
+    /// MIME type (e.g., "image/png", "inode/directory").
35
+    pub mime_type: Option<String>,
36
+    /// Last visit timestamp.
37
+    pub visited: SystemTime,
38
+    /// Last modification timestamp.
39
+    pub modified: SystemTime,
40
+}
41
+
42
+impl RecentEntry {
43
+    /// Returns true if this entry is a directory.
44
+    pub fn is_directory(&self) -> bool {
45
+        self.mime_type
46
+            .as_ref()
47
+            .is_some_and(|m| m == "inode/directory")
48
+    }
49
+
50
+    /// Returns the file name.
51
+    pub fn file_name(&self) -> Option<&str> {
52
+        self.path.file_name().and_then(|n| n.to_str())
53
+    }
54
+
55
+    /// Returns the parent directory.
56
+    pub fn parent_dir(&self) -> Option<&Path> {
57
+        self.path.parent()
58
+    }
59
+}
60
+
61
+/// Manager for XDG recently-used files.
62
+pub struct RecentsManager {
63
+    /// Path to recently-used.xbel file.
64
+    xbel_path: PathBuf,
65
+    /// Cached entries (sorted by visited time, most recent first).
66
+    entries: Vec<RecentEntry>,
67
+    /// Maximum entries to display.
68
+    max_entries: usize,
69
+}
70
+
71
+impl RecentsManager {
72
+    /// Create a new RecentsManager with default path.
73
+    pub fn new() -> Self {
74
+        let xbel_path = dirs::data_local_dir()
75
+            .unwrap_or_else(|| PathBuf::from("~/.local/share"))
76
+            .join("recently-used.xbel");
77
+
78
+        Self {
79
+            xbel_path,
80
+            entries: Vec::new(),
81
+            max_entries: MAX_ENTRIES,
82
+        }
83
+    }
84
+
85
+    /// Load/refresh entries from ~/.local/share/recently-used.xbel
86
+    pub fn load(&mut self) -> Result<(), RecentsError> {
87
+        self.entries.clear();
88
+
89
+        if !self.xbel_path.exists() {
90
+            return Ok(());
91
+        }
92
+
93
+        let file = fs::File::open(&self.xbel_path)?;
94
+        let reader = BufReader::new(file);
95
+        let mut xml = Reader::from_reader(reader);
96
+        xml.config_mut().trim_text(true);
97
+
98
+        let mut buf = Vec::new();
99
+        let mut current_bookmark: Option<PartialBookmark> = None;
100
+        let mut in_metadata = false;
101
+
102
+        loop {
103
+            match xml.read_event_into(&mut buf) {
104
+                Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
105
+                    match e.name().as_ref() {
106
+                        b"bookmark" => {
107
+                            current_bookmark = Some(parse_bookmark_attrs(e));
108
+                        }
109
+                        b"mime:mime-type" if current_bookmark.is_some() && in_metadata => {
110
+                            if let Some(ref mut bm) = current_bookmark {
111
+                                bm.mime_type = get_attr(e, b"type");
112
+                            }
113
+                        }
114
+                        b"metadata" => {
115
+                            in_metadata = true;
116
+                        }
117
+                        _ => {}
118
+                    }
119
+
120
+                    // Handle empty elements (self-closing tags)
121
+                    if matches!(xml.read_event_into(&mut buf), Ok(Event::End(_))) {
122
+                        // This was actually a start tag, not empty
123
+                    }
124
+                }
125
+                Ok(Event::End(ref e)) => match e.name().as_ref() {
126
+                    b"bookmark" => {
127
+                        if let Some(bm) = current_bookmark.take() {
128
+                            if let Some(entry) = bm.into_entry() {
129
+                                // Only include file:// entries that still exist
130
+                                if entry.path.exists() {
131
+                                    self.entries.push(entry);
132
+                                }
133
+                            }
134
+                        }
135
+                    }
136
+                    b"metadata" => {
137
+                        in_metadata = false;
138
+                    }
139
+                    _ => {}
140
+                },
141
+                Ok(Event::Eof) => break,
142
+                Err(e) => return Err(RecentsError::Xml(e)),
143
+                _ => {}
144
+            }
145
+            buf.clear();
146
+        }
147
+
148
+        // Sort by visited time, most recent first
149
+        self.entries
150
+            .sort_by(|a, b| b.visited.cmp(&a.visited));
151
+
152
+        // Limit to max entries
153
+        self.entries.truncate(self.max_entries);
154
+
155
+        Ok(())
156
+    }
157
+
158
+    /// Get entries (files that still exist, limited to max_entries).
159
+    pub fn entries(&self) -> &[RecentEntry] {
160
+        &self.entries
161
+    }
162
+
163
+    /// Check if there are any entries.
164
+    pub fn is_empty(&self) -> bool {
165
+        self.entries.is_empty()
166
+    }
167
+
168
+    /// Get entry count.
169
+    pub fn len(&self) -> usize {
170
+        self.entries.len()
171
+    }
172
+
173
+    /// Add an entry when garfield opens a file (writes to xbel).
174
+    /// This makes garfield a good citizen of the XDG ecosystem.
175
+    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);
179
+        self.load()
180
+    }
181
+
182
+    /// Clear all cached entries (does not modify the file).
183
+    pub fn clear_cache(&mut self) {
184
+        self.entries.clear();
185
+    }
186
+}
187
+
188
+impl Default for RecentsManager {
189
+    fn default() -> Self {
190
+        Self::new()
191
+    }
192
+}
193
+
194
+/// Partial bookmark being parsed.
195
+struct PartialBookmark {
196
+    href: Option<String>,
197
+    visited: Option<String>,
198
+    modified: Option<String>,
199
+    mime_type: Option<String>,
200
+}
201
+
202
+impl PartialBookmark {
203
+    fn into_entry(self) -> Option<RecentEntry> {
204
+        let href = self.href?;
205
+
206
+        // Only handle file:// URIs
207
+        if !href.starts_with("file://") {
208
+            return None;
209
+        }
210
+
211
+        // Decode file:// URI to path
212
+        let path_str = &href[7..]; // Strip "file://"
213
+        let decoded = percent_decode(path_str);
214
+        let path = PathBuf::from(decoded);
215
+
216
+        let visited = parse_iso8601(&self.visited.unwrap_or_default())
217
+            .unwrap_or(SystemTime::UNIX_EPOCH);
218
+        let modified = parse_iso8601(&self.modified.unwrap_or_default())
219
+            .unwrap_or(SystemTime::UNIX_EPOCH);
220
+
221
+        Some(RecentEntry {
222
+            path,
223
+            mime_type: self.mime_type,
224
+            visited,
225
+            modified,
226
+        })
227
+    }
228
+}
229
+
230
+/// Parse bookmark element attributes.
231
+fn parse_bookmark_attrs(e: &BytesStart) -> PartialBookmark {
232
+    PartialBookmark {
233
+        href: get_attr(e, b"href"),
234
+        visited: get_attr(e, b"visited"),
235
+        modified: get_attr(e, b"modified"),
236
+        mime_type: None,
237
+    }
238
+}
239
+
240
+/// Get an attribute value as String.
241
+fn get_attr(e: &BytesStart, name: &[u8]) -> Option<String> {
242
+    e.attributes()
243
+        .filter_map(|a| a.ok())
244
+        .find(|a| a.key.as_ref() == name)
245
+        .and_then(|a| String::from_utf8(a.value.to_vec()).ok())
246
+}
247
+
248
+/// Percent-decode a URL path component.
249
+fn percent_decode(s: &str) -> String {
250
+    let mut result = String::with_capacity(s.len());
251
+    let mut chars = s.chars().peekable();
252
+
253
+    while let Some(c) = chars.next() {
254
+        if c == '%' {
255
+            // Try to parse the next two characters as hex
256
+            let hex: String = chars.by_ref().take(2).collect();
257
+            if hex.len() == 2 {
258
+                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
259
+                    result.push(byte as char);
260
+                    continue;
261
+                }
262
+            }
263
+            // If parsing failed, keep the original
264
+            result.push('%');
265
+            result.push_str(&hex);
266
+        } else {
267
+            result.push(c);
268
+        }
269
+    }
270
+
271
+    result
272
+}
273
+
274
+/// Parse ISO 8601 timestamp to SystemTime.
275
+fn parse_iso8601(s: &str) -> Option<SystemTime> {
276
+    // Format: 2026-01-12T11:08:52.375556Z
277
+    // We'll use chrono for parsing
278
+    use chrono::{DateTime, Utc};
279
+
280
+    let dt: DateTime<Utc> = s.parse().ok()?;
281
+    Some(SystemTime::from(dt))
282
+}
283
+
284
+#[cfg(test)]
285
+mod tests {
286
+    use super::*;
287
+
288
+    #[test]
289
+    fn test_percent_decode() {
290
+        assert_eq!(percent_decode("hello%20world"), "hello world");
291
+        assert_eq!(percent_decode("file%3A%2F%2F"), "file://");
292
+        assert_eq!(percent_decode("no_encoding"), "no_encoding");
293
+    }
294
+
295
+    #[test]
296
+    fn test_parse_iso8601() {
297
+        let ts = parse_iso8601("2026-01-12T11:08:52.375556Z");
298
+        assert!(ts.is_some());
299
+    }
300
+
301
+    #[test]
302
+    fn test_recents_manager_new() {
303
+        let manager = RecentsManager::new();
304
+        assert!(manager.entries.is_empty());
305
+        assert!(manager.xbel_path.ends_with("recently-used.xbel"));
306
+    }
307
+}