1use std::{
2 borrow::Cow,
3 collections::{HashMap, HashSet},
4};
5
6use once_cell::unsync::Lazy;
7
8use super::*;
9
10const L10N_HELP: &str = "
11Copy localization files (.ftl) and optimize for release
12
13The request file:
14 source/l10n.zr-l10n
15 | # comment
16 | path/dev-l10n
17
18Copies the `path/dev-l10n` dir to:
19 target/l10n
20
21Paths are relative to the Cargo workspace root
22
23Filter:
24
25Only localization files are included
26 **/*.ftl
27
28Development langs are excluded
29 !./pseudo*
30 !./template
31
32Only lang folders that have local translations are included
33 If ./{lang}/deps/** exists but no ./{lang}/*.ftl exists it is excluded
34
35Comments are stripped
36
37Subsetting:
38
39If a l10n subset profile is found is is applied to the dependency localization
40
41The subset profile is an allow list, see the docs `zng::l10n` for how to create one
42
43The subset profile is resolved in this order:
44
45ZNG_L10N_PROFILE_FILE env if is set
46 Must be set to a .subset file path, relative to the Cargo workspace root
47 If the file is a {name}.rec.subset auto includes a {name}.subset and vice versa
48
49res/optimization-profiles/zng-ext-l10n.rec.subset
50 Default location, also includes zng-ext-l10n.subset if present
51
52{l10n-path}/*.subset
53 If multiple files match all are used
54
55";
56pub(super) fn l10n() {
57 help(L10N_HELP);
58
59 let source = read_path(&path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
61 let mut target = path(ZR_TARGET);
63 if target.ends_with(".zr-l10n") {
65 target = target.with_file_name(source.file_name().unwrap());
66 }
67
68 if source.is_dir() {
69 println!("{}", display_path(&target));
70 fs::create_dir(&target).unwrap_or_else(|e| {
71 if e.kind() != io::ErrorKind::AlreadyExists {
72 fatal!("{e}")
73 }
74 });
75
76 l10n_filter_copy(source, target);
77 } else if source.is_file() {
78 fatal!("expected l10n dir, '{}' is a file", source.display());
79 } else if source.is_symlink() {
80 symlink_warn(&source);
81 } else {
82 warn!("cannot copy l10n dir '{}', not found", source.display());
83 }
84}
85
86pub(crate) fn release_langs(path: &Path) {
88 let mut sep = "";
89 for from_lang in fs::read_dir(path).unwrap_or_else(|e| fatal!("cannot read {}, {}", path.display(), e)) {
90 let from_lang = from_lang
91 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", path.display(), e))
92 .path();
93 if !from_lang.is_dir() {
94 continue;
95 }
96 let lang = match from_lang.file_name().and_then(|s| s.to_str()) {
97 Some(l) => l,
98 None => continue,
99 };
100 if lang.starts_with("pseudo") || lang == "template" {
101 continue;
102 }
103
104 let mut any_ftl = false;
105 for from_entry in fs::read_dir(&from_lang).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_lang.display(), e)) {
106 let from_entry = from_entry
107 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_lang.display(), e))
108 .path();
109
110 if from_entry.is_file()
111 && let Some(ext) = from_entry.extension()
112 && ext.eq_ignore_ascii_case("ftl")
113 {
114 any_ftl = true;
115 break;
116 }
117 }
118 if any_ftl {
119 print!("{sep}{lang}");
120 sep = ",";
121 }
122 }
123 println!();
124}
125
126fn l10n_filter_copy(from: PathBuf, to: PathBuf) {
127 let subset = allow_subset(&from);
128
129 for from_lang in fs::read_dir(&from).unwrap_or_else(|e| fatal!("cannot read {}, {}", from.display(), e)) {
130 let from_lang = from_lang
131 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from.display(), e))
132 .path();
133 if !from_lang.is_dir() {
134 continue;
135 }
136
137 let lang = match from_lang.file_name().and_then(|s| s.to_str()) {
139 Some(l) => l,
140 None => continue,
141 };
142 if lang.starts_with("pseudo") || lang == "template" {
143 continue;
144 }
145
146 let to_lang = to.join(lang);
147
148 let mut any_ftl = false;
150 let mut from_deps = None;
151 for from_entry in fs::read_dir(&from_lang).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_lang.display(), e)) {
152 let from_entry = from_entry
153 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_lang.display(), e))
154 .path();
155
156 if from_entry.is_file() {
157 if let Some(ext) = from_entry.extension()
158 && ext.eq_ignore_ascii_case("ftl")
159 {
160 if !any_ftl {
161 fs::create_dir(&to_lang).unwrap_or_else(|e| fatal!("cannot create {}, {}", to_lang.display(), e));
162 }
163 any_ftl = true;
164
165 let to_entry = to_lang.join(from_entry.file_name().unwrap());
166 fs::copy(from_entry, &to_entry).unwrap_or_else(|e| fatal!("cannot copy to {}, {}", to_entry.display(), e));
167 }
168 } else if from_entry.is_dir()
169 && let Some(name) = from_entry.file_name()
170 && name == "deps"
171 {
172 from_deps = Some(from_entry);
173 }
174 }
175
176 if !any_ftl {
178 continue;
179 }
180
181 let from_deps = match from_deps {
182 Some(p) => p,
183 None => continue,
184 };
185
186 macro_rules! lazy_path {
187 ($init:expr) => {
188 Lazy::<PathBuf, _>::new(|| {
189 let p = $init;
190 fs::create_dir(&p).unwrap_or_else(|e| fatal!("cannot create {}, {}", p.display(), e));
191 p
192 })
193 };
194 }
195 let to_deps = lazy_path!(to_lang.join("deps"));
196
197 for from_pkg in fs::read_dir(&from_deps).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_deps.display(), e)) {
199 let from_pkg = from_pkg
200 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_deps.display(), e))
201 .path();
202 if !from_pkg.is_dir() {
203 continue;
204 }
205
206 let pkg = match from_pkg.file_name().and_then(|p| p.to_str()) {
207 Some(p) => p,
208 None => continue,
209 };
210 let subset_pkg = match subset.get(pkg) {
211 Some(m) => Cow::Borrowed(m),
212 None => {
213 if !subset.is_empty() {
215 continue;
216 }
217 Cow::Owned(HashMap::new())
219 }
220 };
221
222 let to_pkg = lazy_path!(to_deps.join(pkg));
223
224 for from_ver in fs::read_dir(&from_pkg).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_pkg.display(), e)) {
225 let from_ver = from_ver
226 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_pkg.display(), e))
227 .path();
228 if !from_ver.is_dir() {
229 continue;
230 }
231
232 let to_ver = lazy_path!(to_pkg.join(from_ver.file_name().unwrap()));
233
234 for from_entry in fs::read_dir(&from_ver).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_ver.display(), e)) {
235 let from_entry = from_entry
236 .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_ver.display(), e))
237 .path();
238 if !from_entry.is_file() || !matches!(from_entry.extension(), Some(ext) if ext.eq_ignore_ascii_case("ftl")) {
239 continue;
240 }
241
242 let file = match from_entry.file_name().unwrap().to_str() {
243 Some(f) => f,
244 None => continue,
245 };
246 let subset_file = match subset_pkg.get(file) {
247 Some(m) => Cow::Borrowed(m),
248 None => {
249 if !subset_pkg.is_empty() {
250 continue;
251 }
252 Cow::Owned(HashMap::new())
253 }
254 };
255
256 let to_entry = to_ver.join(file);
257
258 let ok = crate::l10n::generate_util::transform_file(
259 &from_entry,
260 &to_entry,
261 "",
262 &|id, attr| match subset_file.get(id) {
263 Some(attrs) => attr.is_empty() || attrs.contains(attr),
264 None => subset_file.is_empty(), },
266 &|s| Cow::Borrowed(s),
267 false,
268 false,
269 );
270 if !ok {
271 fatal!("cannot optimize {}", from_entry.display());
272 }
273 }
274 }
275 }
276 }
277}
278
279type SubsetMap = HashMap<String, HashMap<String, HashMap<String, HashSet<String>>>>;
281
282fn allow_subset(from: &Path) -> SubsetMap {
283 let mut out = SubsetMap::new();
284
285 if let Ok(path) = env::var("ZNG_L10N_PROFILE_FILE")
286 && !path.is_empty()
287 {
288 if !path.ends_with(".subset") {
289 fatal!("ZNG_L10N_PROFILE_FILE must be a .subset file");
290 }
291 read_subset(Path::new(&path), true, &mut out);
292 } else {
293 let default_profile = Path::new("res/optimization-profiles/zng-ext-l10n.rec.subset");
294 if default_profile.exists() {
295 read_subset(default_profile, true, &mut out);
296 } else {
297 let default_profile_pair = Path::new("res/optimization-profiles/zng-ext-l10n.subset");
298 if default_profile_pair.exists() {
299 read_subset(default_profile, false, &mut out);
300 } else {
301 for file in ::glob::glob(&format!("{}/*.subset", from.display())).unwrap() {
302 let file = file.unwrap_or_else(|e| fatal!("cannot read {}, {}", from.display(), e));
303 read_subset(&file, false, &mut out);
304 }
305 }
306 }
307 }
308
309 out
310}
311fn read_subset(path: &Path, try_pair: bool, out: &mut SubsetMap) {
312 let file = fs::File::open(path).unwrap_or_else(|e| fatal!("cannot read {}, {}", path.display(), e));
313 let file = io::BufReader::new(file);
314 for (i, line) in file.lines().enumerate() {
315 let line = line.unwrap_or_else(|e| fatal!("cannot read {}, {}", path.display(), e));
316 let line = line.trim();
317 if line.starts_with('#') || line.is_empty() {
318 continue;
319 }
320
321 let (dependency, mut key) = match line.split_once("//") {
322 Some((d, k)) if !d.is_empty() && !k.is_empty() => (d, k),
323 _ => fatal!("unexpected line {}:{}", path.display(), i + 1),
324 };
325 let file = match key.split_once('/') {
326 Some((f, k)) => {
327 key = k;
328 if f == "_" || f.is_empty() {
330 Cow::Borrowed("_.ftl")
331 } else {
332 Cow::Owned(format!("{f}.ftl"))
333 }
334 }
335 None => Cow::Borrowed("_.ftl"),
336 };
337 let (id, attribute) = match key.split_once('.') {
338 Some((id, a)) => {
339 if id.is_empty() || a.is_empty() {
340 fatal!("unexpected line {}:{}", path.display(), i + 1);
341 }
342 (id, a)
343 }
344 None => (key, ""),
345 };
346
347 let dep_map = match out.get_mut(dependency) {
348 Some(m) => m,
349 None => out.entry(dependency.to_owned()).or_default(),
350 };
351 let file_map = match dep_map.get_mut(&*file) {
352 Some(m) => m,
353 None => dep_map.entry(file.into_owned()).or_default(),
354 };
355 let id_map = match file_map.get_mut(id) {
356 Some(m) => m,
357 None => file_map.entry(id.to_owned()).or_default(),
358 };
359 if !attribute.is_empty() && !id_map.contains(attribute) {
360 id_map.insert(attribute.to_owned());
361 }
362 }
363
364 if try_pair {
365 let name = path.file_name().unwrap().to_str().unwrap();
366 let pair_name = if let Some(n) = name.strip_suffix(".rec.subset") {
367 format!("{n}.subset")
368 } else if let Some(n) = name.strip_suffix(".subset") {
369 format!("{n}.rec.subset")
370 } else {
371 return;
372 };
373 let pair_path = path.parent().unwrap().join(pair_name);
374 if pair_path.exists() {
375 read_subset(&pair_path, false, out);
376 }
377 }
378}