Rust · 17033 bytes Raw Blame History
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::{self, File};
7 use std::io::{BufReader, BufWriter, Write};
8 use std::path::{Path, PathBuf};
9 use std::time::SystemTime;
10
11 use chrono::{DateTime, Utc};
12 use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
13 use quick_xml::{Reader, Writer};
14 use thiserror::Error;
15
16 /// Maximum number of recent entries to track.
17 const MAX_ENTRIES: usize = 25;
18
19 /// Errors from recents operations.
20 #[derive(Debug, Error)]
21 pub enum RecentsError {
22 #[error("Failed to read xbel file: {0}")]
23 Io(#[from] std::io::Error),
24 #[error("XML parse error: {0}")]
25 Xml(#[from] quick_xml::Error),
26 #[error("Invalid timestamp format")]
27 InvalidTimestamp,
28 }
29
30 /// A recently accessed file from the XDG recently-used.xbel file.
31 #[derive(Debug, Clone)]
32 pub struct RecentEntry {
33 /// File path (decoded from file:// URI).
34 pub path: PathBuf,
35 /// MIME type (e.g., "image/png", "inode/directory").
36 pub mime_type: Option<String>,
37 /// Last visit timestamp.
38 pub visited: SystemTime,
39 /// Last modification timestamp.
40 pub modified: SystemTime,
41 }
42
43 impl RecentEntry {
44 /// Returns true if this entry is a directory.
45 pub fn is_directory(&self) -> bool {
46 self.mime_type
47 .as_ref()
48 .is_some_and(|m| m == "inode/directory")
49 }
50
51 /// Returns the file name.
52 pub fn file_name(&self) -> Option<&str> {
53 self.path.file_name().and_then(|n| n.to_str())
54 }
55
56 /// Returns the parent directory.
57 pub fn parent_dir(&self) -> Option<&Path> {
58 self.path.parent()
59 }
60 }
61
62 /// Manager for XDG recently-used files.
63 pub struct RecentsManager {
64 /// Path to recently-used.xbel file.
65 xbel_path: PathBuf,
66 /// Cached entries (sorted by visited time, most recent first).
67 entries: Vec<RecentEntry>,
68 /// Maximum entries to display.
69 max_entries: usize,
70 }
71
72 impl RecentsManager {
73 /// Create a new RecentsManager with default path.
74 pub fn new() -> Self {
75 let xbel_path = dirs::data_local_dir()
76 .unwrap_or_else(|| PathBuf::from("~/.local/share"))
77 .join("recently-used.xbel");
78
79 Self {
80 xbel_path,
81 entries: Vec::new(),
82 max_entries: MAX_ENTRIES,
83 }
84 }
85
86 /// Load/refresh entries from ~/.local/share/recently-used.xbel
87 pub fn load(&mut self) -> Result<(), RecentsError> {
88 self.entries.clear();
89
90 if !self.xbel_path.exists() {
91 return Ok(());
92 }
93
94 let file = fs::File::open(&self.xbel_path)?;
95 let reader = BufReader::new(file);
96 let mut xml = Reader::from_reader(reader);
97 xml.config_mut().trim_text(true);
98
99 let mut buf = Vec::new();
100 let mut current_bookmark: Option<PartialBookmark> = None;
101 let mut in_metadata = false;
102
103 loop {
104 match xml.read_event_into(&mut buf) {
105 Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
106 match e.name().as_ref() {
107 b"bookmark" => {
108 current_bookmark = Some(parse_bookmark_attrs(e));
109 }
110 b"mime:mime-type" if current_bookmark.is_some() && in_metadata => {
111 if let Some(ref mut bm) = current_bookmark {
112 bm.mime_type = get_attr(e, b"type");
113 }
114 }
115 b"metadata" => {
116 in_metadata = true;
117 }
118 _ => {}
119 }
120
121 // Handle empty elements (self-closing tags)
122 if matches!(xml.read_event_into(&mut buf), Ok(Event::End(_))) {
123 // This was actually a start tag, not empty
124 }
125 }
126 Ok(Event::End(ref e)) => match e.name().as_ref() {
127 b"bookmark" => {
128 if let Some(bm) = current_bookmark.take() {
129 if let Some(entry) = bm.into_entry() {
130 // Only include file:// entries that still exist
131 if entry.path.exists() {
132 self.entries.push(entry);
133 }
134 }
135 }
136 }
137 b"metadata" => {
138 in_metadata = false;
139 }
140 _ => {}
141 },
142 Ok(Event::Eof) => break,
143 Err(e) => return Err(RecentsError::Xml(e)),
144 _ => {}
145 }
146 buf.clear();
147 }
148
149 // Sort by visited time, most recent first
150 self.entries
151 .sort_by(|a, b| b.visited.cmp(&a.visited));
152
153 // Limit to max entries
154 self.entries.truncate(self.max_entries);
155
156 Ok(())
157 }
158
159 /// Get entries (files that still exist, limited to max_entries).
160 pub fn entries(&self) -> &[RecentEntry] {
161 &self.entries
162 }
163
164 /// Check if there are any entries.
165 pub fn is_empty(&self) -> bool {
166 self.entries.is_empty()
167 }
168
169 /// Get entry count.
170 pub fn len(&self) -> usize {
171 self.entries.len()
172 }
173
174 /// Add an entry when garfield opens a file (writes to xbel).
175 /// This makes garfield a good citizen of the XDG ecosystem.
176 pub fn add_entry(&mut self, path: &Path, mime_type: &str) -> Result<(), RecentsError> {
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
207 self.load()
208 }
209
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
355 /// Clear all cached entries (does not modify the file).
356 pub fn clear_cache(&mut self) {
357 self.entries.clear();
358 }
359 }
360
361 impl Default for RecentsManager {
362 fn default() -> Self {
363 Self::new()
364 }
365 }
366
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
377 /// Partial bookmark being parsed.
378 struct PartialBookmark {
379 href: Option<String>,
380 visited: Option<String>,
381 modified: Option<String>,
382 mime_type: Option<String>,
383 }
384
385 impl PartialBookmark {
386 fn into_entry(self) -> Option<RecentEntry> {
387 let href = self.href?;
388
389 // Only handle file:// URIs
390 if !href.starts_with("file://") {
391 return None;
392 }
393
394 // Decode file:// URI to path
395 let path_str = &href[7..]; // Strip "file://"
396 let decoded = percent_decode(path_str);
397 let path = PathBuf::from(decoded);
398
399 let visited = parse_iso8601(&self.visited.unwrap_or_default())
400 .unwrap_or(SystemTime::UNIX_EPOCH);
401 let modified = parse_iso8601(&self.modified.unwrap_or_default())
402 .unwrap_or(SystemTime::UNIX_EPOCH);
403
404 Some(RecentEntry {
405 path,
406 mime_type: self.mime_type,
407 visited,
408 modified,
409 })
410 }
411 }
412
413 /// Parse bookmark element attributes.
414 fn parse_bookmark_attrs(e: &BytesStart) -> PartialBookmark {
415 PartialBookmark {
416 href: get_attr(e, b"href"),
417 visited: get_attr(e, b"visited"),
418 modified: get_attr(e, b"modified"),
419 mime_type: None,
420 }
421 }
422
423 /// Get an attribute value as String.
424 fn get_attr(e: &BytesStart, name: &[u8]) -> Option<String> {
425 e.attributes()
426 .filter_map(|a| a.ok())
427 .find(|a| a.key.as_ref() == name)
428 .and_then(|a| String::from_utf8(a.value.to_vec()).ok())
429 }
430
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
451 /// Percent-decode a URL path component.
452 fn percent_decode(s: &str) -> String {
453 let mut result = String::with_capacity(s.len());
454 let mut chars = s.chars().peekable();
455
456 while let Some(c) = chars.next() {
457 if c == '%' {
458 // Try to parse the next two characters as hex
459 let hex: String = chars.by_ref().take(2).collect();
460 if hex.len() == 2 {
461 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
462 result.push(byte as char);
463 continue;
464 }
465 }
466 // If parsing failed, keep the original
467 result.push('%');
468 result.push_str(&hex);
469 } else {
470 result.push(c);
471 }
472 }
473
474 result
475 }
476
477 /// Parse ISO 8601 timestamp to SystemTime.
478 fn parse_iso8601(s: &str) -> Option<SystemTime> {
479 // Format: 2026-01-12T11:08:52.375556Z
480 let dt: DateTime<Utc> = s.parse().ok()?;
481 Some(SystemTime::from(dt))
482 }
483
484 #[cfg(test)]
485 mod tests {
486 use super::*;
487
488 #[test]
489 fn test_percent_decode() {
490 assert_eq!(percent_decode("hello%20world"), "hello world");
491 assert_eq!(percent_decode("file%3A%2F%2F"), "file://");
492 assert_eq!(percent_decode("no_encoding"), "no_encoding");
493 }
494
495 #[test]
496 fn test_parse_iso8601() {
497 let ts = parse_iso8601("2026-01-12T11:08:52.375556Z");
498 assert!(ts.is_some());
499 }
500
501 #[test]
502 fn test_recents_manager_new() {
503 let manager = RecentsManager::new();
504 assert!(manager.entries.is_empty());
505 assert!(manager.xbel_path.ends_with("recently-used.xbel"));
506 }
507 }
508