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