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
127                Ok(Some(Arc::new(set)))
128            }),
129        );
130
131        Self {
132            dir_watch,
133            dir_watch_status: status.read_only(),
134            res: HashMap::new(),
135        }
136    }
137}
138impl L10nSource for L10nDir {
139    fn available_langs(&mut self) -> Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
140        self.dir_watch.clone()
141    }
142    fn available_langs_status(&mut self) -> Var<LangResourceStatus> {
143        self.dir_watch_status.clone()
144    }
145
146    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> Var<Option<ArcEq<fluent::FluentResource>>> {
147        match self.res.entry((lang, file)) {
148            std::collections::hash_map::Entry::Occupied(mut e) => {
149                if let Some(out) = e.get().res.upgrade() {
150                    out
151                } else {
152                    let (lang, file) = e.key();
153                    let out = resource_var(&self.dir_watch, e.get().status.clone(), lang.clone(), file.clone());
154                    e.get_mut().res = out.downgrade();
155                    out
156                }
157            }
158            std::collections::hash_map::Entry::Vacant(e) => {
159                let mut f = L10nFile::new();
160                let (lang, file) = e.key();
161                let out = resource_var(&self.dir_watch, f.status.clone(), lang.clone(), file.clone());
162                f.res = out.downgrade();
163                e.insert(f);
164                out
165            }
166        }
167    }
168
169    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> Var<LangResourceStatus> {
170        self.res.entry((lang, file)).or_insert_with(L10nFile::new).status.read_only()
171    }
172}
173struct L10nFile {
174    res: WeakVar<Option<ArcEq<fluent::FluentResource>>>,
175    status: Var<LangResourceStatus>,
176}
177impl L10nFile {
178    fn new() -> Self {
179        Self {
180            res: weak_var(),
181            status: var(LangResourceStatus::Loading),
182        }
183    }
184}
185
186fn resource_var(
187    dir_watch: &Var<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
188    status: Var<LangResourceStatus>,
189    lang: Lang,
190    file: LangFilePath,
191) -> Var<Option<ArcEq<fluent::FluentResource>>> {
192    dir_watch
193        .map(move |w| w.get_file(&lang, &file).cloned())
194        .flat_map(move |p| match p {
195            Some(p) => {
196                status.set(LangResourceStatus::Loading);
197
198                let r = WATCHER.read(
199                    p.clone(),
200                    None,
201                    clmv!(status, |file| {
202                        status.set(LangResourceStatus::Loading);
203
204                        match file.and_then(|mut f| f.string()) {
205                            Ok(flt) => match fluent::FluentResource::try_new(flt) {
206                                Ok(flt) => {
207                                    // ok
208                                    // Loaded set by `r` to avoid race condition in waiter.
209                                    return Some(Some(ArcEq::new(flt)));
210                                }
211                                Err(e) => {
212                                    let e = FluentParserErrors(e.1);
213                                    tracing::error!("error parsing fluent resource, {e}");
214                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
215                                }
216                            },
217                            Err(e) => {
218                                if matches!(e.kind(), io::ErrorKind::NotFound) {
219                                    status.set(LangResourceStatus::NotAvailable);
220                                } else {
221                                    tracing::error!("error loading fluent resource, {e}");
222                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
223                                }
224                            }
225                        }
226                        // not ok
227                        Some(None)
228                    }),
229                );
230                // set Loaded status only after `r` updates to ensure the value is available.
231                r.bind_filter_map(&status, |v| v.as_ref().map(|_| LangResourceStatus::Loaded))
232                    .perm();
233                r
234            }
235            None => const_var(None),
236        })
237}