lib/export/
mod.rs

1//! Defines types for exporting data.
2
3use std::fs::File;
4use std::path::Path;
5
6use serde::Serialize;
7
8use crate::contexts::book::BookContext;
9use crate::models::entry::{Entries, Entry};
10use crate::result::Result;
11use crate::strings;
12
13/// The default export directory template.
14///
15/// Outputs `[author] - [book]` e.g. `Robert Henri - The Art Spirit`.
16const DIRECTORY_TEMPLATE: &str = "{{ book.author }} - {{ book.title }}";
17
18/// A struct for running export tasks.
19#[derive(Debug, Copy, Clone)]
20pub struct ExportRunner;
21
22impl ExportRunner {
23    /// Runs the export task.
24    ///
25    /// # Arguments
26    ///
27    /// * `entries` - The entries to export.
28    /// * `path` - The ouput directory.
29    /// * `options` - The export options.
30    ///
31    /// # Errors
32    ///
33    /// Will return `Err` if:
34    /// * Any IO errors are encountered.
35    /// * [`serde_json`][serde-json] encounters any errors.
36    ///
37    /// [serde-json]: https://docs.rs/serde_json/latest/serde_json/
38    pub fn run<O>(entries: &mut Entries, path: &Path, options: O) -> Result<()>
39    where
40        O: Into<ExportOptions>,
41    {
42        let options: ExportOptions = options.into();
43
44        Self::export(entries, path, options)?;
45
46        Ok(())
47    }
48
49    /// Exports data as JSON.
50    ///
51    /// # Arguments
52    ///
53    /// * `entries` - The entries to export.
54    /// * `path` - The ouput directory.
55    /// * `options` - The export options.
56    ///
57    /// The output strucutre is as follows:
58    ///
59    /// ```plaintext
60    /// [ouput-directory]
61    ///  │
62    ///  ├── [author-title]
63    ///  │    ├── book.json
64    ///  │    └── annotations.json
65    ///  │
66    ///  ├── [author-title]
67    ///  │    └── ...
68    ///  └── ...
69    /// ```
70    ///
71    /// # Errors
72    ///
73    /// Will return `Err` if:
74    /// * Any IO errors are encountered.
75    /// * [`serde_json`][serde-json] encounters any errors.
76    ///
77    /// [serde-json]: https://docs.rs/serde_json/latest/serde_json/
78    fn export(entries: &Entries, path: &Path, options: ExportOptions) -> Result<()> {
79        let directory_template = if let Some(template) = options.directory_template {
80            Self::validate_template(&template)?;
81            template
82        } else {
83            DIRECTORY_TEMPLATE.to_string()
84        };
85
86        for entry in entries.values() {
87            // -> [author-title]
88            let directory_name = Self::render_directory_name(&directory_template, entry)?;
89
90            // -> [ouput-directory]/[author-title]
91            let item = path.join(directory_name);
92            // -> [ouput-directory]/[author-title]/book.json
93            let book_json = item.join("book").with_extension("json");
94            // -> [ouput-directory]/[author-title]/annotation.json
95            let annotations_json = item.join("annotations").with_extension("json");
96
97            std::fs::create_dir_all(&item)?;
98
99            if !options.overwrite_existing && book_json.exists() {
100                log::debug!("skipped writing {}", book_json.display());
101            } else {
102                let book_json = File::create(book_json)?;
103                serde_json::to_writer_pretty(&book_json, &entry.book)?;
104            }
105
106            if !options.overwrite_existing && annotations_json.exists() {
107                log::debug!("skipped writing {}", annotations_json.display());
108            } else {
109                let annotations_json = File::create(annotations_json)?;
110                serde_json::to_writer_pretty(&annotations_json, &entry.annotations)?;
111            }
112        }
113
114        Ok(())
115    }
116
117    /// Validates a template by rendering it.
118    ///
119    /// The template is rendered and an empty [`Result`] is returned.
120    ///
121    /// # Arguments
122    ///
123    /// * `template` - The template string to validate.
124    fn validate_template(template: &str) -> Result<()> {
125        let entry = Entry::dummy();
126        Self::render_directory_name(template, &entry).map(|_| ())
127    }
128
129    /// Renders the directory name from a template string and an [`Entry`].
130    ///
131    /// # Arguments
132    ///
133    /// * `template` - The template string to render.
134    /// * `entry` - The [`Entry`] providing the template context.
135    fn render_directory_name(template: &str, entry: &Entry) -> Result<String> {
136        let context = BookContext::from(&entry.book);
137        let context = ExportContext::from(&context);
138        strings::render_and_sanitize(template, context)
139    }
140}
141
142/// A struct representing options for the [`ExportRunner`] struct.
143#[derive(Debug)]
144pub struct ExportOptions {
145    /// The template to use for rendering the export's ouput directories.
146    pub directory_template: Option<String>,
147
148    /// Toggles whether or not to overwrite existing files.
149    pub overwrite_existing: bool,
150}
151
152/// An struct represening the template context for exports.
153///
154/// This is primarily used for generating directory names.
155#[derive(Debug, Serialize)]
156struct ExportContext<'a> {
157    book: &'a BookContext<'a>,
158}
159
160impl<'a> From<&'a BookContext<'a>> for ExportContext<'a> {
161    fn from(book: &'a BookContext<'a>) -> Self {
162        Self { book }
163    }
164}
165
166#[cfg(test)]
167mod test {
168
169    use super::*;
170
171    use crate::defaults::test::TemplatesDirectory;
172    use crate::models::book::Book;
173    use crate::render::engine::RenderEngine;
174    use crate::utils;
175
176    // Tests that the default template returns no error.
177    #[test]
178    fn default_template() {
179        let book = Book::default();
180        let context = BookContext::from(&book);
181        let context = ExportContext { book: &context };
182
183        RenderEngine::default()
184            .render_str(DIRECTORY_TEMPLATE, context)
185            .unwrap();
186    }
187
188    // Tests that all valid context fields return no errors.
189    #[test]
190    fn valid_context() {
191        let template =
192            utils::testing::load_template_str(TemplatesDirectory::ValidContext, "valid-export.txt");
193
194        let book = Book::default();
195        let context = BookContext::from(&book);
196        let context = ExportContext::from(&context);
197
198        RenderEngine::default()
199            .render_str(&template, context)
200            .unwrap();
201    }
202
203    // Tests that an invalid context field returns an error.
204    #[test]
205    #[should_panic(expected = "Failed to render '__tera_one_off'")]
206    fn invalid_context() {
207        let template = utils::testing::load_template_str(
208            TemplatesDirectory::InvalidContext,
209            "invalid-export.txt",
210        );
211
212        let book = Book::default();
213        let context = BookContext::from(&book);
214        let context = ExportContext::from(&context);
215
216        RenderEngine::default()
217            .render_str(&template, context)
218            .unwrap();
219    }
220}