Files
wordcraft/crates/engine/src/packs.rs
T
Martin BallandClaude Opus 5.5 03c50f8af2 CP4.3: restack on #5205 — craft-proof #485 bdd434bc and font store #486 83c2e7b from storytold/craft-libs, craft-i18n 059cbf9; SCOWL notice in About; no "(no dictionary)" or bar for a language with no pack; Swiss German and Portugal's Portuguese keep their names
- craft-proof now names ja, zh and th in missing_dictionary: packs::missing_dictionary hides it
  for a pack language when no pack for it is listed (status bar and Options).
- de-CH and pt-PT are pack languages in craft-proof (de-ch, pt-pt): names, hyphenation and
  grammar stay German and Portuguese (tests); pack names fall back to the region-aware name.
- About › Language data shows craft_proof::scowl::notice() when SCOWL is embedded (test).
- Flatpak manifest's craft-i18n commit follows craft-i18n.rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00

731 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond
//! the embedded English, one pack per locale, downloaded and verified by the font store
//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the
//! store's `language-packs/` folder.
//!
//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language,
//! the missing-pack bar and About › Language data all go through this module. It never asks
//! anything: an install needs the font store's single consent setting (downloading on), which only
//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click).
//! Agents (MCP, the CLI, the control channel) install only while it is on.
//!
//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]).
//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded.
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use serde::Serialize;
use crate::{CmdError, Session};
#[cfg(not(target_arch = "wasm32"))]
mod store;
/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20).
pub const MAX_LOCALE_LEN: usize = 35;
/// Most packs an index may list that are read.
const MAX_PACKS: usize = 1000;
/// Paragraphs of a document looked at for its languages (the missing-pack bar).
const MAX_PARAGRAPHS_SCANNED: usize = 20_000;
/// Distinct languages of a document collected.
const MAX_DOC_LANGUAGES: usize = 64;
/// A pack the pinned index lists.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Pack {
/// Lowercase BCP 47 (`nl`, `pt-pt`).
pub locale: String,
/// The language's own name (`Nederlands`).
pub name: String,
/// The craft-i18n commit the pack was built from.
pub version: String,
/// The archive.
pub url: String,
/// The upstream folder the data comes from, at its pinned commit (the index's optional
/// `source`), shown before install; the archive when the index gives none.
pub source: Option<String>,
/// Bytes of the archive.
pub size: u64,
/// SPDX expression.
pub licence: String,
/// `Embeddable` or `Download-only`.
pub class: String,
/// Has a spelling dictionary.
pub spelling: bool,
/// Has hyphenation patterns.
pub hyphenation: bool,
}
/// An installed pack.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Installed {
pub locale: String,
pub name: String,
pub licence: String,
pub path: PathBuf,
}
/// Stops a download. On the desktop it is the font store's own flag, which a download watches
/// even while the server sends nothing.
#[derive(Clone, Debug, Default)]
pub struct Cancel(#[cfg(not(target_arch = "wasm32"))] pub(crate) craft_font_store::Cancel, #[cfg(target_arch = "wasm32")] Arc<AtomicBool>);
impl Cancel {
pub fn new() -> Self {
Self::default()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn cancel(&self) {
self.0.cancel();
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_cancelled(&self) -> bool {
self.0.is_cancelled()
}
#[cfg(target_arch = "wasm32")]
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
#[cfg(target_arch = "wasm32")]
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`.
pub trait Backend: Send + Sync {
/// The pinned index as last fetched (no request); empty when it never was.
fn index(&self) -> Result<Vec<Pack>, String>;
/// Fetch the pinned index (a request; only while downloading is on).
fn fetch_index(&self, cancel: &Cancel) -> Result<Vec<Pack>, String>;
/// Whether the font store may download.
fn network_on(&self) -> bool;
/// Turn downloading on. Only from the person's own action in the app (an Install click):
/// the commands never call it, and a backend for an agent refuses.
fn enable_network(&self) -> Result<(), String>;
/// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes.
fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>;
/// Remove the installed pack for `locale`.
fn remove(&self, locale: &str) -> Result<(), String>;
/// The installed-packs folder (`<font store>/language-packs`).
fn packs_dir(&self) -> Option<PathBuf>;
/// The packs installed now.
fn installed(&self) -> Vec<Installed> {
installed_from_proof()
}
/// The test index in use instead of the pinned one (`CRAFT_LANGUAGE_PACK_INDEX`), if any.
fn index_override(&self) -> Option<String> {
None
}
}
static BACKEND: RwLock<Option<Arc<dyn Backend>>> = RwLock::new(None);
/// The index last read, so the bar and the list don't read the store's cache every frame.
static INDEX: Mutex<Option<Vec<Pack>>> = Mutex::new(None);
/// Bumped whenever [`INDEX`] changes.
static INDEX_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn set_index(index: Option<Vec<Pack>>) {
*INDEX.lock().unwrap_or_else(PoisonError::into_inner) = index;
INDEX_GENERATION.fetch_add(1, Ordering::Relaxed);
}
/// Changes whenever the list of available packs does (a fetch, another backend).
pub fn index_generation() -> u64 {
INDEX_GENERATION.load(Ordering::Relaxed)
}
/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder.
pub fn set_backend(backend: Option<Arc<dyn Backend>>) {
let dir = backend.as_ref().and_then(|b| b.packs_dir());
*BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend;
set_index(None);
jobs().errors.clear();
wordcraft_proof::packs::set_packs_dir(dir);
}
/// Use the suite's font store for packs (`<font store>/language-packs`), as the person in the app
/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it
/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path,
/// for testing packs before they are published), which the font store reads itself. Returns
/// whether there is a store folder.
#[cfg(not(target_arch = "wasm32"))]
pub fn use_font_store(user: bool) -> bool {
match store::FontStoreBackend::new(user) {
Some(b) => {
set_backend(Some(Arc::new(b)));
true
}
None => false,
}
}
/// [`use_font_store`] with the store in `dir` instead of the person's (`ui_shot`, tests).
#[cfg(not(target_arch = "wasm32"))]
pub fn use_font_store_in(dir: PathBuf, user: bool) {
set_backend(Some(Arc::new(store::FontStoreBackend::in_dir(dir, user))));
}
/// The backend in use.
pub fn backend() -> Option<Arc<dyn Backend>> {
BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone()
}
fn installed_from_proof() -> Vec<Installed> {
wordcraft_proof::packs::installed().into_iter().map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }).collect()
}
/// The test source packs come from instead of the pinned index (`CRAFT_LANGUAGE_PACK_INDEX`):
/// Options › Language says so.
pub fn index_override() -> Option<String> {
backend().and_then(|b| b.index_override())
}
/// The packs installed now (none without a backend).
pub fn installed() -> Vec<Installed> {
backend().map(|b| b.installed()).unwrap_or_default()
}
/// The available packs: the index as last read, read from the store's cache the first time.
pub fn available() -> Vec<Pack> {
let Some(b) = backend() else { return Vec::new() };
let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = cached.as_ref() {
return i.clone();
}
let index = clean_index(b.index().unwrap_or_default());
*cached = Some(index.clone());
drop(cached);
INDEX_GENERATION.fetch_add(1, Ordering::Relaxed);
index
}
/// How many packs [`available`] lists, without copying them (the window asks every frame).
pub fn available_len() -> usize {
if let Some(i) = INDEX.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
return i.len();
}
available().len()
}
/// Fetch the index again (downloading must be on).
pub fn refresh(cancel: &Cancel) -> Result<Vec<Pack>, String> {
let b = backend().ok_or_else(no_store)?;
if !b.network_on() {
return Err(network_off());
}
let index = clean_index(b.fetch_index(cancel)?);
set_index(Some(index.clone()));
Ok(index)
}
/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list.
fn clean_index(mut packs: Vec<Pack>) -> Vec<Pack> {
packs.truncate(MAX_PACKS);
let mut seen = BTreeSet::new();
packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone()));
packs.sort_by(|a, b| a.locale.cmp(&b.locale));
packs
}
/// English is embedded in every build; it never comes as a pack.
fn is_embedded(locale: &str) -> bool {
locale == "en" || locale.starts_with("en-")
}
/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter.
pub fn valid_locale(locale: &str) -> bool {
!locale.is_empty()
&& locale.len() <= MAX_LOCALE_LEN
&& locale.starts_with(|c: char| c.is_ascii_lowercase())
&& locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !locale.contains("--")
&& !locale.ends_with('-')
}
/// A locale from a command's params: trimmed, lowercased, `_` as `-`.
pub fn parse_locale(raw: &str) -> Result<String, CmdError> {
let l = raw.trim().replace('_', "-").to_ascii_lowercase();
if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) }
}
fn no_store() -> String {
if cfg!(target_arch = "wasm32") {
"Language packs aren't available in the browser".into()
} else {
"there is no font store folder for language packs".into()
}
}
fn network_off() -> String {
"downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into()
}
/// The pack for `locale` in the index.
pub fn find(locale: &str) -> Option<Pack> {
available().into_iter().find(|p| p.locale == locale)
}
/// Whether a pack is installed for `locale`.
pub fn is_installed(locale: &str) -> bool {
installed().iter().any(|p| p.locale == locale)
}
/// Install the pack for `locale` (downloading must be on): the commands and the Install button.
/// Proofing picks it up at once.
pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<Installed, String> {
let b = backend().ok_or_else(no_store)?;
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
if !b.network_on() {
return Err(network_off());
}
if let Some(dir) = b.packs_dir() {
crate::file_access::check_write(&dir.to_string_lossy())?;
}
b.install(&pack, cancel, progress)?;
reload(&b);
b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install"))
}
/// Remove the installed pack for `locale`.
pub fn remove(locale: &str) -> Result<(), String> {
let b = backend().ok_or_else(no_store)?;
if jobs().installs.iter().any(|(l, ..)| l == locale) {
return Err(format!("the {locale} pack is being installed: cancel it first"));
}
if !b.installed().iter().any(|p| p.locale == locale) {
return Err(format!("no language pack is installed for {locale}"));
}
if let Some(dir) = b.packs_dir() {
crate::file_access::check_write(&dir.to_string_lossy())?;
}
b.remove(locale)?;
reload(&b);
Ok(())
}
/// What the app does with a long pack request (an install or a fetch of the list): `None` runs it
/// in the caller (the CLI, headless MCP, tests); `Some(notify)` runs it on a thread, so the window
/// keeps drawing whoever asked (the person, the control channel, MCP), and calls `notify` on each
/// change (the window repaints). Set once by the desktop app.
pub type Notify = Arc<dyn Fn() + Send + Sync>;
static BACKGROUND: RwLock<Option<Notify>> = RwLock::new(None);
/// Installs and fetches running on a thread, and how the finished ones ended.
#[derive(Default)]
struct Jobs {
/// Running installs by locale: (bytes done, bytes in all, its Cancel).
installs: Vec<(String, u64, u64, Cancel)>,
fetching: bool,
/// The last failure of each locale's install ("" for the list), until it is tried again.
errors: Vec<(String, String)>,
/// Bumped as each job ends: the window relays out and shows the result.
finished: u64,
}
static JOBS: Mutex<Jobs> = Mutex::new(Jobs { installs: Vec::new(), fetching: false, errors: Vec::new(), finished: 0 });
fn jobs() -> std::sync::MutexGuard<'static, Jobs> {
JOBS.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Run long pack requests on a thread (`Some`: the desktop app), or in the caller (`None`).
pub fn set_background(notify: Option<Notify>) {
*BACKGROUND.write().unwrap_or_else(PoisonError::into_inner) = notify;
}
fn background() -> Option<Notify> {
if cfg!(target_arch = "wasm32") {
return None;
}
BACKGROUND.read().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Whether installs and fetches run on a thread ([`set_background`]).
pub fn runs_in_background() -> bool {
background().is_some()
}
/// A running install: (locale, bytes done, bytes in all).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Progress {
pub locale: String,
pub done: u64,
pub total: u64,
}
/// The installs running now.
pub fn installing() -> Vec<Progress> {
jobs().installs.iter().map(|(l, d, t, _)| Progress { locale: l.clone(), done: *d, total: *t }).collect()
}
/// Whether the list is being fetched.
pub fn fetching() -> bool {
jobs().fetching
}
/// The last failure of `locale`'s install ("" for the list), until it is tried again.
pub fn error(locale: &str) -> Option<String> {
jobs().errors.iter().find(|(l, _)| l == locale).map(|(_, e)| e.clone())
}
/// Every recorded failure, (locale or "" for the list, message).
pub fn errors() -> Vec<(String, String)> {
jobs().errors.clone()
}
/// Counts finished jobs: when it changes, the window lays the documents out again.
pub fn finished() -> u64 {
jobs().finished
}
/// Stop `locale`'s running install.
pub fn cancel(locale: &str) {
if let Some((_, _, _, c)) = jobs().installs.iter().find(|(l, ..)| l == locale) {
c.cancel();
}
}
fn set_error(locale: &str, e: Option<String>) {
let mut j = jobs();
j.errors.retain(|(l, _)| l != locale);
if let Some(e) = e {
j.errors.push((locale.to_string(), e));
}
}
/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now.
/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once.
/// Returns whether it is still running (`false`: done, or it already was running).
pub fn start_install(locale: &str) -> Result<bool, String> {
let b = backend().ok_or_else(no_store)?;
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
if !b.network_on() {
return Err(network_off());
}
if let Some(dir) = b.packs_dir() {
crate::file_access::check_write(&dir.to_string_lossy())?;
}
let Some(notify) = background() else {
let r = install(locale, &Cancel::new(), &mut |_, _| {});
set_error(locale, r.as_ref().err().cloned());
return r.map(|_| false);
};
let cancel = Cancel::new();
{
let mut j = jobs();
if j.installs.iter().any(|(l, ..)| l == locale) {
return Ok(true);
}
j.installs.push((locale.to_string(), 0, pack.size, cancel.clone()));
j.errors.retain(|(l, _)| l != locale);
}
let key = locale.to_string();
let locale = locale.to_string();
let started = spawn(move || {
let progress_notify = notify.clone();
let r = install(&locale, &cancel, &mut |done, total| {
if let Some(job) = jobs().installs.iter_mut().find(|(l, ..)| *l == locale) {
job.1 = done;
job.2 = total;
}
progress_notify();
});
let mut j = jobs();
j.installs.retain(|(l, ..)| *l != locale);
if let Err(e) = r {
j.errors.push((locale, e));
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
});
if let Err(e) = started {
jobs().installs.retain(|(l, ..)| *l != key);
return Err(e);
}
Ok(true)
}
/// Start fetching the list again ([`refresh`]): on a thread when [`runs_in_background`], else now.
/// Returns whether it is still running.
pub fn start_refresh() -> Result<bool, String> {
let b = backend().ok_or_else(no_store)?;
if !b.network_on() {
return Err(network_off());
}
let Some(notify) = background() else {
let r = refresh(&Cancel::new());
set_error("", r.as_ref().err().cloned());
return r.map(|_| false);
};
{
let mut j = jobs();
if j.fetching {
return Ok(true);
}
j.fetching = true;
j.errors.retain(|(l, _)| !l.is_empty());
}
let started = spawn(move || {
let r = refresh(&Cancel::new());
let mut j = jobs();
j.fetching = false;
if let Err(e) = r {
j.errors.push((String::new(), e));
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
});
if let Err(e) = started {
jobs().fetching = false;
return Err(e);
}
Ok(true)
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(f: impl FnOnce() + Send + 'static) -> Result<(), String> {
std::thread::Builder::new().name("language-pack".into()).spawn(f).map(|_| ()).map_err(|e| format!("couldn't start the download: {e}"))
}
#[cfg(target_arch = "wasm32")]
fn spawn(_: impl FnOnce() + Send + 'static) -> Result<(), String> {
Err(no_store())
}
/// Have proofing look at the packs folder again (its dictionaries are reloaded).
fn reload(b: &Arc<dyn Backend>) {
if wordcraft_proof::packs::packs_dir() == b.packs_dir() {
wordcraft_proof::packs::reload();
} else {
wordcraft_proof::packs::set_packs_dir(b.packs_dir());
}
}
/// The full licence texts for About › Language data: the embedded English data, then each
/// installed pack.
pub fn licence_texts() -> Vec<(String, String)> {
let mut texts = wordcraft_proof::packs::licence_texts();
// SCOWL's notices (with the Ispell BSD licence of its affix files), when English spells from
// the embedded SCOWL data; after the Moby and supplement entries.
if let Some(notice) = wordcraft_proof::scowl::notice() {
let at = texts.len().min(2);
texts.insert(at, (SCOWL_TITLE.into(), notice.into()));
}
texts
}
/// About › Language data's title for SCOWL's notices.
pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)";
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
let base = tag.split('-').next().unwrap_or("");
if base.is_empty() || base == "en" {
return None;
}
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
}
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
pub fn document_languages(s: &Session) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) {
let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue };
if p.text.trim().is_empty() {
continue;
}
let style = p.props.style.as_deref();
for (range, c) in p.run_ranges() {
let Some(text) = p.text.get(range) else { continue };
if !text.chars().any(char::is_alphabetic) {
continue;
}
let rc = s.doc.styles.resolve_char_in(style, None, c);
if rc.no_proof {
continue;
}
let tag = rc.lang.unwrap_or_else(|| "en-US".into());
if !out.contains(&tag) {
out.push(tag);
if out.len() >= MAX_DOC_LANGUAGES {
return out;
}
}
}
}
out
}
/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own
/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a
/// malformed tag. The one place the bar's language rule lives.
pub fn proofable(tag: &str) -> bool {
wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other
}
/// The Hunspell dictionary `tag`'s language needs and doesn't have, for the status bar's
/// "(no dictionary)": craft-proof's [`wordcraft_proof::missing_dictionary`], except for a
/// language craft-proof has no rules of its own for (`ProofLang::Pack`: Japanese, Chinese,
/// Thai…) when no pack for it is available either, as no Hunspell dictionary may exist at all.
pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> {
let name = wordcraft_proof::missing_dictionary(tag)?;
let lang = wordcraft_proof::ProofLang::from_tag(tag);
if lang.pack_locale().is_some() && pack_for_tag(tag.unwrap_or(""), &available()).is_none() {
return None;
}
Some(name)
}
/// The packs the document's text asks for that are available and not installed: what the
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
pub fn missing(s: &Session) -> Vec<Pack> {
let packs = available();
if packs.is_empty() {
return Vec::new();
}
let installed = installed();
let mut out: Vec<Pack> = Vec::new();
for tag in document_languages(s) {
if proofable(&tag)
&& let Some(p) = pack_for_tag(&tag, &packs)
&& !installed.iter().any(|i| i.locale == p.locale)
&& !out.iter().any(|o| o.locale == p.locale)
{
out.push(p.clone());
}
}
out
}
/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`.
#[derive(Default)]
pub struct FixedBackend {
pub packs: Vec<Pack>,
pub network: AtomicBool,
/// Agents can't turn the network on.
pub agent: bool,
pub installed: Mutex<Vec<Installed>>,
}
impl Pack {
/// Where the data comes from: the upstream source when the index names it, else the archive.
pub fn source_link(&self) -> &str {
self.source.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.url)
}
}
impl FixedBackend {
/// A few packs shaped like the published ones, for screenshots and tests.
pub fn sample() -> Self {
let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack {
locale: locale.into(),
name: name.into(),
version: "0000000".into(),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
source: Some("https://github.com/LibreOffice/dictionaries".into()),
size,
licence: licence.into(),
class: class.into(),
spelling: true,
hyphenation: true,
};
FixedBackend {
packs: vec![
pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"),
pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"),
pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"),
pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"),
pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"),
pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"),
],
..Default::default()
}
}
}
impl Backend for FixedBackend {
fn index(&self) -> Result<Vec<Pack>, String> {
Ok(self.packs.clone())
}
fn fetch_index(&self, _: &Cancel) -> Result<Vec<Pack>, String> {
Ok(self.packs.clone())
}
fn network_on(&self) -> bool {
self.network.load(Ordering::Relaxed)
}
fn enable_network(&self) -> Result<(), String> {
if self.agent {
return Err("only the user can turn on downloading".into());
}
self.network.store(true, Ordering::Relaxed);
Ok(())
}
fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
if cancel.is_cancelled() {
return Err("cancelled".into());
}
progress(pack.size, pack.size);
let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner);
i.retain(|p| p.locale != pack.locale);
i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() });
Ok(())
}
fn remove(&self, locale: &str) -> Result<(), String> {
self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale);
Ok(())
}
fn packs_dir(&self) -> Option<PathBuf> {
None
}
fn installed(&self) -> Vec<Installed> {
self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
#[cfg(test)]
mod tests {
/// CP4.4 (`cargo xtask language`): a build embeds English Language data and nothing it
/// wasn't asked to. A Language build names the locales it embeds in `CRAFT_PROOF_EMBED`
/// (`en` alone: English only); the default embeds the carry-over locales too, until Language
/// packs are published and pinned (craft-proof's `DEFAULT_EMBED`; TODO(CP4.4): English only
/// then).
/// About › Language data shows SCOWL's notices (the Ispell BSD licence among them) whenever
/// English spells from the embedded SCOWL data.
#[test]
fn about_shows_the_scowl_notice_when_scowl_is_embedded() {
let texts = super::licence_texts();
match wordcraft_proof::scowl::notice() {
Some(notice) => {
let entry = texts.iter().find(|(t, _)| t == super::SCOWL_TITLE).map(|(_, x)| x.as_str());
assert_eq!(entry, Some(notice));
assert!(notice.contains("Ispell") || notice.contains("ispell"), "the Ispell notice is part of it");
}
None => assert!(texts.iter().all(|(t, _)| t != super::SCOWL_TITLE), "no SCOWL data: no SCOWL entry"),
}
}
#[test]
fn the_default_build_embeds_only_english() {
const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"];
let named: Vec<String> = std::env::var("CRAFT_PROOF_EMBED")
.unwrap_or_default()
.split(',')
.map(|l| l.trim().to_ascii_lowercase())
.filter(|l| !l.is_empty())
.collect();
let allowed = |base: &str| base == "en" || if named.is_empty() { CARRY_OVER.contains(&base) } else { named.iter().any(|n| n == base) };
let embedded = wordcraft_proof::packs::embedded_locales();
let extra: Vec<&&str> = embedded.iter().filter(|l| !allowed(l.split('-').next().unwrap_or(l))).collect();
assert!(extra.is_empty(), "embedded beyond English and {named:?} (or the carry-over by default): {extra:?}");
}
}