1use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use crate::error::{Error, Result};
22
23pub const INDEX: &str = "InboardBackup.json";
25
26#[derive(Debug, Clone, PartialEq, Default)]
28pub struct InboardBackup {
29 pub folders: Vec<String>,
31 pub items: Vec<InboardItem>,
33 pub missing: usize,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct InboardItem {
40 pub file: PathBuf,
41 pub name: String,
42 pub tags: Vec<String>,
43 pub url: String,
44 pub trashed: bool,
45 pub added_at: Option<u64>,
47 pub folders: Vec<String>,
49}
50
51pub fn is_backup(dir: &Path) -> bool {
53 dir.join(INDEX).is_file()
54}
55
56pub fn read(dir: &Path) -> Result<InboardBackup> {
58 let index = dir.join(INDEX);
59 let bytes = fs::read(&index).map_err(|e| Error::io(&index, e))?;
60 let v: Value = serde_json::from_slice(&bytes).map_err(|e| Error::json(&index, e))?;
61 let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
62 let list = |v: &Value, k: &str| v.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
63 let mut out = InboardBackup { folders: list(&v, "folders").iter().map(|f| text(f, "title")).collect(), ..Default::default() };
64 for item in list(&v, "items") {
65 if item.get("type").and_then(Value::as_str) != Some("image") {
66 continue;
67 }
68 let path = text(&item, "path");
69 let base = path.rsplit('/').next().unwrap_or_default();
70 let sub = if path.contains("/screenshots/") { "screenshots" } else { "images" };
71 let file = dir.join(sub).join(base);
72 if base.is_empty() || !file.is_file() {
73 out.missing += 1;
74 continue;
75 }
76 out.items.push(InboardItem {
77 file,
78 name: text(&item, "title"),
79 tags: list(&item, "tags").iter().filter_map(|t| t.as_str().map(String::from)).collect(),
80 url: text(&item, "url"),
81 trashed: match item.get("isTrashed") {
82 Some(Value::Number(n)) => n.as_f64() != Some(0.0),
83 Some(Value::Bool(b)) => *b,
84 _ => false,
85 },
86 added_at: item.get("date").and_then(parse_date),
87 folders: list(&item, "folders").iter().filter_map(|t| t.as_str().map(String::from)).collect(),
88 });
89 }
90 Ok(out)
91}
92
93fn parse_date(v: &Value) -> Option<u64> {
97 match v {
98 Value::Number(n) => n.as_f64().filter(|f| *f >= 0.0).map(|f| f as u64),
99 Value::String(s) => {
100 let s = s.trim();
101 chrono::DateTime::parse_from_rfc3339(s)
102 .or_else(|_| chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z"))
103 .ok()
104 .and_then(|d| u64::try_from(d.timestamp_millis()).ok())
105 }
106 _ => None,
107 }
108}
109
110pub fn folder_map(titles: &[String], ids: &[String]) -> HashMap<String, String> {
114 titles.iter().cloned().zip(ids.iter().cloned()).collect()
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn reads_a_backup() {
123 let d = tempfile::tempdir().unwrap();
124 fs::create_dir_all(d.path().join("images")).unwrap();
125 fs::create_dir_all(d.path().join("screenshots")).unwrap();
126 fs::write(d.path().join("images/a.png"), b"a").unwrap();
127 fs::write(d.path().join("screenshots/s.png"), b"s").unwrap();
128 let json = serde_json::json!({
129 "folders": [{"title": "Logos"}, {"title": "UI"}, {"title": "Logos"}],
130 "items": [
131 {"type": "image", "title": "A", "tags": ["x", "y"], "url": "https://example.com/a",
132 "isTrashed": 0, "date": "2018-03-04T05:06:07Z", "folders": ["Logos", "Nope"],
133 "path": "/Users/someone/Library/Containers/x/Data/images/a.png"},
134 {"type": "image", "title": "Shot", "tags": [], "url": "", "isTrashed": 1,
135 "date": 1520139967000_u64, "folders": [], "path": "/x/screenshots/s.png"},
136 {"type": "image", "title": "Gone", "path": "/x/images/gone.png", "folders": []},
137 {"type": "link", "title": "Skip", "path": "/x/images/a.png"}
138 ]
139 });
140 fs::write(d.path().join(INDEX), json.to_string()).unwrap();
141 assert!(is_backup(d.path()));
142 let b = read(d.path()).unwrap();
143 assert_eq!(b.folders, vec!["Logos", "UI", "Logos"]);
144 assert_eq!(b.missing, 1);
145 assert_eq!(b.items.len(), 2);
146 let a = &b.items[0];
147 assert_eq!((a.name.as_str(), a.file.clone(), a.trashed), ("A", d.path().join("images/a.png"), false));
148 assert_eq!(a.added_at, Some(1_520_139_967_000));
149 assert_eq!(a.tags, vec!["x", "y"]);
150 let s = &b.items[1];
151 assert_eq!((s.file.clone(), s.trashed, s.added_at), (d.path().join("screenshots/s.png"), true, Some(1_520_139_967_000)));
152
153 let map = folder_map(&b.folders, &["F1".into(), "F2".into(), "F3".into()]);
154 assert_eq!(map["Logos"], "F3", "a repeated title goes to the last folder");
155 assert_eq!(map["UI"], "F2");
156 }
157
158 #[test]
159 fn dates() {
160 assert_eq!(parse_date(&serde_json::json!("2018-03-04 05:06:07 +0000")), Some(1_520_139_967_000));
161 assert_eq!(parse_date(&serde_json::json!("not a date")), None);
162 assert_eq!(parse_date(&serde_json::json!(null)), None);
163 }
164}