| 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 |