novel_cli/cmd/
transform.rs

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