Move i18n helpers into ftl/, with a single main.rs

Clap gives us a nice help message and better arg parsing
This commit is contained in:
Damien Elmes 2023-07-04 10:45:06 +10:00
parent 78b4a391cc
commit 13572a86b2
13 changed files with 183 additions and 182 deletions

20
Cargo.lock generated
View File

@ -190,17 +190,6 @@ dependencies = [
"unic-langid", "unic-langid",
] ]
[[package]]
name = "anki_i18n_helpers"
version = "0.0.0"
dependencies = [
"fluent-syntax",
"lazy_static",
"regex",
"serde_json",
"walkdir",
]
[[package]] [[package]]
name = "anki_io" name = "anki_io"
version = "0.0.0" version = "0.0.0"
@ -1295,8 +1284,17 @@ dependencies = [
name = "ftl" name = "ftl"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"anki_io",
"anki_process",
"anyhow",
"camino", "camino",
"clap",
"fluent-syntax",
"lazy_static",
"regex",
"serde_json",
"snafu", "snafu",
"walkdir",
] ]
[[package]] [[package]]

View File

@ -9,7 +9,6 @@ edition = "2021"
members = [ members = [
"rslib", "rslib",
"rslib/i18n", "rslib/i18n",
"rslib/i18n_helpers",
"rslib/linkchecker", "rslib/linkchecker",
"rslib/proto", "rslib/proto",
"rslib/io", "rslib/io",

View File

@ -8,11 +8,11 @@ use ninja_gen::build::FilesHandle;
use ninja_gen::cargo::CargoBuild; use ninja_gen::cargo::CargoBuild;
use ninja_gen::cargo::CargoClippy; use ninja_gen::cargo::CargoClippy;
use ninja_gen::cargo::CargoFormat; use ninja_gen::cargo::CargoFormat;
use ninja_gen::cargo::CargoRun;
use ninja_gen::cargo::CargoTest; use ninja_gen::cargo::CargoTest;
use ninja_gen::cargo::RustOutput; use ninja_gen::cargo::RustOutput;
use ninja_gen::git::SyncSubmodule; use ninja_gen::git::SyncSubmodule;
use ninja_gen::glob; use ninja_gen::glob;
use ninja_gen::hash::simple_hash;
use ninja_gen::input::BuildInput; use ninja_gen::input::BuildInput;
use ninja_gen::inputs; use ninja_gen::inputs;
use ninja_gen::Build; use ninja_gen::Build;
@ -60,21 +60,30 @@ fn prepare_translations(build: &mut Build) -> Result<()> {
)?; )?;
build.add_action( build.add_action(
"ftl:sync", "ftl:bin",
CargoRun { CargoBuild {
binary_name: "ftl-sync", inputs: inputs![glob!["ftl/**"],],
cargo_args: "-p ftl", outputs: &[RustOutput::Binary("ftl")],
bin_args: "", target: None,
deps: inputs![":ftl:repo", glob!["ftl/{core,core-repo,qt,qt-repo}/**"]], extra_args: "-p ftl",
release_override: None,
},
)?;
// These don't use :group notation, as it doesn't make sense to invoke multiple
// commands as a group.
build.add_action(
"ftl-sync",
FtlCommand {
args: "sync",
deps: inputs![":ftl:repo", glob!["ftl/**"]],
}, },
)?; )?;
build.add_action( build.add_action(
"ftl:deprecate", "ftl-deprecate",
CargoRun { FtlCommand {
binary_name: "deprecate_ftl_entries", args: "deprecate --ftl-roots ftl/core ftl/qt --source-roots pylib qt rslib ts --json-roots ftl/usage",
cargo_args: "-p anki_i18n_helpers",
bin_args: "ftl/core ftl/qt -- pylib qt rslib ts --keep ftl/usage",
deps: inputs!["ftl/core", "ftl/qt", "pylib", "qt", "rslib", "ts"], deps: inputs!["ftl/core", "ftl/qt", "pylib", "qt", "rslib", "ts"],
}, },
)?; )?;
@ -82,6 +91,24 @@ fn prepare_translations(build: &mut Build) -> Result<()> {
Ok(()) Ok(())
} }
struct FtlCommand {
args: &'static str,
deps: BuildInput,
}
impl BuildAction for FtlCommand {
fn command(&self) -> &str {
"$ftl_bin $args"
}
fn files(&mut self, build: &mut impl FilesHandle) {
build.add_inputs("", &self.deps);
build.add_inputs("ftl_bin", inputs![":ftl:bin"]);
build.add_variable("args", self.args);
build.add_output_stamp(format!("ftl/stamp.{}", simple_hash(self.args)));
}
}
fn build_proto_descriptors_and_interfaces(build: &mut Build) -> Result<()> { fn build_proto_descriptors_and_interfaces(build: &mut Build) -> Result<()> {
let outputs = vec![ let outputs = vec![
RustOutput::Data("descriptors.bin", "rslib/proto/descriptors.bin"), RustOutput::Data("descriptors.bin", "rslib/proto/descriptors.bin"),

View File

@ -6,11 +6,17 @@ edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
rust-version.workspace = true rust-version.workspace = true
description = "Helpers for Anki's i18n system"
[[bin]]
name = "ftl-sync"
path = "sync.rs"
[dependencies] [dependencies]
anki_io.workspace = true
anki_process.workspace = true
anyhow.workspace = true
camino.workspace = true camino.workspace = true
clap.workspace = true
fluent-syntax.workspace = true
lazy_static.workspace = true
regex.workspace = true
serde_json.workspace = true
snafu.workspace = true snafu.workspace = true
walkdir.workspace = true

View File

@ -5,7 +5,12 @@ use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::BufReader; use std::io::BufReader;
use std::iter::FromIterator; use std::iter::FromIterator;
use std::path::PathBuf;
use anki_io::create_file;
use anyhow::Context;
use anyhow::Result;
use clap::Args;
use fluent_syntax::ast; use fluent_syntax::ast;
use fluent_syntax::ast::Resource; use fluent_syntax::ast::Resource;
use fluent_syntax::parser; use fluent_syntax::parser;
@ -17,42 +22,61 @@ use walkdir::WalkDir;
use crate::serialize; use crate::serialize;
#[derive(Args)]
pub struct WriteJsonArgs {
target_filename: PathBuf,
source_roots: Vec<String>,
}
#[derive(Args)]
pub struct GarbageCollectArgs {
json_root: String,
ftl_roots: Vec<String>,
}
#[derive(Args)]
pub struct DeprecateEntriesArgs {
#[clap(long, num_args(1..), required(true))]
ftl_roots: Vec<String>,
#[clap(long, num_args(1..), required(true))]
source_roots: Vec<String>,
#[clap(long, num_args(1..), required(true))]
json_roots: Vec<String>,
}
const DEPCRATION_WARNING: &str = const DEPCRATION_WARNING: &str =
"NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future."; "NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.";
/// Extract references from all Rust, Python, TS, Svelte, Swift, Kotlin and /// Extract references from all Rust, Python, TS, Svelte, Swift, Kotlin and
/// Designer files in the `roots`, convert them to kebab case and write them as /// Designer files in the `roots`, convert them to kebab case and write them as
/// a json to the target file. /// a json to the target file.
pub fn write_ftl_json<S1: AsRef<str>, S2: AsRef<str>>(roots: &[S1], target: S2) { pub fn write_ftl_json(args: WriteJsonArgs) -> Result<()> {
let refs = gather_ftl_references(roots); let refs = gather_ftl_references(&args.source_roots);
let mut refs = Vec::from_iter(refs); let mut refs = Vec::from_iter(refs);
refs.sort(); refs.sort();
serde_json::to_writer_pretty( serde_json::to_writer_pretty(create_file(args.target_filename)?, &refs)
fs::File::create(target.as_ref()).expect("failed to create file"), .context("writing json")?;
&refs,
) Ok(())
.expect("failed to write file");
} }
/// Delete every entry in `ftl_root` that is not mentioned in another message /// Delete every entry in `ftl_root` that is not mentioned in another message
/// or any json in `json_root`. /// or any json in `json_root`.
pub fn garbage_collect_ftl_entries(ftl_roots: &[impl AsRef<str>], json_root: impl AsRef<str>) { pub fn garbage_collect_ftl_entries(args: GarbageCollectArgs) -> Result<()> {
let used_ftls = get_all_used_messages_and_terms(json_root.as_ref(), ftl_roots); let used_ftls = get_all_used_messages_and_terms(&args.json_root, &args.ftl_roots);
strip_unused_ftl_messages_and_terms(ftl_roots, &used_ftls); strip_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls);
Ok(())
} }
/// Moves every entry in `ftl_roots` that is not mentioned in another message, a /// Moves every entry in `ftl_roots` that is not mentioned in another message, a
/// source file or any json in `json_roots` to the bottom of its file below a /// source file or any json in `json_roots` to the bottom of its file below a
/// deprecation warning. /// deprecation warning.
pub fn deprecate_ftl_entries( pub fn deprecate_ftl_entries(args: DeprecateEntriesArgs) -> Result<()> {
ftl_roots: &[impl AsRef<str>], let mut used_ftls = gather_ftl_references(&args.source_roots);
source_roots: &[impl AsRef<str>], import_messages_from_json(&args.json_roots, &mut used_ftls);
json_roots: &[impl AsRef<str>], extract_nested_messages_and_terms(&args.ftl_roots, &mut used_ftls);
) { deprecate_unused_ftl_messages_and_terms(&args.ftl_roots, &used_ftls);
let mut used_ftls = gather_ftl_references(source_roots); Ok(())
import_messages_from_json(json_roots, &mut used_ftls);
extract_nested_messages_and_terms(ftl_roots, &mut used_ftls);
deprecate_unused_ftl_messages_and_terms(ftl_roots, &used_ftls);
} }
fn get_all_used_messages_and_terms( fn get_all_used_messages_and_terms(

50
ftl/src/main.rs Normal file
View File

@ -0,0 +1,50 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub mod garbage_collection;
pub mod serialize;
mod sync;
use anyhow::Result;
use clap::Parser;
use clap::Subcommand;
use garbage_collection::deprecate_ftl_entries;
use garbage_collection::garbage_collect_ftl_entries;
use garbage_collection::write_ftl_json;
use garbage_collection::DeprecateEntriesArgs;
use garbage_collection::GarbageCollectArgs;
use garbage_collection::WriteJsonArgs;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Update commit references to the latest translations,
/// and copy source files to the translation repos. Requires access to the
/// i18n repos to run.
Sync,
/// Extract references from all Rust, Python, TS, Svelte and Designer files
/// in the given roots, convert them to ftl names case and write them as
/// a json to the target file.
WriteJson(WriteJsonArgs),
/// Delete every entry in the ftl files that is not mentioned in another
/// message or a given json.
GarbageCollect(GarbageCollectArgs),
/// Deprecate unused ftl entries by moving them to the bottom of the file
/// and adding a deprecation warning. An entry is considered unused if
/// cannot be found in a source or JSON file.
Deprecate(DeprecateEntriesArgs),
}
fn main() -> Result<()> {
match Cli::parse().command {
Command::Sync => sync::sync(),
Command::WriteJson(args) => write_ftl_json(args),
Command::GarbageCollect(args) => garbage_collect_ftl_entries(args),
Command::Deprecate(args) => deprecate_ftl_entries(args),
}
}

View File

@ -1,17 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
//! A helper script to update commit references to the latest translations,
//! and copy source files to the translation repos. Requires access to the
//! i18n repos to run.
use std::process::Command; use std::process::Command;
use anki_process::CommandExt;
use anyhow::bail;
use anyhow::Context;
use anyhow::Result;
use camino::Utf8Path; use camino::Utf8Path;
use snafu::prelude::*;
use snafu::Whatever;
type Result<T> = std::result::Result<T, Whatever>;
#[derive(Debug)] #[derive(Debug)]
struct Module { struct Module {
@ -23,8 +19,7 @@ struct Module {
/// remote is used to push via authenticated ssh. /// remote is used to push via authenticated ssh.
const GIT_REMOTE: &str = "ssh"; const GIT_REMOTE: &str = "ssh";
#[snafu::report] pub fn sync() -> Result<()> {
fn main() -> Result<()> {
let modules = [ let modules = [
Module { Module {
template_folder: "ftl/core".into(), template_folder: "ftl/core".into(),
@ -41,8 +36,7 @@ fn main() -> Result<()> {
fetch_new_translations(&module)?; fetch_new_translations(&module)?;
push_new_templates(&module)?; push_new_templates(&module)?;
} }
commit(".", "Update translations") commit(".", "Update translations").context("failure expected if no translations changed")?;
.whatever_context("failure expected if no translations changed")?;
Ok(()) Ok(())
} }
@ -50,47 +44,41 @@ fn check_clean() -> Result<()> {
let output = Command::new("git") let output = Command::new("git")
.arg("diff") .arg("diff")
.output() .output()
.whatever_context("git diff")?; .context("git diff")?;
ensure_whatever!(output.status.success(), "git diff"); if !output.status.success() {
ensure_whatever!( bail!("git diff");
output.stdout.is_empty(),
"please commit any outstanding changes first"
);
Ok(())
} }
if !output.stdout.is_empty() {
fn run(command: &mut Command) -> Result<()> { bail!("please commit any outstanding changes first");
let status = command
.status()
.with_whatever_context(|_| format!("{:?}", command))?;
if !status.success() {
whatever!("{:?} exited with code: {:?}", command, status.code());
} }
Ok(()) Ok(())
} }
fn fetch_new_translations(module: &Module) -> Result<()> { fn fetch_new_translations(module: &Module) -> Result<()> {
run(Command::new("git") Command::new("git")
.current_dir(module.translation_repo) .current_dir(module.translation_repo)
.args(["checkout", "main"]))?; .args(["checkout", "main"])
run(Command::new("git") .ensure_success()?;
Command::new("git")
.current_dir(module.translation_repo) .current_dir(module.translation_repo)
.args(["pull", "origin", "main"]))?; .args(["pull", "origin", "main"])
.ensure_success()?;
Ok(()) Ok(())
} }
fn push_new_templates(module: &Module) -> Result<()> { fn push_new_templates(module: &Module) -> Result<()> {
run(Command::new("rsync") Command::new("rsync")
.args(["-ai", "--delete", "--no-perms", "--no-times", "-c"]) .args(["-ai", "--delete", "--no-perms", "--no-times", "-c"])
.args([ .args([
format!("{}/", module.template_folder), format!("{}/", module.template_folder),
format!("{}/", module.translation_repo.join("templates")), format!("{}/", module.translation_repo.join("templates")),
]))?; ])
.ensure_success()?;
let changes_pending = !Command::new("git") let changes_pending = !Command::new("git")
.current_dir(module.translation_repo) .current_dir(module.translation_repo)
.args(["diff", "--exit-code"]) .args(["diff", "--exit-code"])
.status() .status()
.whatever_context("git")? .context("git")?
.success(); .success();
if changes_pending { if changes_pending {
commit(module.translation_repo, "Update templates")?; commit(module.translation_repo, "Update templates")?;
@ -100,11 +88,15 @@ fn push_new_templates(module: &Module) -> Result<()> {
} }
fn push(repo: &Utf8Path) -> Result<()> { fn push(repo: &Utf8Path) -> Result<()> {
run(Command::new("git") Command::new("git")
.current_dir(repo) .current_dir(repo)
.args(["push", GIT_REMOTE, "main"]))?; .args(["push", GIT_REMOTE, "main"])
.ensure_success()?;
// ensure origin matches ssh remote // ensure origin matches ssh remote
run(Command::new("git").current_dir(repo).args(["fetch"]))?; Command::new("git")
.current_dir(repo)
.args(["fetch"])
.ensure_success()?;
Ok(()) Ok(())
} }
@ -112,8 +104,9 @@ fn commit<F>(folder: F, message: &str) -> Result<()>
where where
F: AsRef<str>, F: AsRef<str>,
{ {
run(Command::new("git") Command::new("git")
.current_dir(folder.as_ref()) .current_dir(folder.as_ref())
.args(["commit", "-a", "-m", message]))?; .args(["commit", "-a", "-m", message])
.ensure_success()?;
Ok(()) Ok(())
} }

View File

@ -1,20 +0,0 @@
[package]
name = "anki_i18n_helpers"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
publish = false
rust-version.workspace = true
description = "Helpers for Anki's i18n system"
[lib]
name = "anki_i18n_helpers"
path = "src/lib.rs"
[dependencies]
fluent-syntax.workspace = true
lazy_static.workspace = true
regex.workspace = true
serde_json.workspace = true
walkdir.workspace = true

View File

@ -1,49 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// Deprecate unused ftl entries by moving them to the bottom of the file and
/// adding a deprecation warning. An entry is considered unused if cannot be
/// found in a source or JSON file.
/// Arguments before `--` are roots of ftl files, arguments after that are
/// source roots. JSON roots must be preceded by `--keep` or `-k`.
fn main() {
let args = Arguments::new();
anki_i18n_helpers::garbage_collection::deprecate_ftl_entries(
&args.ftl_roots,
&args.source_roots,
&args.json_roots,
);
}
#[derive(Default)]
struct Arguments {
ftl_roots: Vec<String>,
source_roots: Vec<String>,
json_roots: Vec<String>,
}
impl Arguments {
fn new() -> Self {
let mut args = Self::default();
let mut past_separator = false;
let mut keep_flag = false;
for arg in std::env::args() {
match arg.as_str() {
"--" => {
past_separator = true;
}
"--keep" | "-k" => {
keep_flag = true;
}
_ if keep_flag => {
keep_flag = false;
args.json_roots.push(arg)
}
_ if past_separator => args.source_roots.push(arg),
_ => args.ftl_roots.push(arg),
};
}
args
}
}

View File

@ -1,11 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// Delete every entry in the ftl files that is not mentioned in another message
/// or a given json.
/// First argument is the root of the json files, following are the roots of the
/// ftl files.
fn main() {
let args: Vec<String> = std::env::args().collect();
anki_i18n_helpers::garbage_collection::garbage_collect_ftl_entries(&args[2..], &args[1]);
}

View File

@ -1,11 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// Extract references from all Rust, Python, TS, Svelte and Designer files in
/// the given roots, convert them to ftl names case and write them as a json to
/// the target file.
/// First argument is the target file name, following are source roots.
fn main() {
let args: Vec<String> = std::env::args().collect();
anki_i18n_helpers::garbage_collection::write_ftl_json(&args[2..], &args[1]);
}

View File

@ -1,5 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub mod garbage_collection;
pub mod serialize;