zng_ext_l10n/sources/
dir.rs

1use std::{collections::HashMap, io, path::PathBuf, str::FromStr as _, sync::Arc};
2
3use semver::Version;
4use zng_clone_move::clmv;
5use zng_ext_fs_watcher::WATCHER;
6use zng_txt::Txt;
7use zng_var::{ArcEq, ArcVar, BoxedVar, BoxedWeakVar, LocalVar, Var as _, WeakVar as _, types::WeakArcVar, var};
8
9use crate::{FluentParserErrors, L10nSource, Lang, LangFilePath, LangMap, LangResourceStatus};
10
11/// Represents localization resources synchronized from files in a directory.
12///
13/// The expected directory layout is `{dir}/{lang}/{file}.ftl` app files and `{dir}/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`
14/// for dependencies.
15pub struct L10nDir {
16    dir_watch: BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
17    dir_watch_status: BoxedVar<LangResourceStatus>,
18    res: HashMap<(Lang, LangFilePath), L10nFile>,
19}
20impl L10nDir {
21    /// Start watching the `dir` for localization files.
22    pub fn open(dir: impl Into<PathBuf>) -> Self {
23        Self::new(dir.into())
24    }
25    fn new(dir: PathBuf) -> Self {
26        let (dir_watch, status) = WATCHER.read_dir_status(
27            dir.clone(),
28            true,
29            Arc::default(),
30            clmv!(|d| {
31                let mut set: LangMap<HashMap<LangFilePath, PathBuf>> = LangMap::new();
32                let mut errors: Vec<Arc<dyn std::error::Error + Send + Sync>> = vec![];
33                let mut dir = None;
34                for entry in d.min_depth(0).max_depth(5) {
35                    let entry = match entry {
36                        Ok(e) => e,
37                        Err(e) => {
38                            errors.push(Arc::new(e));
39                            continue;
40                        }
41                    };
42                    let ty = entry.file_type();
43
44                    if dir.is_none() {
45                        // get the watched dir (first because of min_depth(0))
46                        if !ty.is_dir() {
47                            tracing::error!("L10N path not a directory");
48                            return Err(LangResourceStatus::NotAvailable);
49                        }
50                        dir = Some(entry.path().to_owned());
51                        continue;
52                    }
53
54                    const EXT: unicase::Ascii<&'static str> = unicase::Ascii::new("ftl");
55
56                    let is_ftl = ty.is_file()
57                        && entry
58                            .file_name()
59                            .to_str()
60                            .and_then(|n| n.rsplit_once('.'))
61                            .map(|(_, ext)| ext.is_ascii() && unicase::Ascii::new(ext) == EXT)
62                            .unwrap_or(false);
63
64                    if !is_ftl {
65                        continue;
66                    }
67
68                    let mut utf8_path = [""; 5];
69                    for (i, part) in entry.path().iter().rev().take(entry.depth()).enumerate() {
70                        match part.to_str() {
71                            Some(p) => utf8_path[entry.depth() - i - 1] = p,
72                            None => continue,
73                        }
74                    }
75
76                    let (lang, mut file) = match entry.depth() {
77                        // lang/file.ftl
78                        2 => {
79                            let lang = utf8_path[0];
80                            let file = Txt::from_str(utf8_path[1].rsplit_once('.').unwrap().0);
81                            (lang, LangFilePath::current_app(file))
82                        }
83                        // lang/deps/pkg-name/pkg-version/file.ftl
84                        5 => {
85                            if utf8_path[1] != "deps" {
86                                continue;
87                            }
88                            let lang = utf8_path[0];
89                            let pkg_name = Txt::from_str(utf8_path[2]);
90                            let pkg_version: Version = match utf8_path[3].parse() {
91                                Ok(v) => v,
92                                Err(e) => {
93                                    errors.push(Arc::new(e));
94                                    continue;
95                                }
96                            };
97                            let file = Txt::from_str(utf8_path[4]);
98
99                            (lang, LangFilePath::new(pkg_name, pkg_version, file))
100                        }
101                        _ => {
102                            continue;
103                        }
104                    };
105
106                    let lang = match Lang::from_str(lang) {
107                        Ok(l) => l,
108                        Err(e) => {
109                            errors.push(Arc::new(e));
110                            continue;
111                        }
112                    };
113
114                    if file.file == "_" {
115                        file.file = "".into();
116                    }
117
118                    set.get_exact_or_insert(lang, Default::default)
119                        .insert(file, entry.path().to_owned());
120                }
121
122                if errors.is_empty() {
123                    // Loaded set by `dir_watch` to avoid race condition in wait.
124                } else {
125                    let s = LangResourceStatus::Errors(errors);
126                    tracing::error!("'loading available' {s}");
127                    return Err(s);
128                }
129
130                Ok(Some(Arc::new(set)))
131            }),
132        );
133
134        Self {
135            dir_watch: dir_watch.boxed(),
136            dir_watch_status: status.read_only().boxed(),
137            res: HashMap::new(),
138        }
139    }
140}
141impl L10nSource for L10nDir {
142    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
143        self.dir_watch.clone()
144    }
145    fn available_langs_status(&mut self) -> BoxedVar<LangResourceStatus> {
146        self.dir_watch_status.clone()
147    }
148
149    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
150        match self.res.entry((lang, file)) {
151            std::collections::hash_map::Entry::Occupied(mut e) => {
152                if let Some(out) = e.get().res.upgrade() {
153                    out
154                } else {
155                    let (lang, file) = e.key();
156                    let out = resource_var(&self.dir_watch, e.get().status.clone(), lang.clone(), file.clone());
157                    e.get_mut().res = out.downgrade();
158                    out
159                }
160            }
161            std::collections::hash_map::Entry::Vacant(e) => {
162                let mut f = L10nFile::new();
163                let (lang, file) = e.key();
164                let out = resource_var(&self.dir_watch, f.status.clone(), lang.clone(), file.clone());
165                f.res = out.downgrade();
166                e.insert(f);
167                out
168            }
169        }
170    }
171
172    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<LangResourceStatus> {
173        self.res
174            .entry((lang, file))
175            .or_insert_with(L10nFile::new)
176            .status
177            .read_only()
178            .boxed()
179    }
180}
181struct L10nFile {
182    res: BoxedWeakVar<Option<ArcEq<fluent::FluentResource>>>,
183    status: ArcVar<LangResourceStatus>,
184}
185impl L10nFile {
186    fn new() -> Self {
187        Self {
188            res: WeakArcVar::default().boxed(),
189            status: var(LangResourceStatus::Loading),
190        }
191    }
192}
193
194fn resource_var(
195    dir_watch: &BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
196    status: ArcVar<LangResourceStatus>,
197    lang: Lang,
198    file: LangFilePath,
199) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
200    dir_watch
201        .map(move |w| w.get_file(&lang, &file).cloned())
202        .flat_map(move |p| match p {
203            Some(p) => {
204                status.set(LangResourceStatus::Loading);
205
206                let r = WATCHER.read(
207                    p.clone(),
208                    None,
209                    clmv!(status, |file| {
210                        status.set(LangResourceStatus::Loading);
211
212                        match file.and_then(|mut f| f.string()) {
213                            Ok(flt) => match fluent::FluentResource::try_new(flt) {
214                                Ok(flt) => {
215                                    // ok
216                                    // Loaded set by `r` to avoid race condition in waiter.
217                                    return Some(Some(ArcEq::new(flt)));
218                                }
219                                Err(e) => {
220                                    let e = FluentParserErrors(e.1);
221                                    tracing::error!("error parsing fluent resource, {e}");
222                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
223                                }
224                            },
225                            Err(e) => {
226                                if matches!(e.kind(), io::ErrorKind::NotFound) {
227                                    status.set(LangResourceStatus::NotAvailable);
228                                } else {
229                                    tracing::error!("error loading fluent resource, {e}");
230                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
231                                }
232                            }
233                        }
234                        // not ok
235                        Some(None)
236                    }),
237                );
238                r.bind_map(&status, |_| LangResourceStatus::Loaded).perm();
239                r.boxed()
240            }
241            None => LocalVar(None).boxed(),
242        })
243}