diff --git a/.gitignore b/.gitignore index a547bf3..aec01c1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Test +.test/ diff --git a/docs/dev/design/repo-rule.md b/docs/dev/design/repo-rule.md index 0e7e618..2971dcc 100644 --- a/docs/dev/design/repo-rule.md +++ b/docs/dev/design/repo-rule.md @@ -20,19 +20,59 @@ Here's how the exact rule file is defined. The repository rule file will be written to a TOML file, following the structure below: ```toml +# The article template to use when creating a new source file for an article. +article_template = """--- +title: {{title}} +date: <发布时间> +author: + - fosscope-translation-team + - {{translator}} + - {{proofreader}} +banner: {{cover_image}} +cover: {{cover_image}} +categories: + - 翻译 + - <类型> +tags: + - <标签> +authorInfo: | + via: {{via}} + + 作者:[{{author}}]({{author_link}}) + 选题:[{{selector}}](https://github.com/{{selector}}) + 译者:[{{translator}}](https://github.com/{{translator}}) + 校对:[{{proofreader}}](https://github.com/{{proofreader}}) + + 本文由 [FOSScope翻译组](https://github.com/FOSScope/TranslateProject) 原创编译,[开源观察](https://fosscope.com/) 荣誉推出 +--- + + + +{{summary}} + + + +{{content}} +""" + + [[articles]] # Each `[[articles]]` block defines a type of article available to contribute to. type = "news" # The type of article. description = "News Articles" # The description of the article type. -directory = "{step}/news" # The directory where the articles of this type are stored. +directory = "{{step}}/news" # The directory where the articles of this type are stored. # `{step}` is the placeholder for the directory where the article will be moved from # step to step (e.g. "source", "translated", "published", etc.) +# If needed, a specific article template can be defined for this article type. +# Otherwise, the default article template will be used. +# article_template = """ +# """ # Multiple article types can be defined. [[articles]] type = "tech" description = "Tech Articles" -directory = "{step}/tech" +directory = "{{step}}/tech" # [[articles]] # ... @@ -41,7 +81,7 @@ directory = "{step}/tech" # Each `[[actions]]` block defines an action that can be made in the contribution process. action = "select" # The action name. description = "Select an article to translate." # The description of the action. -command = "TOUCH source/{article}.md" # The command to execute when the action is made. +command = "TOUCH source/{{article_id}}.md" # The command to execute when the action is made. # The command follows a *nix shell command syntax, but is defined, parsed, and executed by the core component of Toolkit software. # In this case, {article} is the placeholder for the article name. @@ -49,7 +89,7 @@ command = "TOUCH source/{article}.md" # The command to execute when the action [[actions]] action = "translate" description = "Translate the article." -command = "MV source/{article}.md translated/{article}.md" +command = "MV source/{{article_id}}.md translated/{{article_id}}.md" # [[actions]] # ... @@ -58,9 +98,12 @@ command = "MV source/{article}.md translated/{article}.md" # This section defines how git conventions applies in different steps. # `{action}`, `{type}`, and `{article}` are placeholders for the action's name, article type, and article name respectively. # Other placeholders can be used as well. -branch_naming = "{action}/{type}/{article}" # The branch naming rule. -commit_message = "[{action.desc}][{type.desc}]: {article.title}" # The commit message rule. +branch_naming = "{{action_name}}/{{type_name}}/{{article_id}}" # The branch naming rule. +commit_message = "[{{action_desc}}][{{type_desc}}]: {{article_title}}" # The commit message rule. ``` > [!NOTE] -> In general, placeholders like `{step}` can be used anywhere, and placerholders other than what's shown in the example above can be defined and used as well. +> +> In general, placeholders like `{{title}}` can be used anywhere, and the template engine will replace them with the actual value when generating the file. +> +> Place holder other than what's shown above may be defined and used. diff --git a/src-rust/Cargo.lock b/src-rust/Cargo.lock index f6515c2..e15d7b7 100644 --- a/src-rust/Cargo.lock +++ b/src-rust/Cargo.lock @@ -814,6 +814,7 @@ dependencies = [ name = "fosscopetoolkit-core" version = "0.1.0-dev" dependencies = [ + "handlebars", "html2md", "libhtmlfilter", "octocrab", @@ -1273,6 +1274,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "handlebars" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2383,6 +2398,51 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "pest_meta" +version = "2.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -4172,6 +4232,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + [[package]] name = "unicode-bidi" version = "0.3.15" diff --git a/src-rust/toolkit-core/Cargo.toml b/src-rust/toolkit-core/Cargo.toml index bfcbdd7..1014108 100644 --- a/src-rust/toolkit-core/Cargo.toml +++ b/src-rust/toolkit-core/Cargo.toml @@ -25,6 +25,7 @@ octocrab = "0.38.0" # GitHub API html2md = { workspace = true } # HTML to Markdown libhtmlfilter = "0.1.2" # HTML Filter openai_api_rust = "0.1.9" # OpenAI API +handlebars = "5.1.2" # Template Engine [dev-dependencies] wiremock = "0.6.0" diff --git a/src-rust/toolkit-core/src/models/action_command.rs b/src-rust/toolkit-core/src/models/action_command.rs new file mode 100644 index 0000000..5f98dde --- /dev/null +++ b/src-rust/toolkit-core/src/models/action_command.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; +use handlebars::Handlebars; + +#[derive(PartialEq, Eq, Debug)] +pub struct ActionCommand { + pub command: String, + pub args: Vec, +} + +impl<'de> serde::Deserialize<'de> for ActionCommand { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let content = String::deserialize(deserializer)?; + let mut parts = content.split_whitespace(); + + let command = parts.next().unwrap().to_string(); + let args = parts.map(|s| s.to_string()).collect(); + + Ok(Self { + command, + args, + }) + } +} + +impl ActionCommand { + pub fn new(command: String, args: Vec) -> Self { + Self { + command, + args, + } + } + + pub fn execute(&self, vars: Option<&HashMap<&str, &str>>) -> Result<(), &str> { + let mut args = self.args.clone(); + if vars.is_some() { + let handlebars = Handlebars::new(); + args = args.iter().map( + |arg| handlebars.render_template(&*arg, &vars).unwrap() + ).collect(); + } + + match self.command.as_str() { + // Copy a file or a directory + "CP" => { + let src = args.get(0).unwrap(); + let dest = args.get(1).unwrap(); + + let src_dir = std::path::Path::new(&src); + if src_dir.is_dir() { + let r = copy_dir_all(src, dest); + match r { + Ok(_) => Ok(()), + Err(_) => Err("Error copying directory"), + } + } else { + let dest_dir = std::path::Path::new(&dest); + if !dest_dir.exists() { + std::fs::create_dir_all(dest_dir.parent().unwrap()).unwrap(); + } + + std::fs::copy(src, dest).unwrap(); + Ok(()) + } + } + // Write a content to a file + "ECHO" => { + let path = args.get(0).unwrap(); + let content = args.get(1).unwrap(); + std::fs::write(path, content).unwrap(); + Ok(()) + } + // Create a directory + "MKDIR" => { + let path = args.get(0).unwrap(); + std::fs::create_dir_all(path).unwrap(); + Ok(()) + } + // Move a file or a directory + "MV" => { + let src = args.get(0).unwrap(); + let dest = args.get(1).unwrap(); + + let dest_dir = std::path::Path::new(dest); + if !dest_dir.exists() { + std::fs::create_dir_all(dest_dir.parent().unwrap()).unwrap(); + } + + std::fs::rename(src, dest).unwrap(); + Ok(()) + } + // Remove a file or a directory + "RM" => { + let path = args.get(0).unwrap(); + if std::fs::metadata(path).unwrap().is_dir() { + std::fs::remove_dir_all(path).unwrap(); + } else { + std::fs::remove_file(path).unwrap(); + } + Ok(()) + } + // Create a file + "TOUCH" => { + let path = args.get(0).unwrap(); + std::fs::File::create(path).unwrap(); + Ok(()) + } + _ => Err("Unknown command"), + } + } +} + +fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> std::io::Result<()> { + std::fs::create_dir_all(&dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} diff --git a/src-rust/toolkit-core/src/models/mod.rs b/src-rust/toolkit-core/src/models/mod.rs index 7e8cc33..f81f065 100644 --- a/src-rust/toolkit-core/src/models/mod.rs +++ b/src-rust/toolkit-core/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod github_repo; pub mod repo_rule; pub mod html_filter_rule; +pub mod action_command; diff --git a/src-rust/toolkit-core/src/models/repo_rule.rs b/src-rust/toolkit-core/src/models/repo_rule.rs index ba7d8c1..e773cf5 100644 --- a/src-rust/toolkit-core/src/models/repo_rule.rs +++ b/src-rust/toolkit-core/src/models/repo_rule.rs @@ -1,9 +1,13 @@ +use crate::models::action_command::ActionCommand; + /// A representation of an article type in the FOSScope repository rule, which defines the types of articles that can be found in the repository. /// /// # Fields /// - `article_type`(`type` in TOML file): The type of the article. e.g. `news`, `tech`. /// - `description`: The description of the article type. e.g. `News Articles`, `Tech Articles`. -/// - `directory`: The directory where the article type is stored. e.g. `{step}/news`, `{step}/tech`. +/// - `directory`: The directory where the article type is stored. e.g. `{{step}}/news`, `{{step}}/tech`. +/// - `article_template`: An optional article template to use when creating a new source file for an article. +/// If not provided, the `article_template` from the [`RepoRule`](struct.RepoRule.html) will be used. /// /// Check the [related design documentation](https://github.com/FOSScope/Toolkit/blob/main/docs/dev/design/repo-rule.md) /// and [RepoRule](struct.RepoRule.html) definition for more information. @@ -16,14 +20,17 @@ pub struct Article { pub description: String, /// The directory where the article type is stored. pub directory: String, + /// An optional article template to use when creating a new source file for an article. + pub article_template: Option, } impl Article { - pub fn new(article_type: String, description: String, directory: String) -> Self { + pub fn new(article_type: String, description: String, directory: String, article_template: Option) -> Self { Self { article_type, description, directory, + article_template, } } } @@ -33,7 +40,7 @@ impl Article { /// # Fields /// - `action`: The action name. e.g. `select`, `translate`, `review`. /// - `description`: The description of the action. e.g. `Select an article to translate`. -/// - `command`: The command that should be executed when the action is performed. e.g. `TOUCH source/{article}.md`. +/// - `command`: The command that should be executed when the action is performed. e.g. `TOUCH source/{{article_id}}.md`. /// /// Check the [related design documentation](https://github.com/FOSScope/Toolkit/blob/main/docs/dev/design/repo-rule.md) /// and [RepoRule](struct.RepoRule.html) definition for more information. @@ -44,11 +51,11 @@ pub struct Action { /// The description of the action. pub description: String, /// The command that should be executed when the action is performed. - pub command: String, + pub command: ActionCommand, } impl Action { - pub fn new(action: String, description: String, command: String) -> Self { + pub fn new(action: String, description: String, command: ActionCommand) -> Self { Self { action, description, @@ -64,8 +71,8 @@ impl Action { /// - `commit_message`: The commit message template. Which is a string containing placeholders that will be replaced with the actual values. /// /// # Example -/// - `branch_naming`: `{action}/{type}/{article}` -/// - `commit_message`: `[{action.desc}][{type.desc}]: {article.title}` +/// - `branch_naming`: `{{action_name}}/{{type_name}}/{{article_id}}` +/// - `commit_message`: `[{{action_desc}}][{{type_desc}}]: {{article_title}}` /// /// Check the [related design documentation](https://github.com/FOSScope/Toolkit/blob/main/docs/dev/design/repo-rule.md) /// and [RepoRule](struct.RepoRule.html) definition for more information. @@ -91,6 +98,7 @@ impl GitRule { /// The rule includes a list of articles, a list of actions, and a Git rule. /// /// # Fields +/// - `article_template`(String): The article template to use when creating a new source file for an article. /// - `articles`([Article](struct.Article.html)): A list of types of articles that can be found in the repository. /// - `actions`([Action](struct.Action.html)): : A list of actions that can be performed on the repository. /// - `git`([GitRule](struct.GitRule.html)): The Git rule that defines how the repository should be managed. @@ -98,6 +106,8 @@ impl GitRule { /// Check the [related design documentation](https://github.com/FOSScope/Toolkit/blob/main/docs/dev/design/repo-rule.md) for more information. #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub struct RepoRule { + /// The article template to use when creating a new source file for an article. + pub article_template: String, /// The list of types of articles that can be found in the repository. pub articles: Vec
, /// The list of actions that can be performed on the repository. @@ -107,8 +117,9 @@ pub struct RepoRule { } impl RepoRule { - pub fn new(articles: Vec
, actions: Vec, git: GitRule) -> Self { + pub fn new(article_template: String, articles: Vec
, actions: Vec, git: GitRule) -> Self { Self { + article_template, articles, actions, git, diff --git a/src-rust/toolkit-core/tests/action_command_test.rs b/src-rust/toolkit-core/tests/action_command_test.rs new file mode 100644 index 0000000..759b421 --- /dev/null +++ b/src-rust/toolkit-core/tests/action_command_test.rs @@ -0,0 +1,168 @@ +mod tests { + use std::collections::HashMap; + use fosscopetoolkit_core::models::action_command::ActionCommand; + + #[test] + fn cp() { + // Create Test Directory + let _ = std::fs::create_dir(".test"); + + // Create Test Directory + let _ = std::fs::create_dir(".test/source"); + // Create Test File + let _ = std::fs::write(".test/source/test.md", "Test File"); + + // Test The Command + let command = ActionCommand::new( + "CP".to_string(), + vec![ + ".test/source/test.md".to_string(), + ".test/copied/test.md".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + + let _ = std::fs::create_dir(".test/source_dir"); + let _ = std::fs::write(".test/source_dir/test.md", "Dir Test File"); + + let command = ActionCommand::new( + "CP".to_string(), + vec![ + ".test/source_dir".to_string(), + ".test/copied_dir".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + } + + #[test] + fn echo() { + // Create Test Directory + let _ = std::fs::create_dir(".test"); + + // Test The Command + let command = ActionCommand::new( + "ECHO".to_string(), + vec![ + ".test/echo.md".to_string(), + "Echo Test".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + } + + #[test] + fn mkdir() { + // Create Test Directory + let _ = std::fs::create_dir(".test"); + + // Test The Command + let command = ActionCommand::new( + "MKDIR".to_string(), + vec![ + ".test/mkdir".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + } + + #[test] + fn mv() { + // Create Test Directory + let _ = std::fs::create_dir(".test"); + + // Create Test Directory + let _ = std::fs::create_dir(".test/mv-src"); + // Create Test File + let _ = std::fs::write(".test/mv-src/test.md", "Test File"); + + // Test The Command + let command = ActionCommand::new( + "MV".to_string(), + vec![ + ".test/mv-src/test.md".to_string(), + ".test/moved/test.md".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + + let _ = std::fs::write(".test/mv-src/test.md", "Dir Test File"); + + let command = ActionCommand::new( + "MV".to_string(), + vec![ + ".test/mv-src".to_string(), + ".test/moved_dir".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + } + + #[test] + fn touch() { + // Create Test Directory + let _ = std::fs::create_dir(".test"); + + // Test The Command + let command = ActionCommand::new( + "TOUCH".to_string(), + vec![ + ".test/touch.md".to_string(), + ], + ); + + let r = command.execute(None); + assert!(r.is_ok()); + } + + #[test] + fn unknown() { + // Test The Command + let command = ActionCommand::new( + "UNKNOWN".to_string(), + vec![], + ); + + let r = command.execute(None); + assert!(r.is_err()); + } + + #[test] + fn cp_with_env() { + // Create Test Directory + let _ = std::fs::create_dir_all(".test/with_template_engine"); + + // Create Test Directory + let _ = std::fs::create_dir_all(".test/with_template_engine/source"); + // Create Test File + let _ = std::fs::write(".test/with_template_engine/source/test.md", "Test File"); + + // Test The Command + let command = ActionCommand::new( + "CP".to_string(), + vec![ + ".test/{{ from }}/test.md".to_string(), + ".test/{{ to }}/test.md".to_string(), + ], + ); + + let mut data = HashMap::new(); + data.insert("from", "with_template_engine/source"); + data.insert("to", "with_template_engine/copied"); + + let r = command.execute(Some(&data)); + assert!(r.is_ok()); + } +} diff --git a/src-rust/toolkit-core/tests/repo_rule_test.rs b/src-rust/toolkit-core/tests/repo_rule_test.rs index f2c8251..f7f82b7 100644 --- a/src-rust/toolkit-core/tests/repo_rule_test.rs +++ b/src-rust/toolkit-core/tests/repo_rule_test.rs @@ -1,29 +1,42 @@ #[cfg(test)] mod tests { use fosscopetoolkit_core::get_repo_rule; - use fosscopetoolkit_core::models::{Article, Action, GitRule, RepoRule}; + use fosscopetoolkit_core::models::action_command::ActionCommand; + use fosscopetoolkit_core::models::repo_rule::{Article, Action, GitRule, RepoRule}; #[test] fn deserialize() { - let rule = r#"[[articles]] + let rule = r#"# The article template to use when creating a new source file for an article. +article_template = """--- +General Article Template +---""" + + +[[articles]] # Each `[[articles]]` block defines a type of article available to contribute to. type = "news" # The type of article. description = "News Articles" # The description of the article type. -directory = "{step}/news" # The directory where the articles of this type are stored. +directory = "{{step}}/news" # The directory where the articles of this type are stored. # `{step}` is the placeholder for the directory where the article will be moved from # step to step (e.g. "source", "translated", "published", etc.) +article_template = """--- +News Article Template +---""" # Multiple article types can be defined. [[articles]] type = "tech" description = "Tech Articles" -directory = "{step}/tech" +directory = "{{step}}/tech" + +# [[articles]] +# ... [[actions]] # Each `[[actions]]` block defines an action that can be made in the contribution process. action = "select" # The action name. description = "Select an article to translate." # The description of the action. -command = "TOUCH source/{article}.md" # The command to execute when the action is made. +command = "TOUCH source/{{article_id}}.md" # The command to execute when the action is made. # The command follows a *nix shell command syntax, but is defined, parsed, and executed by the core component of Toolkit software. # In this case, {article} is the placeholder for the article name. @@ -31,24 +44,31 @@ command = "TOUCH source/{article}.md" # The command to execute when the action [[actions]] action = "translate" description = "Translate the article." -command = "MV source/{article}.md translated/{article}.md" +command = "MV source/{{article_id}}.md translated/{{article_id}}.md" + +# [[actions]] +# ... [git] # This section defines how git conventions applies in different steps. # `{action}`, `{type}`, and `{article}` are placeholders for the action's name, article type, and article name respectively. # Other placeholders can be used as well. -branch_naming = "{action}/{type}/{article}" # The branch naming rule. -commit_message = "[{action.desc}][{type.desc}]: {article.title}" # The commit message rule."#; +branch_naming = "{{action_name}}/{{type_name}}/{{article_id}}" # The branch naming rule. +commit_message = "[{{action_desc}}][{{type_desc}}]: {{article_title}}" # The commit message rule."#; let deserialized = get_repo_rule(rule).unwrap(); - let news: Article = Article::new("news".to_string(), "News Articles".to_string(), "{step}/news".to_string()); - let tech: Article = Article::new("tech".to_string(), "Tech Articles".to_string(), "{step}/tech".to_string()); - let select: Action = Action::new("select".to_string(), "Select an article to translate.".to_string(), "TOUCH source/{article}.md".to_string()); - let translate: Action = Action::new("translate".to_string(), "Translate the article.".to_string(), "MV source/{article}.md translated/{article}.md".to_string()); - let git_rule: GitRule = GitRule::new("{action}/{type}/{article}".to_string(), "[{action.desc}][{type.desc}]: {article.title}".to_string()); + let news: Article = Article::new("news".to_string(), "News Articles".to_string(), "{{step}}/news".to_string(), Some("---\nNews Article Template\n---".to_string())); + let tech: Article = Article::new("tech".to_string(), "Tech Articles".to_string(), "{{step}}/tech".to_string(), None); + let select: Action = Action::new("select".to_string(), "Select an article to translate.".to_string(), + ActionCommand::new("TOUCH".to_string(), vec!["source/{{article_id}}.md".to_string()]) + ); + let translate: Action = Action::new("translate".to_string(), "Translate the article.".to_string(), + ActionCommand::new("MV".to_string(), vec!["source/{{article_id}}.md".to_string(), "translated/{{article_id}}.md".to_string()]) + ); + let git_rule: GitRule = GitRule::new("{{action_name}}/{{type_name}}/{{article_id}}".to_string(), "[{{action_desc}}][{{type_desc}}]: {{article_title}}".to_string()); - let expected = RepoRule::new(vec![news, tech], vec![select, translate], git_rule); + let expected = RepoRule::new("---\nGeneral Article Template\n---".to_string(), vec![news, tech], vec![select, translate], git_rule); assert_eq!(deserialized, expected); }