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, Var, WeakVar, const_var, var, weak_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: Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
17    dir_watch_status: Var<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, file) = match entry.depth() {
77                        // lang/file.ftl
78                        2 => {
79                            let lang = utf8_path[0];
80                            let file_str = utf8_path[1].rsplit_once('.').unwrap().0;
81                            let file = Txt::from_str(if file_str == "_" { "" } else { file_str });
82                            (lang, LangFilePath::current_app(file))
83                        }
84                        // lang/deps/pkg-name/pkg-version/file.ftl
85                        5 => {
86                            if utf8_path[1] != "deps" {
87                                continue;
88                            }
89                            let lang = utf8_path[0];
90                            let pkg_name = Txt::from_str(utf8_path[2]);
91                            let pkg_version: Version = match utf8_path[3].parse() {
92                                Ok(v) => v,
93                                Err(e) => {
94                                    errors.push(Arc::new(e));
95                                    continue;
96                                }
97                            };
98                            let file_str = utf8_path[4].rsplit_once('.').unwrap().0;
99                            let file = Txt::from_str(if file_str == "_" { "" } else { file_str });
100
101                            (lang, LangFilePath::new(pkg_name, pkg_version, file))
102                        }
103                        _ => {
104                            continue;
105                        }
106                    };
107
108                    let lang = match Lang::from_str(lang) {
109                        Ok(l) => l,
110                        Err(e) => {
111                            errors.push(Arc::new(e));
112                            continue;
113                        }
114                    };
115
116                    set.get_exact_or_insert(lang, Default::default)
117                        .insert(file, entry.path().to_owned());
118                }
119
120                if errors.is_empty() {
121                    // Loaded set by `dir_watch` to avoid race condition in wait.
122                } else {
123                    let s = LangResourceStatus::Errors(errors);
124                    tracing::error!("'loading available' {s}");
125                    return Err(s);
126                }
127
128                Ok(Some(Arc::new(set)))
129            }),
130        );
131
132        Self {
133            dir_watch,
134            dir_watch_status: status.read_only(),
135            res: HashMap::new(),
136        }
137    }
138}
139impl L10nSource for L10nDir {
140    fn available_langs(&mut self) -> Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
141        self.dir_watch.clone()
142    }
143    fn available_langs_status(&mut self) -> Var<LangResourceStatus> {
144        self.dir_watch_status.clone()
145    }
146
147    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> Var<Option<ArcEq<fluent::FluentResource>>> {
148        match self.res.entry((lang, file)) {
149            std::collections::hash_map::Entry::Occupied(mut e) => {
150                if let Some(out) = e.get().res.upgrade() {
151                    out
152                } else {
153                    let (lang, file) = e.key();
154                    let out = resource_var(&self.dir_watch, e.get().status.clone(), lang.clone(), file.clone());
155                    e.get_mut().res = out.downgrade();
156                    out
157                }
158            }
159            std::collections::hash_map::Entry::Vacant(e) => {
160                let mut f = L10nFile::new();
161                let (lang, file) = e.key();
162                let out = resource_var(&self.dir_watch, f.status.clone(), lang.clone(), file.clone());
163                f.res = out.downgrade();
164                e.insert(f);
165                out
166            }
167        }
168    }
169
170    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> Var<LangResourceStatus> {
171        self.res.entry((lang, file)).or_insert_with(L10nFile::new).status.read_only()
172    }
173}
174struct L10nFile {
175    res: WeakVar<Option<ArcEq<fluent::FluentResource>>>,
176    status: Var<LangResourceStatus>,
177}
178impl L10nFile {
179    fn new() -> Self {
180        Self {
181            res: weak_var(),
182            status: var(LangResourceStatus::Loading),
183        }
184    }
185}
186
187fn resource_var(
188    dir_watch: &Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
189    status: Var<LangResourceStatus>,
190    lang: Lang,
191    file: LangFilePath,
192) -> Var<Option<ArcEq<fluent::FluentResource>>> {
193    dir_watch
194        .map(move |w| w.get_file(&lang, &file).cloned())
195        .flat_map(move |p| match p {
196            Some(p) => {
197                status.set(LangResourceStatus::Loading);
198
199                let r = WATCHER.read(
200                    p.clone(),
201                    None,
202                    clmv!(status, |file| {
203                        status.set(LangResourceStatus::Loading);
204
205                        match file.and_then(|mut f| f.string()) {
206                            Ok(flt) => match fluent::FluentResource::try_new(flt) {
207                                Ok(flt) => {
208                                    // ok
209                                    // Loaded set by `r` to avoid race condition in waiter.
210                                    return Some(Some(ArcEq::new(flt)));
211                                }
212                                Err(e) => {
213                                    let e = FluentParserErrors(e.1);
214                                    tracing::error!("error parsing fluent resource, {e}");
215                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
216                                }
217                            },
218                            Err(e) => {
219                                if matches!(e.kind(), io::ErrorKind::NotFound) {
220                                    status.set(LangResourceStatus::NotAvailable);
221                                } else {
222                                    tracing::error!("error loading fluent resource, {e}");
223                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
224                                }
225                            }
226                        }
227                        // not ok
228                        Some(None)
229                    }),
230                );
231                // set Loaded status only after `r` updates to ensure the value is available.
232                r.bind_filter_map(&status, |v| v.as_ref().map(|_| LangResourceStatus::Loaded))
233                    .perm();
234                r
235            }
236            None => const_var(None),
237        })
238}