@@ -3,13 +3,14 @@ |
| 3 | 3 | //! Parses and manages `~/.local/share/recently-used.xbel` (XBEL format). |
| 4 | 4 | //! This provides system-wide recently accessed files from any application. |
| 5 | 5 | |
| 6 | | -use std::fs; |
| 7 | | -use std::io::BufReader; |
| 6 | +use std::fs::{self, File}; |
| 7 | +use std::io::{BufReader, BufWriter, Write}; |
| 8 | 8 | use std::path::{Path, PathBuf}; |
| 9 | 9 | use std::time::SystemTime; |
| 10 | 10 | |
| 11 | | -use quick_xml::events::{BytesStart, Event}; |
| 12 | | -use quick_xml::Reader; |
| 11 | +use chrono::{DateTime, Utc}; |
| 12 | +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event}; |
| 13 | +use quick_xml::{Reader, Writer}; |
| 13 | 14 | use thiserror::Error; |
| 14 | 15 | |
| 15 | 16 | /// Maximum number of recent entries to track. |
@@ -173,12 +174,184 @@ impl RecentsManager { |
| 173 | 174 | /// Add an entry when garfield opens a file (writes to xbel). |
| 174 | 175 | /// This makes garfield a good citizen of the XDG ecosystem. |
| 175 | 176 | 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); |
| 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 |
| 179 | 207 | self.load() |
| 180 | 208 | } |
| 181 | 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 | + |
| 182 | 355 | /// Clear all cached entries (does not modify the file). |
| 183 | 356 | pub fn clear_cache(&mut self) { |
| 184 | 357 | self.entries.clear(); |
@@ -191,6 +364,16 @@ impl Default for RecentsManager { |
| 191 | 364 | } |
| 192 | 365 | } |
| 193 | 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 | + |
| 194 | 377 | /// Partial bookmark being parsed. |
| 195 | 378 | struct PartialBookmark { |
| 196 | 379 | href: Option<String>, |
@@ -245,6 +428,26 @@ fn get_attr(e: &BytesStart, name: &[u8]) -> Option<String> { |
| 245 | 428 | .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) |
| 246 | 429 | } |
| 247 | 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 | + |
| 248 | 451 | /// Percent-decode a URL path component. |
| 249 | 452 | fn percent_decode(s: &str) -> String { |
| 250 | 453 | let mut result = String::with_capacity(s.len()); |
@@ -274,9 +477,6 @@ fn percent_decode(s: &str) -> String { |
| 274 | 477 | /// Parse ISO 8601 timestamp to SystemTime. |
| 275 | 478 | fn parse_iso8601(s: &str) -> Option<SystemTime> { |
| 276 | 479 | // Format: 2026-01-12T11:08:52.375556Z |
| 277 | | - // We'll use chrono for parsing |
| 278 | | - use chrono::{DateTime, Utc}; |
| 279 | | - |
| 280 | 480 | let dt: DateTime<Utc> = s.parse().ok()?; |
| 281 | 481 | Some(SystemTime::from(dt)) |
| 282 | 482 | } |