ctoolbox/
storage.rs

1use crate::formats::{
2    html::html2text_with_width, troff::convert_man_troff_to_html,
3};
4use crate::utilities::*;
5use anyhow::{Context, Result};
6use directories::ProjectDirs;
7use encre_css::{Config, Preflight};
8use handlebars::Handlebars;
9use std::{
10    fs::{self, OpenOptions},
11    io::Write,
12    path::{Path, PathBuf},
13};
14
15pub mod db;
16pub mod graph;
17pub mod node;
18pub mod pc_settings;
19pub mod user;
20
21pub fn put(key: Vec<u8>, value: Vec<u8>) {
22    println!(
23        "Storage put with key {}, value {}",
24        String::from_utf8_lossy(&key),
25        String::from_utf8_lossy(&value)
26    );
27}
28
29pub fn get(key: Vec<u8>) -> Option<Vec<u8>> {
30    println!("Storage get with key {}", String::from_utf8_lossy(&key));
31    Some(key)
32}
33
34pub fn create_store(_name: String, _password: String) {}
35
36pub fn get_asset(key: &str) -> Option<Vec<u8>> {
37    let key = if key.starts_with('/') {
38        key.strip_prefix('/').expect("Strip prefix failed")
39    } else {
40        key
41    };
42
43    let file = if key == "src.zip" {
44        crate::SOURCE_ZIP.get_file(key)
45    } else {
46        crate::PROJECT_DIR.get_file(key)
47    };
48
49    Some(file?.contents().to_vec())
50}
51
52fn _get_storage_dir() -> Option<PathBuf> {
53    Some(
54        ProjectDirs::from("com", "collectivetoolbox", "collectivetoolbox")?
55            .data_dir()
56            .to_path_buf(),
57    )
58}
59
60pub fn get_storage_dir() -> Result<PathBuf> {
61    let data_dir =
62        _get_storage_dir().ok_or(anyhow::anyhow!("Failed to get cache dir"))?;
63    std::fs::create_dir_all(data_dir.clone())
64        .with_context(|| format!("Failed to create cache dir {data_dir:?}"))?;
65    Ok(data_dir)
66}
67
68pub fn get_help_troff() -> Vec<u8> {
69    get_asset("docs/cli/ctoolbox.1").expect("Could not load help")
70}
71
72pub fn get_help_html() -> Vec<u8> {
73    let (converted, log) = convert_man_troff_to_html(get_help_troff())
74        .expect("Could not convert help");
75    log.auto_log();
76    converted
77}
78
79pub fn get_help_for_tty(width: u16) -> Vec<u8> {
80    html2text_with_width(get_help_html(), width)
81}
82
83fn inline_css_imports(css: &str, base_path: &str) -> Result<String> {
84    fn normalize_path(p: &Path) -> PathBuf {
85        let mut stack = Vec::new();
86        for component in p.components() {
87            match component {
88                std::path::Component::Normal(c) => stack.push(c),
89                std::path::Component::ParentDir => {
90                    stack.pop();
91                }
92                std::path::Component::CurDir => {}
93                _ => {}
94            }
95        }
96        stack.into_iter().collect()
97    }
98
99    let mut result = String::new();
100    for line in css.lines() {
101        if line.trim_start().starts_with("@import") {
102            let trimmed = line.trim_start();
103            if trimmed == "@import \"tailwindcss\";" {
104                continue;
105            }
106            if let Some(start) = trimmed.find("url('") {
107                if let Some(end) = trimmed[start + 5..].find("')") {
108                    let path = &trimmed[start + 5..start + 5 + end];
109                    let resolved =
110                        if path.starts_with("web/") || path.starts_with('/') {
111                            path.strip_prefix('/').unwrap_or(path).to_string()
112                        } else {
113                            Path::new(base_path)
114                                .join(path)
115                                .to_string_lossy()
116                                .to_string()
117                        };
118                    let resolved_path = Path::new(&resolved);
119                    let normalized = normalize_path(resolved_path);
120                    let resolved = normalized.to_string_lossy().to_string();
121                    let imported_css = String::from_utf8(
122                        get_asset(&resolved).ok_or_else(|| {
123                            anyhow::anyhow!(
124                                "Failed to load imported CSS: {}",
125                                resolved
126                            )
127                        })?,
128                    )
129                    .with_context(|| {
130                        format!(
131                            "Failed to convert imported CSS to string: {}",
132                            resolved
133                        )
134                    })?;
135                    let new_base = Path::new(&resolved)
136                        .parent()
137                        .unwrap_or(Path::new(""))
138                        .to_string_lossy();
139                    let inlined = inline_css_imports(&imported_css, &new_base)?;
140                    result.push_str(&inlined);
141                    result.push('\n');
142                    continue;
143                }
144            }
145            return Err(anyhow::anyhow!(
146                "Failed to parse @import line: {}",
147                line
148            ));
149        } else {
150            result.push_str(line);
151            result.push('\n');
152        }
153    }
154    Ok(result)
155}
156
157pub fn register_views<'a>() -> handlebars::Handlebars<'a> {
158    let mut handlebars = Handlebars::new();
159    handlebars.set_strict_mode(true);
160    let mut buffer = String::new();
161    let views = crate::PROJECT_DIR
162        .find("**/*.hbs")
163        .expect("Failed to find views");
164    for entry in views {
165        let path = entry.path();
166        let template_name = substr_mb(
167            path.to_str()
168                .expect("Built-in assets should all use valid UTF-8 filenames"),
169            6,
170            -4,
171        )
172        .replace('/', ".");
173        log!(format!("Registering template {}", template_name));
174        let template_contents: &str = crate::PROJECT_DIR
175            .get_file(path)
176            .unwrap()
177            .contents_utf8()
178            .unwrap();
179        assert!(
180            handlebars
181                .register_template_string(
182                    template_name.as_str(),
183                    template_contents
184                )
185                .is_ok()
186        );
187        buffer.push_str(template_contents);
188    }
189
190    // JS
191    let js = String::from_utf8(
192        get_asset("web/pretty-load.js").expect("Could not load pretty-load.js"),
193    )
194    .expect("Could not convert pretty-load.js to string");
195    buffer.push_str(js.as_str());
196
197    let mut config = Config::default();
198    let mut css: String = String::new();
199
200    // Imports
201
202    let app_css = String::from_utf8(
203        get_asset("web/app.css").expect("Could not load app.css"),
204    )
205    .expect("Could not convert app.css to string");
206
207    // Preflight
208    config.preflight = Preflight::new_full();
209    css.push_str(encre_css::generate([""], &config).as_str());
210
211    config.preflight = Preflight::new_none();
212
213    // Inline imports
214
215    let inlined_app_css =
216        inline_css_imports(&app_css, "web/").expect("Failed to inline imports");
217
218    // app.css
219    for line in inlined_app_css.lines() {
220        if line.trim_start().starts_with("@apply") {
221            let html =
222                format!("<div class='{}'></div>", remove_suffix(line, ";"));
223            let html_css = encre_css::generate([html.as_str()], &config);
224            let html_css = html_css.as_str();
225            let mut starting_media_query;
226            let mut in_media_query = false;
227            let mut leaving_media_query;
228            for line in html_css.lines() {
229                starting_media_query = line.starts_with('@');
230                leaving_media_query = line.starts_with('}') && in_media_query;
231                if (!in_media_query && line.starts_with(' '))
232                    || starting_media_query
233                    || (in_media_query
234                        && !line.ends_with('{')
235                        && !line.ends_with('}'))
236                    || leaving_media_query
237                {
238                    css.push_str(format!("{line}\n").as_str());
239                }
240                if starting_media_query {
241                    in_media_query = true;
242                }
243                if leaving_media_query {
244                    in_media_query = false;
245                }
246            }
247            continue;
248        }
249        css.push_str(format!("{line}\n").as_str());
250    }
251
252    // Views
253    let css_from_views = encre_css::generate([buffer.as_str()], &config);
254    for line in css_from_views.lines() {
255        if line == "@media (prefers-color-scheme: dark) {" {
256            css.push_str(
257                format!("{}\n", "@container dark (width > 0) {").as_str(),
258            );
259        } else if line.trim_start().starts_with('@') {
260            css.push_str(format!("{line}\n").as_str());
261        } else {
262            css.push_str(
263                format!("{line}\n")
264                    .as_str()
265                    .replace(" {", ":not(.increase-specificity) {")
266                    .as_str(),
267            );
268        }
269    }
270    css.push('\n');
271    config.preflight = Preflight::new_none();
272
273    // log!(css);
274    assert!(
275        handlebars
276            .register_template_string("encre_css", css)
277            .is_ok()
278    );
279    handlebars.set_strict_mode(true);
280    handlebars
281}
282
283pub fn guarantee_dir(path: &Path) -> Result<()> {
284    if let Some(parent) = path.parent() {
285        if !parent.exists() {
286            fs::create_dir_all(parent).with_context(|| {
287                format!("Failed to create parent dir {parent:?}")
288            })?;
289        }
290    }
291    Ok(())
292}
293
294pub fn put_file(path: &Path, data: &[u8]) -> Result<()> {
295    guarantee_dir(path)?;
296    overwrite_file(path, data)
297}
298
299pub fn append_file(path: &Path, data: &[u8]) -> Result<()> {
300    guarantee_dir(path)?;
301    let mut file = OpenOptions::new()
302        .create(true)
303        .append(true)
304        .open(path)
305        .with_context(|| {
306            format!("Failed to open file for appending {path:?}")
307        })?;
308    file.write_all(data)
309        .with_context(|| format!("Failed to append data to {path:?}"))
310}
311
312pub fn overwrite_file(path: &Path, data: &[u8]) -> Result<()> {
313    guarantee_dir(path)?;
314    fs::write(path, data)
315        .with_context(|| format!("Failed to overwrite file {path:?}"))
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[crate::ctb_test]
323    fn can_get_asset() {
324        assert_eq!(
325            strtovec("Hello, world!"),
326            get_asset("fixtures/hello.txt").expect("Could not load hello.txt")
327        );
328    }
329
330    #[crate::ctb_test]
331    fn can_store_and_get_value() {
332        put("key".as_bytes().to_owned(), "value".as_bytes().to_owned());
333        assert_eq!(
334            "key",
335            String::from_utf8_lossy(&get(strtovec("key")).unwrap())
336        );
337    }
338
339    #[crate::ctb_test]
340    fn can_get_help_troff() {
341        assert!(
342            String::from_utf8_lossy(&get_help_troff()).contains(".SH SYNOPSIS")
343        );
344    }
345
346    #[crate::ctb_test]
347    fn can_get_help_html() {
348        assert!(
349            String::from_utf8_lossy(&get_help_html())
350                .contains(">Synopsis</h2>")
351        );
352    }
353
354    #[crate::ctb_test]
355    fn can_get_help_for_tty() {
356        // The middle of "## Description", wrapped since it's only 8 characters
357        // terminal width
358        assert!(
359            String::from_utf8_lossy(&get_help_for_tty(8)).contains("## iptio")
360        );
361    }
362}