Skip to main content

falcon_library/
inboard.rs

1//! Reading an Inboard backup folder for import, as Eagle's File ▸ Import ▸
2//! Inboard does.
3//!
4//! The folder holds `InboardBackup.json` with `folders` (each with a
5//! `title`) and `items`, plus the files in `images/` and `screenshots/`.
6//! Eagle creates one top-level folder per Inboard folder (all of them, in
7//! order; when two share a title, items go to the later one), then imports
8//! every item of `type` `"image"` whose file exists: the file is the last
9//! path component of the item's `path`, looked up in `screenshots/` when
10//! that path runs through a `/screenshots/` folder and in `images/`
11//! otherwise. The item keeps Inboard's `title` as its name, its `tags`,
12//! its `url`, its trashed state (`isTrashed` other than 0) and its `date`
13//! as the date added.
14
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use crate::error::{Error, Result};
22
23/// The backup's index file.
24pub const INDEX: &str = "InboardBackup.json";
25
26/// What an Inboard backup holds.
27#[derive(Debug, Clone, PartialEq, Default)]
28pub struct InboardBackup {
29    /// Folder titles, in order (possibly repeated).
30    pub folders: Vec<String>,
31    /// The items that can be imported.
32    pub items: Vec<InboardItem>,
33    /// Image items whose file isn't in the backup.
34    pub missing: usize,
35}
36
37/// One image to import.
38#[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    /// The date added, ms since the epoch, when the backup's date reads.
46    pub added_at: Option<u64>,
47    /// Titles of the Inboard folders it was in.
48    pub folders: Vec<String>,
49}
50
51/// Whether `dir` is an Inboard backup folder.
52pub fn is_backup(dir: &Path) -> bool {
53    dir.join(INDEX).is_file()
54}
55
56/// Reads the backup in `dir`.
57pub 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
93/// Inboard's `date`: a number is taken as ms (as JavaScript's `new
94/// Date(n)` takes it), a string as RFC 3339 / ISO 8601 or
95/// `YYYY-MM-DD HH:MM:SS ±ZZZZ`.
96fn 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
110/// Which folder id each title maps to after creating one folder per
111/// entry of `titles` (ids in the same order): a repeated title maps to the
112/// last folder made with it, as in Eagle.
113pub 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}