@@ -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 | +} |