Skip to main content

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                            (lang, LangFilePath::new(pkg_name, pkg_version, file))
101                        }
102                        _ => {
103                            continue;
104                        }
105                    };
106
107                    let lang = match Lang::from_str(lang) {
108                        Ok(l) => l,
109                        Err(e) => {
110                            errors.push(Arc::new(e));
111                            continue;
112                        }
113                    };
114
115                    set.get_exact_or_insert(lang, Default::default)
116                        .insert(file, entry.path().to_owned());
117                }
118
119                if errors.is_empty() {
120                    // Loaded set by `dir_watch` to avoid race condition in wait.
121                } else {
122                    let s = LangResourceStatus::Errors(errors);
123                    tracing::error!("'loading available' {s}");
124                    return Err(s);
125                }
126                for m in set.values_mut() {
127                    m.shrink_to_fit();
128                }
129                set.shrink_to_fit();
130                Ok(Some(Arc::new(set)))
131            }),
132        );
133
134        Self {
135            dir_watch,
136            dir_watch_status: status.read_only(),
137            res: HashMap::new(),
138        }
139    }
140}
141impl L10nSource for L10nDir {
142    fn available_langs(&mut self) -> Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
143        self.dir_watch.clone()
144    }
145    fn available_langs_status(&mut self) -> Var<LangResourceStatus> {
146        self.dir_watch_status.clone()
147    }
148
149    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> Var<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) -> Var<LangResourceStatus> {
173        self.res.entry((lang, file)).or_insert_with(L10nFile::new).status.read_only()
174    }
175}
176struct L10nFile {
177    res: WeakVar<Option<ArcEq<fluent::FluentResource>>>,
178    status: Var<LangResourceStatus>,
179}
180impl L10nFile {
181    fn new() -> Self {
182        Self {
183            res: weak_var(),
184            status: var(LangResourceStatus::Loading),
185        }
186    }
187}
188
189fn resource_var(
190    dir_watch: &Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
191    status: Var<LangResourceStatus>,
192    lang: Lang,
193    file: LangFilePath,
194) -> Var<Option<ArcEq<fluent::FluentResource>>> {
195    dir_watch
196        .map(move |w| w.get_file(&lang, &file).cloned())
197        .flat_map(move |p| match p {
198            Some(p) => {
199                status.set(LangResourceStatus::Loading);
200
201                let r = WATCHER.read(
202                    p.clone(),
203                    None,
204                    clmv!(status, |file| {
205                        status.set(LangResourceStatus::Loading);
206
207                        match file.and_then(|mut f| f.string()) {
208                            Ok(flt) => match fluent::FluentResource::try_new(flt) {
209                                Ok(flt) => {
210                                    // ok
211                                    // Loaded set by `r` to avoid race condition in waiter.
212                                    return Some(Some(ArcEq::new(flt)));
213                                }
214                                Err(e) => {
215                                    let e = FluentParserErrors(e.1);
216                                    tracing::error!("error parsing fluent resource, {e}");
217                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
218                                }
219                            },
220                            Err(e) => {
221                                if matches!(e.kind(), io::ErrorKind::NotFound) {
222                                    status.set(LangResourceStatus::NotAvailable);
223                                } else {
224                                    tracing::error!("error loading fluent resource, {e}");
225                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
226                                }
227                            }
228                        }
229                        // not ok
230                        Some(None)
231                    }),
232                );
233                // set Loaded status only after `r` updates to ensure the value is available.
234                r.bind_filter_map(&status, |v| v.as_ref().map(|_| LangResourceStatus::Loaded))
235                    .perm();
236                r
237            }
238            None => const_var(None),
239        })
240}