novel_cli/cmd/
transform.rs

1use std::{
2    fmt::Write,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use clap::Args;
8use color_eyre::eyre::{self, Result};
9use fluent_templates::Loader;
10use novel_api::Timing;
11use pulldown_cmark::{Event, MetadataBlockKind, Options, Parser, Tag, TagEnd, TextMergeWithOffset};
12use walkdir::WalkDir;
13
14use crate::{
15    LANG_ID, LOCALES,
16    cmd::Convert,
17    utils::{self, Metadata},
18};
19
20#[must_use]
21#[derive(Args)]
22#[command(arg_required_else_help = true,
23    about = LOCALES.lookup(&LANG_ID, "transform_command"))]
24pub struct Transform {
25    #[arg(help = LOCALES.lookup(&LANG_ID, "file_path"))]
26    pub file_path: PathBuf,
27
28    #[arg(short, long, value_enum, value_delimiter = ',',
29        help = LOCALES.lookup(&LANG_ID, "converts"))]
30    pub converts: Vec<Convert>,
31
32    #[arg(short, long, default_value_t = false,
33        help = LOCALES.lookup(&LANG_ID, "delete"))]
34    pub delete: bool,
35}
36
37pub fn execute(config: Transform) -> Result<()> {
38    let mut timing = Timing::new();
39
40    let input_file_path;
41    let input_file_parent_path;
42
43    if utils::is_markdown_or_txt_file(&config.file_path)? {
44        input_file_path = dunce::canonicalize(&config.file_path)?;
45        input_file_parent_path = input_file_path.parent().unwrap().to_path_buf();
46    } else if let Ok(Some(path)) =
47        utils::try_get_markdown_or_txt_file_name_in_dir(&config.file_path)
48    {
49        input_file_path = path;
50        input_file_parent_path = dunce::canonicalize(&config.file_path)?;
51    } else {
52        eyre::bail!("Invalid input path: `{}`", config.file_path.display());
53    }
54    tracing::info!("Input file path: `{}`", input_file_path.display());
55
56    let input_file_stem = input_file_path.file_stem().unwrap().to_str().unwrap();
57    let input_file_ext = input_file_path.extension().unwrap().to_str().unwrap();
58
59    let bytes = fs::read(&input_file_path)?;
60    let markdown = simdutf8::basic::from_utf8(&bytes)?;
61    let mut parser = TextMergeWithOffset::new(
62        Parser::new_ext(markdown, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS).into_offset_iter(),
63    );
64
65    let mut metadata = utils::get_metadata(&mut parser)?;
66    convert_metadata(&mut metadata, &config.converts, &input_file_parent_path)?;
67
68    let mut image_index = 1;
69    let mut in_heading = false;
70    let parser = parser.map(|(event, range)| match event {
71        Event::Start(Tag::CodeBlock(_)) | Event::End(TagEnd::CodeBlock) => {
72            panic!("Cannot contain CodeBlock: {}", &markdown[range]);
73        }
74        Event::Start(Tag::Heading {
75            level,
76            id,
77            classes,
78            attrs,
79        }) => {
80            in_heading = true;
81            Event::Start(Tag::Heading {
82                level,
83                id,
84                classes,
85                attrs,
86            })
87        }
88        Event::End(TagEnd::Heading(level)) => {
89            in_heading = false;
90            Event::End(TagEnd::Heading(level))
91        }
92        Event::Text(text) => Event::Text(
93            utils::convert_str(text, &config.converts, in_heading)
94                .unwrap()
95                .into(),
96        ),
97        Event::Start(Tag::Image {
98            link_type,
99            dest_url,
100            title,
101            id,
102        }) => {
103            let new_image_path =
104                utils::convert_image_ext(input_file_parent_path.join(dest_url.as_ref())).unwrap();
105
106            let new_image_path =
107                utils::convert_image_file_stem(new_image_path, utils::num_to_str(image_index))
108                    .unwrap();
109            image_index += 1;
110
111            Event::Start(Tag::Image {
112                link_type,
113                dest_url: new_image_path
114                    .file_name()
115                    .unwrap()
116                    .to_str()
117                    .unwrap()
118                    .to_string()
119                    .into(),
120                title,
121                id,
122            })
123        }
124        _ => event,
125    });
126
127    let metadata_block = vec![
128        Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)),
129        Event::Text(serde_yml::to_string(&metadata)?.into()),
130        Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)),
131    ];
132
133    let mut buf = String::with_capacity(markdown.len());
134    pulldown_cmark_to_cmark::cmark(metadata_block.iter(), &mut buf)?;
135    buf.write_char('\n')?;
136    pulldown_cmark_to_cmark::cmark(parser, &mut buf)?;
137    buf.write_char('\n')?;
138
139    if config.delete {
140        utils::remove_file_or_dir(&input_file_path)?;
141    } else {
142        let backup_file_path =
143            input_file_parent_path.join(format!("{input_file_stem}.old.{input_file_ext}"));
144        tracing::info!("Backup file path: `{}`", backup_file_path.display());
145
146        fs::rename(&input_file_path, backup_file_path)?;
147    }
148
149    let new_file_name = utils::to_novel_dir_name(utils::convert_str(
150        &metadata.title,
151        &config.converts,
152        false,
153    )?)
154    .with_extension(input_file_ext);
155    let output_file_path = input_file_parent_path.join(new_file_name);
156    tracing::info!("Output file path: `{}`", output_file_path.display());
157
158    if cfg!(windows) {
159        buf = buf.replace('\n', "\r\n");
160    }
161    fs::write(&output_file_path, buf)?;
162
163    if config.delete {
164        let image_paths = utils::read_markdown_to_images(&output_file_path)?;
165
166        let mut to_remove = Vec::new();
167        for entry in WalkDir::new(&input_file_parent_path).max_depth(1) {
168            let path = entry?.path().to_path_buf();
169
170            if path != output_file_path && path != input_file_parent_path {
171                let file_name = path.file_name().unwrap().to_str().unwrap();
172                if !image_paths.contains(&PathBuf::from(file_name)) {
173                    to_remove.push(path);
174                }
175            }
176        }
177
178        utils::remove_file_or_dir_all(&to_remove)?;
179    }
180
181    tracing::debug!("Time spent on `transform`: {}", timing.elapsed()?);
182
183    Ok(())
184}
185
186fn convert_metadata(metadata: &mut Metadata, converts: &[Convert], input_dir: &Path) -> Result<()> {
187    metadata.title = utils::convert_str(&metadata.title, converts, false)?;
188    metadata.author = utils::convert_str(&metadata.author, converts, false)?;
189    metadata.lang = utils::lang(converts);
190
191    if metadata.description.is_some() {
192        let mut description = Vec::with_capacity(4);
193
194        for line in metadata.description.as_ref().unwrap().split('\n') {
195            description.push(utils::convert_str(line, converts, false).unwrap());
196        }
197
198        metadata.description = Some(description.join("\n"));
199    }
200
201    if metadata.cover_image.is_some() {
202        let new_image_path =
203            utils::convert_image_ext(input_dir.join(metadata.cover_image.as_ref().unwrap()))
204                .unwrap();
205
206        let new_image_path = utils::convert_image_file_stem(new_image_path, "cover").unwrap();
207
208        metadata.cover_image = Some(PathBuf::from(
209            new_image_path.file_name().unwrap().to_str().unwrap(),
210        ));
211    }
212
213    Ok(())
214}