1use 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#[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 #[serde(rename = "bookmark", default)]
42 pub bookmarks: Vec<Bookmark>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
47#[serde(rename_all = "kebab-case")]
48pub struct Bookmark {
49 #[serde(rename = "@href")]
51 pub href: String,
52 #[serde(rename = "@added")]
54 pub added: String,
55 #[serde(rename = "@modified")]
57 pub modified: String,
58 #[serde(rename = "@visited")]
60 pub visited: String,
61 #[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 #[serde(rename = "metadata")]
71 pub metadata: Metadata,
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
76#[serde(rename_all = "kebab-case")]
77pub struct Metadata {
78 #[serde(rename = "@owner")]
80 pub owner: String,
81
82 #[serde(rename = "mime-type")]
84 pub mime_type: Option<MimeType>,
85
86 #[serde(rename = "applications")]
88 pub applications: Applications,
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
93#[serde(rename_all = "kebab-case")]
94pub struct MimeType {
95 #[serde(rename = "@type")]
97 pub mime_type: String,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize)]
102#[serde(rename_all = "kebab-case")]
103pub struct Applications {
104 #[serde(rename = "application")]
107 pub applications: Vec<Application>,
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize)]
112#[serde(rename_all = "kebab-case")]
113pub struct Application {
114 #[serde(rename = "@name")]
116 pub name: String,
117
118 #[serde(rename = "@exec")]
120 pub exec: String,
121
122 #[serde(rename = "@modified")]
124 pub modified: String,
125
126 #[serde(rename = "@count")]
128 pub count: u32,
129}
130
131#[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
150pub fn dir() -> Option<PathBuf> {
152 dirs::home_dir().map(|dir| dir.join(".local/share/recently-used.xbel"))
153}
154
155pub 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
162pub 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
177pub 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 let existing_bookmark = parsed_file.bookmarks.iter_mut().find(|b| b.href == href);
227
228 if let Some(bookmark) = existing_bookmark {
229 bookmark.added = added;
231 bookmark.modified = modified.clone();
232 bookmark.visited = visited;
233
234 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 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 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
295pub 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 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 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}