Skip to main content

recently_used_xbel/
lib.rs

1// Copyright 2022 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4//! Parse the `~/.local/share/recently-used.xbel` file
5//!
6//! ```
7//! fn main() -> Result<(), Box<dyn std::error::Error>> {
8//!     let recently_used = recently_used_xbel::parse_file()?;
9//!
10//!     for bookmark in recently_used.bookmarks {
11//!         println!("{:?}", bookmark);
12//!     }
13//!
14//!     Ok(())
15//! }
16//! ```
17
18use chrono::{DateTime, SecondsFormat, Utc};
19use custom_writer::custom_write;
20use quick_xml::DeError;
21use serde::{Deserialize, Serialize};
22use std::{
23    collections::HashSet,
24    fs::{self},
25    path::{Path, PathBuf},
26    time::SystemTime,
27};
28use url::Url;
29mod custom_writer;
30
31/// Stores recently-opened files accessed by the desktop user.
32#[derive(Debug, Clone, Deserialize, Serialize)]
33#[serde(rename = "xbel", rename_all = "kebab-case")]
34pub struct RecentlyUsed {
35    #[serde(rename = "@xmlns:bookmark")]
36    pub xmlns_bookmark: String,
37    #[serde(rename = "@xmlns:mime")]
38    pub xmlns_mime: String,
39
40    /// Files that have been recently used.
41    #[serde(rename = "bookmark", default)]
42    pub bookmarks: Vec<Bookmark>,
43}
44
45/// A file that was recently opened by the desktop user.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47#[serde(rename_all = "kebab-case")]
48pub struct Bookmark {
49    /// The location of the file.
50    #[serde(rename = "@href")]
51    pub href: String,
52    /// When the file was added to the list.
53    #[serde(rename = "@added")]
54    pub added: String,
55    /// When the file was last modified.
56    #[serde(rename = "@modified")]
57    pub modified: String,
58    /// When the file was last visited.
59    #[serde(rename = "@visited")]
60    pub visited: String,
61    /// Additional metadata and applications related to the bookmark.
62    #[serde(rename = "info")]
63    pub info: Option<Info>,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
67#[serde(rename_all = "kebab-case")]
68pub struct Info {
69    /// Metadata about the bookmark.
70    #[serde(rename = "metadata")]
71    pub metadata: Metadata,
72}
73
74/// Metadata containing MIME type and application info.
75#[derive(Debug, Clone, Deserialize, Serialize)]
76#[serde(rename_all = "kebab-case")]
77pub struct Metadata {
78    /// The owner of the metadata.
79    #[serde(rename = "@owner")]
80    pub owner: String,
81
82    /// The MIME type information.
83    #[serde(rename = "mime-type")]
84    pub mime_type: Option<MimeType>,
85
86    /// The applications that have accessed the file.
87    #[serde(rename = "applications")]
88    pub applications: Applications,
89}
90
91/// The MIME type of the file.
92#[derive(Debug, Clone, Deserialize, Serialize)]
93#[serde(rename_all = "kebab-case")]
94pub struct MimeType {
95    /// The type of the file (e.g., "text/markdown").
96    #[serde(rename = "@type")]
97    pub mime_type: String,
98}
99
100/// A list of applications that accessed the bookmark.
101#[derive(Debug, Clone, Deserialize, Serialize)]
102#[serde(rename_all = "kebab-case")]
103pub struct Applications {
104    /// The list of applications.
105    //#[serde(rename(deserialize="application", serialize="bookmark:applications"))]
106    #[serde(rename = "application")]
107    pub applications: Vec<Application>,
108}
109
110/// An application that accessed the bookmark.
111#[derive(Debug, Clone, Deserialize, Serialize)]
112#[serde(rename_all = "kebab-case")]
113pub struct Application {
114    /// The name of the application.
115    #[serde(rename = "@name")]
116    pub name: String,
117
118    /// The command used to execute the application.
119    #[serde(rename = "@exec")]
120    pub exec: String,
121
122    /// When the application last modified the bookmark.
123    #[serde(rename = "@modified")]
124    pub modified: String,
125
126    /// The number of times the application has accessed the bookmark.
127    #[serde(rename = "@count")]
128    pub count: u32,
129}
130
131/// An error that can occur when accessing recently-used files.
132#[derive(Debug, thiserror::Error)]
133pub enum Error {
134    #[error("~/.local/share/recently-used.xbel: file does not exist")]
135    DoesNotExist,
136    #[error("~/.local/share/recently-used.xbel: could not deserialize")]
137    Deserialization(#[source] DeError),
138    #[error("could not serialize new file")]
139    Serialization(#[source] Option<DeError>),
140    #[error("could not read recents file")]
141    Read(#[source] std::io::Error),
142    #[error("could not read metadata from path")]
143    Metadata(#[source] std::io::Error),
144    #[error("could not read generate href from path")]
145    Path,
146    #[error("could not update recent files")]
147    Update,
148}
149
150/// The path where the recently-used.xbel file is expected to be found.
151pub fn dir() -> Option<PathBuf> {
152    dirs::home_dir().map(|dir| dir.join(".local/share/recently-used.xbel"))
153}
154
155/// Convenience function for parsing the recently-used.xbel file in its default location.
156pub fn parse_file() -> Result<RecentlyUsed, Error> {
157    let path = dir().ok_or(Error::DoesNotExist)?;
158    let file_content = fs::read_to_string(&path).map_err(|err| Error::Read(err))?;
159    quick_xml::de::from_str(&file_content).map_err(|err| Error::Deserialization(err))
160}
161
162/// Clear the list of recently used files.
163pub fn clear_recently_used() -> Result<(), Error> {
164    let mut parsed_file = parse_file()?;
165    parsed_file.bookmarks.clear();
166
167    let serialized = custom_write(parsed_file.clone())?;
168    let recently_used_file_path = dir().ok_or(Error::DoesNotExist)?;
169    let xml_declaration = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
170    let full_content = format!("{}{}", xml_declaration, serialized);
171
172    fs::write(recently_used_file_path, full_content).map_err(|_| Error::Update)?;
173
174    Ok(())
175}
176
177/// Updates the list of recently used files.
178///
179/// This function checks if the specified file already exists in the recently used list.
180/// If it exists, the function updates the file's metadata, including the times when the file was
181/// added, modified, and last visited. If the file does not exist in the list, the function adds
182/// a new entry for the file.
183///
184/// If the file already exists in the list, the function also updates the application's usage count,
185/// or adds a new application entry if it hasn't been recorded previously.
186///
187/// # Arguments
188///
189/// * `element_path` - A `PathBuf` that represents the path to the file being updated or added.
190/// * `app_name` - A `String` representing the name of the application associated with the file.
191/// * `exec` - A `String` representing the command to execute the application.
192/// * `owner` - An optional `String` representing the owner of the metadata. If not provided,
193///   defaults to `"http://freedesktop.org"`.
194///
195/// # Returns
196///
197/// This function returns `Result<(), Error>`, which is:
198/// - `Ok(())` on success.
199/// - `Err(Error)` if there is a failure in processing the file (e.g., reading metadata, serialization, or file I/O).
200///
201/// # Errors
202///
203/// This function can return errors in the following cases:
204///
205/// - If the file's metadata cannot be accessed or read.
206/// - If the recently used file list cannot be parsed or serialized.
207/// - If there is an issue writing the updated list back to the file system.
208pub fn update_recently_used(
209    element_path: &PathBuf,
210    app_name: String,
211    exec: String,
212    owner: Option<String>,
213) -> Result<(), Error> {
214    let owner = match owner {
215        Some(owner) => owner,
216        None => "http://freedesktop.org".to_string(),
217    };
218    let mut parsed_file = parse_file()?;
219    let href = path_to_href(element_path).ok_or(Error::Path)?;
220    let metadata = element_path.metadata().map_err(Error::Metadata)?;
221    let added = system_time_to_string(metadata.created().map_err(Error::Metadata)?);
222    let modified = system_time_to_string(metadata.modified().map_err(Error::Metadata)?);
223    let visited = system_time_to_string(metadata.accessed().map_err(Error::Metadata)?);
224
225    // Attempt to find the existing bookmark and update it if found
226    let existing_bookmark = parsed_file.bookmarks.iter_mut().find(|b| b.href == href);
227
228    if let Some(bookmark) = existing_bookmark {
229        // Bookmark exists, update the metadata
230        bookmark.added = added;
231        bookmark.modified = modified.clone();
232        bookmark.visited = visited;
233
234        // Find the application entry or insert a new one
235        if let Some(info) = bookmark.info.as_mut() {
236            if let Some(app) = info
237                .metadata
238                .applications
239                .applications
240                .iter_mut()
241                .find(|el| el.name == app_name)
242            {
243                app.count += 1;
244                app.modified = modified.clone();
245            } else {
246                // Application not found, insert a new one
247                info.metadata.applications.applications.push(Application {
248                    name: app_name,
249                    exec,
250                    modified: modified.clone(),
251                    count: 1,
252                });
253            }
254        }
255    } else {
256        // Bookmark does not exist, create a new one
257        let mime = mime_from_path(&element_path).map(|mime| MimeType { mime_type: mime });
258
259        let applications = vec![Application {
260            name: app_name,
261            exec,
262            modified: modified.clone(),
263            count: 1,
264        }];
265
266        let info = Info {
267            metadata: Metadata {
268                owner,
269                mime_type: mime,
270                applications: Applications { applications },
271            },
272        };
273
274        let new_bookmark = Bookmark {
275            href,
276            added,
277            modified,
278            visited,
279            info: Some(info),
280        };
281
282        parsed_file.bookmarks.push(new_bookmark);
283    }
284
285    let serialized = custom_write(parsed_file.clone())?;
286    let recently_used_file_path = dir().ok_or(Error::DoesNotExist)?;
287    let xml_declaration = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
288    let full_content = format!("{}{}", xml_declaration, serialized);
289
290    fs::write(recently_used_file_path, full_content).map_err(|_| Error::Update)?;
291
292    Ok(())
293}
294
295/// Removes elements from the list of recently used files.
296///
297/// # Arguments
298///
299/// * `element_path` - A `PathBuf` that represents the path to the file being removed.
300///
301/// # Returns
302///
303/// This function returns `Result<(), Error>`, which is:
304/// - `Ok(())` on success.
305/// - `Err(Error)` if there is a failure in processing the file (e.g., reading metadata, serialization, or file I/O).
306///
307/// # Errors
308///
309/// This function can return errors in the following cases:
310///
311/// - If the file's metadata cannot be accessed or read.
312/// - If the recently used file list cannot be parsed or serialized.
313/// - If there is an issue writing the updated list back to the file system.
314pub fn remove_recently_used(element_paths: &[&Path]) -> Result<(), Error> {
315    let mut parsed_file = parse_file()?;
316
317    let mut hrefs = HashSet::with_capacity(element_paths.len());
318    for path in element_paths {
319        hrefs.insert(path_to_href(path).ok_or(Error::Path)?);
320    }
321
322    parsed_file.bookmarks.retain(|b| !hrefs.contains(&b.href));
323
324    let serialized = custom_write(parsed_file.clone())?;
325    let recently_used_file_path = dir().ok_or(Error::DoesNotExist)?;
326    let xml_declaration = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
327    let full_content = format!("{}{}", xml_declaration, serialized);
328
329    fs::write(recently_used_file_path, full_content).map_err(|_| Error::Update)?;
330
331    Ok(())
332}
333
334fn system_time_to_string(time: SystemTime) -> String {
335    let datetime: DateTime<Utc> = time.into();
336    datetime.to_rfc3339_opts(SecondsFormat::Micros, true)
337}
338
339fn path_to_href(path: &Path) -> Option<String> {
340    let path_str = path.to_str()?;
341    Url::from_file_path(path_str).ok().map(Into::into)
342}
343
344fn mime_from_path(path: &Path) -> Option<String> {
345    let path = path.to_string_lossy().to_string();
346    let kind = mime_guess::from_path(path);
347    let mime = kind.first();
348    let mime = match mime {
349        Some(mime) => mime,
350        None => return None,
351    };
352    Some(format!("{}/{}", mime.type_(), mime.subtype()))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::{
359        fs::{self, OpenOptions},
360        io::Write,
361    };
362    use tempfile::tempdir;
363
364    #[test]
365    fn test_update_recenty_used() -> Result<(), Box<dyn std::error::Error>> {
366        let temp_dir = tempdir()?;
367        let temp_file_path = temp_dir.path().join("test_file.txt");
368        let recently_used_path = dir().ok_or(Error::DoesNotExist)?;
369
370        fs::write(&temp_file_path, b"Test content")?;
371
372        if !recently_used_path.exists() {
373            create_empty_recently_used_file(&recently_used_path)?;
374        }
375
376        update_recently_used(
377            &temp_file_path,
378            String::from("org.test"),
379            String::from("test"),
380            None,
381        )?;
382
383        // check new file name is in recents
384        let content = fs::read_to_string(&recently_used_path)?;
385        assert!(content.contains("test_file.txt"));
386
387        let deserialized = parse_file()?;
388
389        assert!(deserialized.bookmarks.len() > 0);
390
391        let bookmark = deserialized
392            .bookmarks
393            .iter()
394            .find(|el| el.href.contains("test_file"));
395
396        assert!(bookmark.is_some());
397
398        let length_before_remove = deserialized.bookmarks.len();
399
400        remove_recently_used(&[&temp_file_path])?;
401
402        // Check that the file name was removed from recents
403        let content = fs::read_to_string(&recently_used_path)?;
404        assert!(!content.contains("test_file.txt"));
405
406        let deserialized = parse_file()?;
407
408        assert!(deserialized.bookmarks.len() == length_before_remove - 1);
409
410        let bookmark = deserialized
411            .bookmarks
412            .iter()
413            .find(|el| el.href.contains("test_file"));
414
415        assert!(bookmark.is_none());
416
417        Ok(())
418    }
419
420    fn create_empty_recently_used_file(path: &PathBuf) -> Result<(), Error> {
421        let empty_file = RecentlyUsed {
422            bookmarks: vec![],
423            xmlns_mime: String::new(),
424            xmlns_bookmark: String::new(),
425        };
426        let serialized =
427            quick_xml::se::to_string(&empty_file).map_err(|why| Error::Serialization(Some(why)))?;
428        let mut file = OpenOptions::new()
429            .write(true)
430            .create(true)
431            .open(path)
432            .map_err(|_| Error::Update)?;
433        file.write_all(serialized.as_bytes())
434            .map_err(|_| Error::Update)?;
435        Ok(())
436    }
437}