fontpm_source_google_fonts/
lib.rs

1mod github;
2mod data;
3
4use std::collections::HashMap;
5use std::fs::{create_dir_all, File};
6use std::path::{Path, PathBuf};
7use default_env::default_env;
8use fontpm_api::{FpmHost, Source, trace};
9use fontpm_api::async_trait::async_trait;
10use fontpm_api::host::EmptyFpmHost;
11use fontpm_api::source::{RefreshOutput};
12use fontpm_api::Error;
13use std::io::{Error as IOError, ErrorKind as IOErrorKind, Read, Write};
14use reqwest::{Client, ClientBuilder};
15use serde::{Serialize};
16use serde::de::DeserializeOwned;
17use sha2::{Sha256, Digest};
18use fontpm_api::font::{DefinedFontInstallSpec, DefinedFontVariantSpec, FontInstallSpec, FontDescription as FpmFontDescription};
19use fontpm_api::util::create_parent;
20use crate::data::Data;
21use crate::data::description::variant_to_string;
22use crate::github::GithubBranchData;
23
24pub struct GoogleFontsSource<'host> {
25    host: &'host dyn FpmHost,
26    client: Option<Client>
27}
28
29// GitHub API
30const COMMIT_DATA_URL: &str = default_env!("COMMIT", "https://api.github.com/repos/fontpm/data/branches/data");
31// Raw contentâ„¢
32const FONT_INDEX_URL: &str = default_env!("FONT_INDEX_URL", "https://raw.githubusercontent.com/fontpm/data/data/google-fonts.json");
33
34const COMMIT_FILE: &str = "commit.sha";
35const DATA_FILE: &str = "data.json";
36
37impl<'host> GoogleFontsSource<'host> {
38    pub const ID: &'host str = "google-fonts";
39    pub const NAME: &'host str = "Google Fonts";
40
41    pub fn new() -> Self {
42        return GoogleFontsSource {
43            host: &EmptyFpmHost::EMPTY_HOST,
44            client: None
45        };
46    }
47
48    fn client(&self) -> &Client {
49        return self.client.as_ref().unwrap();
50    }
51
52    fn cache_dir(&self) -> PathBuf {
53        return self.host.cache_dir_for(Self::ID.into());
54    }
55    fn cache_file<P>(&self, s: P) -> PathBuf where P: AsRef<Path> {
56        let mut path = self.cache_dir();
57        path.push(s);
58        path
59    }
60
61    fn last_downloaded_commit(&self) -> Option<String> {
62        log::debug!("Reading last downloaded commit");
63        let mut path = self.cache_dir();
64        path.push(COMMIT_FILE);
65        let path_str = path.clone().into_os_string();
66        let path_str = path_str.to_string_lossy();
67
68        let path = path;
69
70        let mut file = match File::open(path) {
71            Ok(v) => v,
72            Err(e) => {
73                match e.kind() {
74                    IOErrorKind::NotFound => return None,
75                    // there should probably be better error handling than this, but it's fine for now
76                    _ => log::error!("Error whilst opening {}: {}", path_str, e)
77                }
78                return None
79            }
80        };
81
82        let mut data = String::new();
83        match file.read_to_string(&mut data) {
84            Ok(v) => log::trace!("Read {} bytes from {}", v, path_str),
85            Err(e) => {
86                log::error!("Error whilst reading data: {}", e);
87                return None
88            }
89        }
90
91        Some(data)
92    }
93
94    async fn latest_commit(&self) -> Result<String, Error> {
95        let response = self.client().get(COMMIT_DATA_URL).send().await?;
96        #[cfg(debug_assertions)]
97        let data = {
98            let text = response.text().await?;
99            serde_json::from_str::<GithubBranchData>(text.as_str())
100                .map_err(|v| Error::Deserialisation(v.to_string()))?
101        };
102        #[cfg(not(debug_assertions))]
103        let data: GithubBranchData = response.json().await?;
104
105        Ok(data.commit.sha)
106    }
107
108    async fn get_data(&self) -> Result<Data, reqwest::Error> {
109        let response = self.client().get(FONT_INDEX_URL).send().await?;
110        let data = response.json::<Data>().await?;
111        Ok(data)
112    }
113
114    fn cache_write_str<S, V>(&self, file: S, value: V) -> Result<(), Error> where S: AsRef<Path>, V: Into<String> {
115        let path = self.cache_file(file);
116        create_parent(&path)?;
117
118        let str: String = value.into();
119        let mut file = File::create(path)?;
120        file.write_all(str.as_bytes())?;
121
122        Ok(())
123    }
124    fn cache_write_serialise<P, T>(&self, file: P, value: &T) -> Result<(), Error> where P: AsRef<Path>, T: Serialize {
125        let path = self.cache_file(file);
126        create_parent(&path)?;
127
128        let file = File::create(path)?;
129        serde_json::ser::to_writer(file, value)
130            .map_err(|v| Error::Generic(format!("Error whilst serialising: {}", v)))?;
131
132        Ok(())
133    }
134    fn cache_read_deserialise<'de, T, P>(&self, file: P) -> Result<T, Error> where P: AsRef<Path>, T: DeserializeOwned {
135        let path = self.cache_file(file);
136        if !path.exists() {
137            return Err(Error::IO(IOErrorKind::NotFound.into()))
138        }
139
140        let file = File::open(path)?;
141        let result = serde_json::de::from_reader::<_, T>(file)
142            .map_err(|v| Error::Deserialisation(format!("{}", v)));
143
144        result
145    }
146
147    fn read_data(&self) -> Result<Data, Error> {
148        let data_file = self.cache_file(DATA_FILE);
149        if !data_file.exists() {
150            return Err(Error::IO(IOError::new(IOErrorKind::NotFound, "Data file does not exist")))
151        }
152
153        self.cache_read_deserialise(data_file)
154    }
155}
156
157#[async_trait]
158impl<'host> Source<'host> for GoogleFontsSource<'host> {
159    fn id(&self) -> &'host str {
160        return Self::ID;
161    }
162    fn name(&self) -> &'host str {
163        return Self::NAME;
164    }
165
166    fn set_host(&mut self, host: &'host dyn FpmHost) {
167        self.host = host;
168        self.client = Some(
169            ClientBuilder::new()
170                .user_agent(host.user_agent())
171                .build()
172                .expect("HTTP client required")
173        )
174    }
175
176    async fn refresh(&self, force: bool) -> Result<RefreshOutput, Error> {
177        let cache_file = self.cache_file(DATA_FILE);
178        let latest = self.latest_commit().await?;
179        trace!("[{}] Latest commit: {}", Self::ID, latest);
180
181        if !force {
182            let current = self.last_downloaded_commit();
183            trace!("[{}] Last downloaded commit: {}", Self::ID, current.clone().unwrap_or("<none>".into()));
184            if current != None && cache_file.exists() && current.unwrap() == latest {
185                return Ok(RefreshOutput::AlreadyUpToDate)
186            }
187        }
188
189        let index = self.get_data().await?;
190        self.cache_write_serialise(DATA_FILE, &index)?;
191        self.cache_write_str(COMMIT_FILE, latest)?;
192
193        Ok(RefreshOutput::Downloaded)
194    }
195
196    async fn resolve_font(&self, spec: &FontInstallSpec) -> Result<(DefinedFontInstallSpec, FpmFontDescription), Error> {
197        let data = self.read_data()?;
198
199        let family = data.get_family(&spec.id).ok_or(Error::NoSuchFamily(spec.id.clone()))?;
200
201        Ok((family.clone().try_into()?, family.into()))
202    }
203
204    async fn download_font(&self, font_id: &DefinedFontInstallSpec, dir: &PathBuf) -> Result<HashMap<DefinedFontVariantSpec, PathBuf>, Error> {
205        let data = self.read_data()?;
206
207        let font = if let Some(desc) = data.get_family(&font_id.id) {
208            desc
209        } else {
210            return Err(Error::Generic(format!("Font {} does not exist", font_id.id)))
211        };
212
213        let dir = dir.join(&font_id.id);
214        let mut paths = HashMap::new();
215
216        for variant in &font_id.styles {
217            let variant_name = variant_to_string(variant);
218            let remote_file = match font.files.get(variant_name.as_str()) {
219                Some(file) => file.clone(),
220                None => return Err(Error::Generic(format!("Could not get file for font variant {variant_name}")))
221            };
222
223            let extension = PathBuf::from(&remote_file).extension().map_or(String::new(), |v| ".".to_string() + v.to_str().unwrap());
224            let url_hash = Sha256::new()
225                .chain_update(&remote_file)
226                .finalize();
227            let url_hash = format!("{:x}", url_hash);
228            let path = dir.join(variant_name).join(format!("{}{}", url_hash, extension));
229
230            paths.insert(variant.clone(), path.clone());
231            if path.exists() {
232                continue;
233            }
234            path.parent().map(create_dir_all);
235
236            let remote_data = reqwest::get("https://".to_string() + remote_file.as_str()).await?.error_for_status()?;
237            let mut file = File::create(&path)?;
238            let remote_data = remote_data.bytes().await?;
239            file.write_all(remote_data.as_ref())?;
240        }
241
242        Ok(paths)
243    }
244}