feat(dictionary): Japanese cut into words and looked up on the device
Once the person agrees, the device holds MeCab's IPADIC and the
instance's JMdict and cuts lines as Jisho.org does, so their text never
leaves it (ADR 0012). MeCab comes through mecab_for_dart, whose build
hook compiles it; its tokens are grouped by Ve's rules, written anew in
Dart, with に kept a word of its own. Each word is found as it stands or
as a form of a word, else by its dictionary form, else as the longest
words it holds from token to token; longer words over token boundaries
are offered as before.
The curator packs Ubuntu's compiled IPADIC beside the dictionary files
(curate ipadic, and every update run). The server exports its JMdict
from its tables, so the device finds the same keys and keeps the same
versions, and serves both at /device-dictionaries/, named in
dictionary.deviceFiles('ja') with their hashes; Caddy forwards the path.
The app checks each file against its hash, unpacks IPADIC, builds a
SQLite database from the export on an isolate, and asks the instance
for newer files every 30 days, downloading only what changed. The Watch
screen offers the download once per device with its size (34 MB, some
150 MB once set up); Settings downloads and removes them. Without them,
or should cutting on the device fail, lines go to the instance as
before. MeCab's, IPADIC's and Ve's licences join the licence list.
Every build now needs a C++ compiler; mecab_for_dart holds code_assets
at 1.x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8fe2cf3436
commit
1d2011a676
69 changed files with 5487 additions and 245 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
# Caddy forwards to the API server, and the assistant connector's paths (ADR 0006) and
|
||||
# profile pages (ADR 0011) to the web server (8082), which serves nothing else. Insights (8081) doesn't run in
|
||||
# production; if it is ever re-enabled, reach it through an SSH tunnel, never through
|
||||
# this file.
|
||||
# Caddy forwards to the API server, and the assistant connector's paths (ADR 0006),
|
||||
# profile pages (ADR 0011) and the dictionaries devices download (ADR 0012) to the web
|
||||
# server (8082), which serves nothing else. Insights (8081) doesn't run in production;
|
||||
# if it is ever re-enabled, reach it through an SSH tunnel, never through this file.
|
||||
#
|
||||
# {$DOMAIN} is `learn.halkyon.eu` on its server, where Caddy obtains and renews a
|
||||
# Let's Encrypt certificate by itself, and `http://localhost` for the local rehearsal.
|
||||
|
|
@ -30,6 +30,12 @@
|
|||
reverse_proxy server:8082
|
||||
}
|
||||
|
||||
# The dictionaries devices download to look Japanese up by themselves, named by
|
||||
# their hashes; already compressed.
|
||||
handle /device-dictionaries/* {
|
||||
reverse_proxy server:8082
|
||||
}
|
||||
|
||||
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
|
||||
handle {
|
||||
reverse_proxy server:8080
|
||||
|
|
|
|||
95
docs/adr/0012-japanese-on-the-device.md
Normal file
95
docs/adr/0012-japanese-on-the-device.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# ADR 0012: Japanese cut into words and looked up on the device
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-15
|
||||
|
||||
## Context
|
||||
|
||||
Watching from Jellyfin cuts every line of subtitles and lyrics into words. Until now the
|
||||
app sent the text of each line, and of each stretch chosen by hand, to the instance,
|
||||
which answered from its `dictionary_key` table and kept nothing; the Jellyfin settings
|
||||
said so. Two things were wanting:
|
||||
|
||||
- **Privacy.** The text of what a person watches left the device, line by line.
|
||||
- **The cut.** The longest match knows no grammar: 今日は is read as こんにちは, and
|
||||
heuristics keep short kana from swallowing particles (が and まだ, not がま and だ).
|
||||
Jisho.org cuts sentences with MeCab and IPADIC, grouping MeCab's tokens into the words
|
||||
a learner looks up with Ve's rules. AGENTS.md had this planned for after the
|
||||
hackathon.
|
||||
|
||||
The developer decided (2026-10-15) to cut and look up on the device, with MeCab through
|
||||
`mecab_for_dart`, IPADIC downloaded from the instance at first use, and JMdict on the
|
||||
device too; to ask once, with the size, and to keep the instance's way as the fallback.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **MeCab** 0.996 through `mecab_for_dart` (BSD; MeCab itself is offered under the GPL,
|
||||
the LGPL or the BSD licence, and the app takes the BSD's). Its build hook compiles
|
||||
MeCab's C++ for each platform (`native_toolchain_c`), so every build needs a C++
|
||||
compiler, `flutter test` included: the snap's build has clang; CI's runner must have
|
||||
one. The package still wants `code_assets` 1.x, which moved `code_assets` from 2.1.0
|
||||
to 1.2.1, `native_toolchain_c` from 0.19.5 to 0.19.3 and `objective_c` from 9.6.0 to
|
||||
9.5.0. Not chosen: Sudachi and Kuromoji (JVM), Lindera (Rust; a larger library and
|
||||
the same dictionaries), a tokenizer in Dart (none mature).
|
||||
2. **IPADIC comes from the instance, not with the app.** Bundled, it would add 14 MB to
|
||||
every download of the app (51 MB once unpacked), for people who never watch in
|
||||
Japanese too. The curator packs Ubuntu's compiled `mecab-ipadic-utf8` (what MeCab
|
||||
0.996 reads) into `mecab-ipadic.tar.gz` beside the dictionary files: the files MeCab
|
||||
reads, IPADIC's licence and an empty `mecabrc`, the same bytes for the same
|
||||
dictionary, left as it is when unchanged (`curate ipadic`; `curate update` on every
|
||||
run). 13.6 MB to download.
|
||||
3. **JMdict as the instance holds it.** The server exports its JMdict with English
|
||||
meanings from its tables: each entry with its keys (`dictionary_key`'s), labels,
|
||||
rank, version and the entry as cards take it, as gzipped JSON lines with a header
|
||||
(`format` 1). From the tables rather than the curator's file, so that the device finds
|
||||
words by the same keys and keeps the same versions, and corrections still reach its
|
||||
cards. The server makes the export in its temporary folder as it starts and after
|
||||
each load, and serves it and the IPADIC pack on its web server at
|
||||
`/device-dictionaries/<name>-<first 16 hex digits of the hash>…` (immutable, so
|
||||
cached for good; Caddy forwards the path), only the files it has ready.
|
||||
`dictionary.deviceFiles('ja')` names them with their address, SHA-256 and size, once
|
||||
both are ready, none for other languages. 21.7 MB.
|
||||
4. **The device builds a database** from the export, streamed, on an isolate: the
|
||||
entries, and every key with its entry's rank and id, ordered as the instance orders
|
||||
them (rank, then id), so a key gives the same first eight entries
|
||||
(`DeviceDictionaryStore`). Some 8 seconds on a desktop, 101 MB.
|
||||
5. **The cut.** MeCab's tokens are grouped by Ve's rules (Kim Ahlström's Ve, MIT,
|
||||
written anew in Dart, `ve_words.dart`; one change: に stays a word of its own, as
|
||||
Jisho.org shows it). Each word is found as the longest match finds a stretch (as it
|
||||
stands, or as a form of a word whose class fits, `japaneseWordOf`), else by its
|
||||
dictionary form (静かな by 静か), else cut into the longest words it holds from token
|
||||
to token (二十五歳 into 二十, 五 and 歳). The longer words running over a word into its
|
||||
neighbours, from token to token, are offered as before (携帯 and 電話, which IPADIC
|
||||
cuts apart, both offer 携帯電話). A stretch chosen by hand is looked up in the device's
|
||||
database. Each batch of lines is cut on an isolate of its own, MeCab and the database
|
||||
opened anew (about 100 ms for a batch, which is the line shown, then a whole track).
|
||||
6. **Asked once, with the size.** The Watch screen offers the download once per device,
|
||||
for a dictionary the instance's export would serve, saying how much it downloads
|
||||
("34.0 MB, more once set up"); Not now is final there. Settings › Japanese on this
|
||||
device downloads them, says how much room they take and removes them. They belong to
|
||||
the device (`<app support>/dictionaries/`, a manifest of what is installed and whether
|
||||
the person was asked): the same for every person and instance, downloaded through the
|
||||
instance in use. Each file is checked against its SHA-256 and size, fetched over HTTPS
|
||||
(plain HTTP only to this machine), and the pack unpacks only the files MeCab reads.
|
||||
7. **Updates.** Once 30 days have passed, cutting lines asks the instance quietly for
|
||||
newer files, and only those whose hash changed are downloaded: IPADIC rarely, JMdict's
|
||||
export after the instance loaded a newer JMdict, so at most some 22 MB a month.
|
||||
8. **The instance stays the fallback.** Without the dictionaries (not downloaded, another
|
||||
language, another source), or should cutting on the device fail, lines go to the
|
||||
instance as before. Lines already cut are cut anew once the dictionaries arrive.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A device holding the dictionaries never sends the text of what is watched; the
|
||||
instance only learns that it downloaded them, and, monthly, that it asked for newer
|
||||
ones.
|
||||
- Japanese is cut with its grammar, as Jisho.org cuts it; the longest match's
|
||||
heuristics stay for the fallback.
|
||||
- A device spends 34 MB to download and some 150 MB of room; the offer says the first,
|
||||
Settings the second, and removing them is a tap.
|
||||
- Every build, CI's too, needs a C++ compiler.
|
||||
- Each instance keeps a 22 MB export in its temporary folder, made again after a
|
||||
restart, and its web server serves it (production runs one already, for the
|
||||
assistant's connector).
|
||||
- MeCab's, IPADIC's and Ve's licences join the app's licence list.
|
||||
- Other languages are still cut through the instance.
|
||||
|
|
@ -24,6 +24,8 @@ import 'package:halkyon_learn_client/src/protocol/auth/email_code_accepted.dart'
|
|||
as _ivlnmdt4;
|
||||
import 'package:halkyon_learn_client/src/protocol/auth/sign_in_ways.dart'
|
||||
as _ixy30i05;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/device_dictionary_file.dart'
|
||||
as _ijp9kwtx;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||
as _iqbqiphu;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart'
|
||||
|
|
@ -366,6 +368,18 @@ class EndpointDictionary extends _isc.EndpointRef {
|
|||
@override
|
||||
String get name => 'dictionary';
|
||||
|
||||
/// The files a device studying [language] downloads to cut its text into
|
||||
/// words and look them up by itself (ADR 0012): for Japanese MeCab's
|
||||
/// IPADIC and JMdict as this instance holds it; none while one of them is
|
||||
/// being made, and none for other languages.
|
||||
_ida.Future<List<_ijp9kwtx.DeviceDictionaryFile>> deviceFiles(
|
||||
String language,
|
||||
) => caller.callServerEndpoint<List<_ijp9kwtx.DeviceDictionaryFile>>(
|
||||
'dictionary',
|
||||
'deviceFiles',
|
||||
{'language': language},
|
||||
);
|
||||
|
||||
/// The dictionaries there are, with whose work they are.
|
||||
_ida.Future<List<_i8vj64sp.DictionarySource>> dictionaries() =>
|
||||
caller.callServerEndpoint<List<_i8vj64sp.DictionarySource>>(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// A file a device downloads to look Japanese up by itself (ADR 0012): MeCab's
|
||||
/// IPADIC, which cuts text into words, or a dictionary as the instance holds it.
|
||||
/// The app keeps one of each name and downloads it again when its hash changes.
|
||||
abstract class DeviceDictionaryFile
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
DeviceDictionaryFile._({
|
||||
required this.name,
|
||||
required this.url,
|
||||
required this.sha256,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
factory DeviceDictionaryFile({
|
||||
required String name,
|
||||
required String url,
|
||||
required String sha256,
|
||||
required int size,
|
||||
}) = _DeviceDictionaryFileImpl;
|
||||
|
||||
factory DeviceDictionaryFile.fromJson(
|
||||
Map<String, dynamic> jsonSerialization,
|
||||
) {
|
||||
return DeviceDictionaryFile(
|
||||
name: jsonSerialization['name'] as String,
|
||||
url: jsonSerialization['url'] as String,
|
||||
sha256: jsonSerialization['sha256'] as String,
|
||||
size: jsonSerialization['size'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// What it is: `mecab-ipadic`, or a dictionary by its source and meaning
|
||||
/// language (`jmdict-en`).
|
||||
String name;
|
||||
|
||||
/// Where to download it, on the instance's web server.
|
||||
String url;
|
||||
|
||||
/// Its SHA-256, in hex.
|
||||
String sha256;
|
||||
|
||||
/// Its size, in bytes.
|
||||
int size;
|
||||
|
||||
/// Returns a shallow copy of this [DeviceDictionaryFile]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
DeviceDictionaryFile copyWith({
|
||||
String? name,
|
||||
String? url,
|
||||
String? sha256,
|
||||
int? size,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'DeviceDictionaryFile',
|
||||
'name': name,
|
||||
'url': url,
|
||||
'sha256': sha256,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'DeviceDictionaryFile',
|
||||
'name': name,
|
||||
'url': url,
|
||||
'sha256': sha256,
|
||||
'size': size,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceDictionaryFileImpl extends DeviceDictionaryFile {
|
||||
_DeviceDictionaryFileImpl({
|
||||
required String name,
|
||||
required String url,
|
||||
required String sha256,
|
||||
required int size,
|
||||
}) : super._(
|
||||
name: name,
|
||||
url: url,
|
||||
sha256: sha256,
|
||||
size: size,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DeviceDictionaryFile]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
DeviceDictionaryFile copyWith({
|
||||
String? name,
|
||||
String? url,
|
||||
String? sha256,
|
||||
int? size,
|
||||
}) {
|
||||
return DeviceDictionaryFile(
|
||||
name: name ?? this.name,
|
||||
url: url ?? this.url,
|
||||
sha256: sha256 ?? this.sha256,
|
||||
size: size ?? this.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection
|
|||
as _iplrdti2;
|
||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||
as _ieqh4yr9;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/device_dictionary_file.dart'
|
||||
as _ijp9kwtx;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||
as _iqbqiphu;
|
||||
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_key_hit.dart'
|
||||
|
|
@ -49,6 +51,7 @@ import 'auth/sign_in_ways.dart' as _ie6tbh85;
|
|||
import 'card/card.dart' as _ipiw1cmx;
|
||||
import 'card/card_revision.dart' as _ipbyuef1;
|
||||
import 'common/not_found_exception.dart' as _i8oee73b;
|
||||
import 'dictionary/device_dictionary_file.dart' as _iexe4ymt;
|
||||
import 'dictionary/dictionary_credit.dart' as _ii4hopsr;
|
||||
import 'dictionary/dictionary_entry.dart' as _i4ipxv77;
|
||||
import 'dictionary/dictionary_hit.dart' as _ivbh47kc;
|
||||
|
|
@ -104,6 +107,7 @@ export 'auth/sign_in_ways.dart';
|
|||
export 'card/card.dart';
|
||||
export 'card/card_revision.dart';
|
||||
export 'common/not_found_exception.dart';
|
||||
export 'dictionary/device_dictionary_file.dart';
|
||||
export 'dictionary/dictionary_credit.dart';
|
||||
export 'dictionary/dictionary_entry.dart';
|
||||
export 'dictionary/dictionary_hit.dart';
|
||||
|
|
@ -2339,6 +2343,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _i8oee73b.NotFoundException) {
|
||||
return _i8oee73b.NotFoundException.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iexe4ymt.DeviceDictionaryFile) {
|
||||
return _iexe4ymt.DeviceDictionaryFile.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ii4hopsr.DictionaryCredit) {
|
||||
return _ii4hopsr.DictionaryCredit.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -2524,6 +2531,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_iexe4ymt.DeviceDictionaryFile?>()) {
|
||||
return (data != null
|
||||
? _iexe4ymt.DeviceDictionaryFile.fromJson(data)
|
||||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_ii4hopsr.DictionaryCredit?>()) {
|
||||
return (data != null ? _ii4hopsr.DictionaryCredit.fromJson(data) : null)
|
||||
as T;
|
||||
|
|
@ -2719,6 +2732,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_ijp9kwtx.DeviceDictionaryFile>) {
|
||||
return (data as List)
|
||||
.map((e) => deserialize<_ijp9kwtx.DeviceDictionaryFile>(e))
|
||||
.toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_i8vj64sp.DictionarySource>) {
|
||||
return (data as List)
|
||||
.map((e) => deserialize<_i8vj64sp.DictionarySource>(e))
|
||||
|
|
@ -2792,6 +2811,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
_ipiw1cmx.Card => 'Card',
|
||||
_ipbyuef1.CardRevision => 'CardRevision',
|
||||
_i8oee73b.NotFoundException => 'NotFoundException',
|
||||
_iexe4ymt.DeviceDictionaryFile => 'DeviceDictionaryFile',
|
||||
_ii4hopsr.DictionaryCredit => 'DictionaryCredit',
|
||||
_i4ipxv77.DictionaryEntry => 'DictionaryEntry',
|
||||
_ivbh47kc.DictionaryHit => 'DictionaryHit',
|
||||
|
|
@ -2880,6 +2900,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return 'CardRevision';
|
||||
case _i8oee73b.NotFoundException():
|
||||
return 'NotFoundException';
|
||||
case _iexe4ymt.DeviceDictionaryFile():
|
||||
return 'DeviceDictionaryFile';
|
||||
case _ii4hopsr.DictionaryCredit():
|
||||
return 'DictionaryCredit';
|
||||
case _i4ipxv77.DictionaryEntry():
|
||||
|
|
@ -3034,6 +3056,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'NotFoundException') {
|
||||
return deserialize<_i8oee73b.NotFoundException>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'DeviceDictionaryFile') {
|
||||
return deserialize<_iexe4ymt.DeviceDictionaryFile>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'DictionaryCredit') {
|
||||
return deserialize<_ii4hopsr.DictionaryCredit>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,15 @@ WORKDIR /app/halkyon_learn_curator
|
|||
RUN dart build cli --target bin/curate.dart --output build
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────────
|
||||
# Ubuntu 24.04 as the server's, with CA certificates for EDRDG's HTTPS.
|
||||
# Ubuntu 24.04 as the server's, with CA certificates for EDRDG's HTTPS, and MeCab's
|
||||
# IPADIC, which the package compiles as it is installed: the curator packs it for the
|
||||
# app's devices (ADR 0012). Its sources go once it is compiled.
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates mecab-ipadic-utf8 \
|
||||
&& test -s /var/lib/mecab/dic/ipadic-utf8/sys.dic \
|
||||
&& rm -rf /usr/share/mecab/dic/ipadic /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/halkyon_learn_curator/build/bundle/ ./
|
||||
|
|
|
|||
|
|
@ -236,6 +236,21 @@ when Wiktionary's Korean file is newer than the Hanja's. Files the curator wrote
|
|||
for a language no longer named, or for a source replaced (Korean's words from
|
||||
Wiktionary, before KRDict), are removed, and the server unloads them.
|
||||
|
||||
Each run also packs MeCab's IPADIC, where the image installed it (Ubuntu's
|
||||
`mecab-ipadic-utf8`, which the Dockerfile installs), into `mecab-ipadic.tar.gz`
|
||||
beside the files: the dictionary the app's devices download from their instance
|
||||
to cut Japanese into words themselves (ADR 0012). The same dictionary always
|
||||
packs to the same bytes, and a pack whose content is unchanged is left as it is
|
||||
(`.mecab-ipadic.tar.gz.sha256` keeps its hash), so devices download it again only
|
||||
when it changed. The server loads no file but dictionary files, so it leaves the
|
||||
pack be, and serves it. By hand, from any compiled IPADIC:
|
||||
|
||||
```sh
|
||||
dart run halkyon_learn_curator:curate ipadic \
|
||||
[--input /var/lib/mecab/dic/ipadic-utf8] \
|
||||
--output ../halkyon_learn_server/dictionaries
|
||||
```
|
||||
|
||||
## The format (`halkyon-dictionary`, version 1)
|
||||
|
||||
[JSON Lines](https://jsonlines.org/), gzipped when the name ends in `.gz`.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:args/args.dart';
|
||||
import 'package:halkyon_learn_curator/src/mecab.dart';
|
||||
import 'package:halkyon_learn_curator/src/sources/cedict.dart';
|
||||
import 'package:halkyon_learn_curator/src/sources/hangul_strokes.dart';
|
||||
import 'package:halkyon_learn_curator/src/sources/jlpt.dart';
|
||||
|
|
@ -52,6 +53,14 @@ import 'package:halkyon_learn_curator/src/wordfreq.dart';
|
|||
/// --input raw-wiktextract-data.jsonl.gz --languages de,el,en \
|
||||
/// --output ../halkyon_learn_server/dictionaries
|
||||
///
|
||||
/// MeCab's compiled IPADIC into the pack the app's devices download (ADR
|
||||
/// 0012), from Ubuntu's `mecab-ipadic-utf8` unless another folder is given
|
||||
/// (`update` packs it too, where that package is installed):
|
||||
///
|
||||
/// dart run halkyon_learn_curator:curate ipadic \
|
||||
/// [--input /var/lib/mecab/dic/ipadic-utf8] \
|
||||
/// --output ../halkyon_learn_server/dictionaries
|
||||
///
|
||||
/// CC-CEDICT into its two dictionary files, simplified and traditional:
|
||||
///
|
||||
/// dart run halkyon_learn_curator:curate cedict \
|
||||
|
|
@ -202,6 +211,26 @@ Future<void> main(List<String> args) async {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (source == 'ipadic') {
|
||||
final output = options.option('output');
|
||||
if (output == null) {
|
||||
_fail(
|
||||
'Usage: curate ipadic [--input <compiled IPADIC>] --output <folder>'
|
||||
'\n${parser.usage}',
|
||||
);
|
||||
}
|
||||
final packed = await packIpadic(
|
||||
Directory(options.option('input') ?? systemIpadic),
|
||||
Directory(output),
|
||||
licence: File(systemIpadicLicence),
|
||||
);
|
||||
stdout.writeln(
|
||||
packed == null
|
||||
? '$ipadicFile in $output is up to date.'
|
||||
: 'Wrote ${packed.path}.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (source == 'tatoeba') {
|
||||
final input = options.option('input');
|
||||
final output = options.option('output');
|
||||
|
|
@ -602,6 +631,18 @@ Future<void> _update(
|
|||
greek: greek,
|
||||
),
|
||||
);
|
||||
// MeCab's IPADIC for the devices, where the image installed it; packed
|
||||
// again only when it changed.
|
||||
Future<bool> ipadicOnce() async =>
|
||||
!Directory(systemIpadic).existsSync() ||
|
||||
await attempt('IPADIC', () async {
|
||||
final packed = await packIpadic(
|
||||
Directory(systemIpadic),
|
||||
into,
|
||||
licence: File(systemIpadicLicence),
|
||||
);
|
||||
return [?packed];
|
||||
});
|
||||
// In this order: Korean's words and Hanja take what Wiktionary kept.
|
||||
// What is no longer wanted goes last, once what replaces it is there.
|
||||
Future<bool> languagesOnce() async {
|
||||
|
|
@ -609,7 +650,8 @@ Future<void> _update(
|
|||
!(!await chineseOnce() |
|
||||
!await wiktionaryOnce() |
|
||||
!await koreanOnce() |
|
||||
!await charactersOnce());
|
||||
!await charactersOnce() |
|
||||
!await ipadicOnce());
|
||||
final removed = removesOthers
|
||||
? removeUnwanted(into, languages)
|
||||
: const <File>[];
|
||||
|
|
|
|||
77
halkyon_learn_curator/lib/src/mecab.dart
Normal file
77
halkyon_learn_curator/lib/src/mecab.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
// MeCab's IPADIC for the app's devices (ADR 0012): the app cuts Japanese into
|
||||
// words on the device, with MeCab, and downloads the dictionary it needs from
|
||||
// its instance once. The curator packs the dictionary MeCab compiled into one
|
||||
// archive beside the dictionary files, which the server serves as it is.
|
||||
|
||||
/// The packed dictionary's name in the dictionaries' folder; the server
|
||||
/// loads only dictionary files (`.jsonl`, `.jsonl.gz`), so it leaves it be.
|
||||
const ipadicFile = 'mecab-ipadic.tar.gz';
|
||||
|
||||
/// Where Ubuntu's `mecab-ipadic-utf8` package compiles IPADIC as it is
|
||||
/// installed (MeCab 0.996, the dictionary format the app's MeCab reads), as
|
||||
/// the curator's image installs it (its Dockerfile).
|
||||
const systemIpadic = '/var/lib/mecab/dic/ipadic-utf8';
|
||||
|
||||
/// Where that package keeps IPADIC's licence.
|
||||
const systemIpadicLicence = '/usr/share/doc/mecab-ipadic/copyright';
|
||||
|
||||
/// The files of a compiled MeCab dictionary that MeCab reads.
|
||||
const _dictionaryFiles = [
|
||||
'char.bin',
|
||||
'dicrc',
|
||||
'matrix.bin',
|
||||
'sys.dic',
|
||||
'unk.dic',
|
||||
];
|
||||
|
||||
/// Packs the compiled dictionary in [compiled] into [into] as [ipadicFile]:
|
||||
/// the files MeCab reads, IPADIC's [licence] as `COPYING` where it is given,
|
||||
/// and an empty `mecabrc` (the app's MeCab asks for one), as a gzipped tar.
|
||||
/// The same dictionary always packs to the same bytes (files in one order,
|
||||
/// no times), and a pack already there with the same content is left as it
|
||||
/// is, so devices download it again only when it changed. Returns the file
|
||||
/// written; null when it was there already. Throws a [FileSystemException]
|
||||
/// when a file MeCab reads is missing.
|
||||
Future<File?> packIpadic(
|
||||
Directory compiled,
|
||||
Directory into, {
|
||||
File? licence,
|
||||
}) async {
|
||||
final archive = Archive();
|
||||
void add(String name, Uint8List bytes) => archive.add(
|
||||
ArchiveFile.bytes(name, bytes)
|
||||
..lastModTime = 0
|
||||
..mode = 420, // rw-r--r--
|
||||
);
|
||||
for (final name in _dictionaryFiles) {
|
||||
add(name, await File('${compiled.path}/$name').readAsBytes());
|
||||
}
|
||||
if (licence != null && licence.existsSync()) {
|
||||
add('COPYING', await licence.readAsBytes());
|
||||
}
|
||||
add('mecabrc', Uint8List(0));
|
||||
|
||||
final tar = TarEncoder().encodeBytes(archive);
|
||||
final digest = sha256.convert(tar).toString();
|
||||
final mark = File('${into.path}/.$ipadicFile.sha256');
|
||||
final target = File('${into.path}/$ipadicFile');
|
||||
if (target.existsSync() &&
|
||||
mark.existsSync() &&
|
||||
(await mark.readAsString()).trim() == digest) {
|
||||
return null;
|
||||
}
|
||||
await into.create(recursive: true);
|
||||
// Beside its place first, then renamed over it: the server never serves
|
||||
// half of one.
|
||||
final part = File('${into.path}/.$ipadicFile.part');
|
||||
await part.writeAsBytes(const GZipEncoder().encodeBytes(tar));
|
||||
await part.rename(target.path);
|
||||
await mark.writeAsString(digest);
|
||||
return target;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ resolution: workspace
|
|||
dependencies:
|
||||
archive: ^4.0.7
|
||||
args: ^2.7.0
|
||||
crypto: ^3.0.7
|
||||
xml: ^7.0.1
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
86
halkyon_learn_curator/test/mecab_test.dart
Normal file
86
halkyon_learn_curator/test/mecab_test.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:halkyon_learn_curator/src/mecab.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory compiled;
|
||||
late Directory into;
|
||||
|
||||
setUp(() async {
|
||||
compiled = await Directory.systemTemp.createTemp('compiled');
|
||||
into = await Directory.systemTemp.createTemp('dictionaries');
|
||||
for (final name in [
|
||||
'char.bin',
|
||||
'dicrc',
|
||||
'matrix.bin',
|
||||
'sys.dic',
|
||||
'unk.dic',
|
||||
]) {
|
||||
await File('${compiled.path}/$name').writeAsString('$name of IPADIC');
|
||||
}
|
||||
});
|
||||
tearDown(() async {
|
||||
await compiled.delete(recursive: true);
|
||||
await into.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('packs the files MeCab reads, the licence and an empty mecabrc into '
|
||||
'one gzipped tar beside the dictionary files', () async {
|
||||
final licence = File('${compiled.path}/copyright')
|
||||
..writeAsStringSync('Copyright 2000, 2001, 2002, 2003 Nara Institute');
|
||||
|
||||
final packed = await packIpadic(compiled, into, licence: licence);
|
||||
|
||||
expect(packed?.path, '${into.path}/$ipadicFile');
|
||||
final files = {
|
||||
for (final file in TarDecoder().decodeBytes(
|
||||
const GZipDecoder().decodeBytes(packed!.readAsBytesSync()),
|
||||
))
|
||||
file.name: String.fromCharCodes(file.content),
|
||||
};
|
||||
expect(files.keys, {
|
||||
'char.bin',
|
||||
'dicrc',
|
||||
'matrix.bin',
|
||||
'sys.dic',
|
||||
'unk.dic',
|
||||
'COPYING',
|
||||
'mecabrc',
|
||||
});
|
||||
expect(files['sys.dic'], 'sys.dic of IPADIC');
|
||||
expect(files['COPYING'], startsWith('Copyright'));
|
||||
expect(files['mecabrc'], isEmpty);
|
||||
// Nothing the server would load as a dictionary.
|
||||
expect(
|
||||
into.listSync().where(
|
||||
(file) =>
|
||||
file.path.endsWith('.jsonl') || file.path.endsWith('.jsonl.gz'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves the pack as it is when the dictionary is the same, so devices '
|
||||
'download it again only once it changed', () async {
|
||||
final first = await packIpadic(compiled, into);
|
||||
final bytes = first!.readAsBytesSync();
|
||||
|
||||
expect(await packIpadic(compiled, into), isNull);
|
||||
expect(File('${into.path}/$ipadicFile').readAsBytesSync(), bytes);
|
||||
|
||||
await File('${compiled.path}/sys.dic').writeAsString('a newer IPADIC');
|
||||
expect(await packIpadic(compiled, into), isNotNull);
|
||||
});
|
||||
|
||||
test('refuses a dictionary missing a file MeCab reads', () async {
|
||||
await File('${compiled.path}/matrix.bin').delete();
|
||||
|
||||
await expectLater(
|
||||
packIpadic(compiled, into),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
expect(File('${into.path}/$ipadicFile').existsSync(), isFalse);
|
||||
});
|
||||
}
|
||||
73
halkyon_learn_flutter/assets/mecab/IPADIC.txt
Normal file
73
halkyon_learn_flutter/assets/mecab/IPADIC.txt
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
IPADIC, MeCab's dictionary, which the app downloads from its instance to cut
|
||||
Japanese into words on the device.
|
||||
|
||||
Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
|
||||
and Technology. All Rights Reserved.
|
||||
|
||||
Use, reproduction, and distribution of this software is permitted.
|
||||
Any copy of this software, whether in its original form or modified,
|
||||
must include both the above copyright notice and the following
|
||||
paragraphs.
|
||||
|
||||
Nara Institute of Science and Technology (NAIST),
|
||||
the copyright holders, disclaims all warranties with regard to this
|
||||
software, including all implied warranties of merchantability and
|
||||
fitness, in no event shall NAIST be liable for
|
||||
any special, indirect or consequential damages or any damages
|
||||
whatsoever resulting from loss of use, data or profits, whether in an
|
||||
action of contract, negligence or other tortuous action, arising out
|
||||
of or in connection with the use or performance of this software.
|
||||
|
||||
A large portion of the dictionary entries
|
||||
originate from ICOT Free Software. The following conditions for ICOT
|
||||
Free Software applies to the current dictionary as well.
|
||||
|
||||
Each User may also freely distribute the Program, whether in its
|
||||
original form or modified, to any third party or parties, PROVIDED
|
||||
that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
|
||||
on, or be attached to, the Program, which is distributed substantially
|
||||
in the same form as set out herein and that such intended
|
||||
distribution, if actually made, will neither violate or otherwise
|
||||
contravene any of the laws and regulations of the countries having
|
||||
jurisdiction over the User or the intended distribution itself.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
The program was produced on an experimental basis in the course of the
|
||||
research and development conducted during the project and is provided
|
||||
to users as so produced on an experimental basis. Accordingly, the
|
||||
program is provided without any warranty whatsoever, whether express,
|
||||
implied, statutory or otherwise. The term "warranty" used herein
|
||||
includes, but is not limited to, any warranty of the quality,
|
||||
performance, merchantability and fitness for a particular purpose of
|
||||
the program and the nonexistence of any infringement or violation of
|
||||
any right of any third party.
|
||||
|
||||
Each user of the program will agree and understand, and be deemed to
|
||||
have agreed and understood, that there is no warranty whatsoever for
|
||||
the program and, accordingly, the entire risk arising from or
|
||||
otherwise connected with the program is assumed by the user.
|
||||
|
||||
Therefore, neither ICOT, the copyright holder, or any other
|
||||
organization that participated in or was otherwise related to the
|
||||
development of the program and their respective officials, directors,
|
||||
officers and other employees shall be held liable for any and all
|
||||
damages, including, without limitation, general, special, incidental
|
||||
and consequential damages, arising out of or otherwise in connection
|
||||
with the use or inability to use the program or any product, material
|
||||
or result produced or otherwise obtained by using the program,
|
||||
regardless of whether they have been advised of, or otherwise had
|
||||
knowledge of, the possibility of such damages at any time during the
|
||||
project or thereafter. Each user will be deemed to have agreed to the
|
||||
foregoing by his or her commencement of use of the program. The term
|
||||
"use" as used herein includes, but is not limited to, the use,
|
||||
modification, copying and distribution of the program and the
|
||||
production of secondary products from the program.
|
||||
|
||||
In the case where the program, whether in its original form or
|
||||
modified, was distributed or delivered to or received by a user from
|
||||
any person, organization or entity other than ICOT, unless it makes or
|
||||
grants independently of ICOT any specific warranty to the user in
|
||||
writing, such person, organization or entity, will also be exempted
|
||||
from and not be held liable to the user for any such damages as noted
|
||||
above as far as the program is concerned.
|
||||
36
halkyon_learn_flutter/assets/mecab/MECAB.txt
Normal file
36
halkyon_learn_flutter/assets/mecab/MECAB.txt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
MeCab, Yet Another Part-of-Speech and Morphological Analyzer, which the app
|
||||
compiles in to cut Japanese into words on the device
|
||||
https://taku910.github.io/mecab/
|
||||
|
||||
MeCab is offered under the GPL, the LGPL or the BSD licence; Halkyon Learn
|
||||
uses it under the BSD licence:
|
||||
|
||||
Copyright (c) 2001-2008, Taku Kudo
|
||||
Copyright (c) 2004-2008, Nippon Telegraph and Telephone Corporation
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are
|
||||
permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above
|
||||
copyright notice, this list of conditions and the
|
||||
following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the
|
||||
following disclaimer in the documentation and/or other
|
||||
materials provided with the distribution.
|
||||
|
||||
* Neither the name of the Nippon Telegraph and Telegraph Corporation
|
||||
nor the names of its contributors may be used to endorse or
|
||||
promote products derived from this software without specific
|
||||
prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
|
||||
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
|
||||
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
24
halkyon_learn_flutter/assets/mecab/VE.txt
Normal file
24
halkyon_learn_flutter/assets/mecab/VE.txt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Ve, whose rules for grouping MeCab's tokens into words the app follows
|
||||
https://github.com/Kimtaro/ve
|
||||
|
||||
|
||||
The MIT License (MIT)
|
||||
Copyright © 2015 Kim Ahlström <kim.ahlstrom@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionary_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/japanese_analyser.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
// Japanese cut on the device for real (ADR 0012): MeCab as the package's
|
||||
// build hook compiles it for the platform, IPADIC as the curator packs it,
|
||||
// and JMdict as an instance exports it. On the desktop,
|
||||
//
|
||||
// flutter test integration_test/japanese_analyser_test.dart -d linux \
|
||||
// --dart-define=IPADIC_PACK=<mecab-ipadic.tar.gz> \
|
||||
// --dart-define=JMDICT_EXPORT=<jmdict-en-….jsonl.gz>
|
||||
//
|
||||
// with the files an instance serves at /device-dictionaries/ (both named in
|
||||
// `dictionary.deviceFiles('ja')`); the curator's `curate ipadic` writes the
|
||||
// pack.
|
||||
const _pack = String.fromEnvironment('IPADIC_PACK');
|
||||
const _export = String.fromEnvironment('JMDICT_EXPORT');
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory folder;
|
||||
|
||||
setUpAll(() async {
|
||||
folder = Directory.systemTemp.createTempSync('japanese');
|
||||
unpackIpadic(File(_pack), Directory('${folder.path}/ipadic'));
|
||||
await buildDeviceDictionary(
|
||||
File(_export),
|
||||
File('${folder.path}/jmdict.sqlite'),
|
||||
);
|
||||
});
|
||||
tearDownAll(() => folder.deleteSync(recursive: true));
|
||||
|
||||
String cut(List<SentencePart> parts) => [
|
||||
for (final part in parts)
|
||||
if (part.isWord) part.text else '(${part.text})',
|
||||
].join(' ');
|
||||
|
||||
testWidgets('cuts lines into words as Jisho.org does, and finds each in '
|
||||
'JMdict', (tester) async {
|
||||
final lines = await tester.runAsync(
|
||||
() => IsolateJapaneseAnalyser().wordsOf(
|
||||
ipadic: '${folder.path}/ipadic',
|
||||
database: '${folder.path}/jmdict.sqlite',
|
||||
sentences: ['今日はいい天気ですね。', 'お前がもうろくだよ', '勉強している', '静かな部屋'],
|
||||
),
|
||||
);
|
||||
|
||||
final [weather, senile, study, room] = lines!;
|
||||
expect(cut(weather), '今日 は いい 天気 です ね (。)');
|
||||
expect(weather.first.entries.first.headword, '今日');
|
||||
expect(cut(senile), 'お前 が もうろく だ よ');
|
||||
expect(senile[2].entries.first.headword, '耄碌');
|
||||
expect(study.single.entries.first.headword, '勉強');
|
||||
expect(study.single.steps, isNotEmpty);
|
||||
expect(cut(room), '静かな 部屋');
|
||||
expect(room.first.entries.first.headword, '静か');
|
||||
});
|
||||
}
|
||||
|
|
@ -1878,7 +1878,25 @@
|
|||
"examPlanDelete": "Plan löschen",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
"jellyfinSubtitle": "Schau Serien und Filme und hör Musik von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel und Liedtexte. Angemeldet bietet „Material hinzufügen“ eines Lernsets „Aus Jellyfin“ an.",
|
||||
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.",
|
||||
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht, außer die japanischen Wörterbücher sind auf diesem Gerät (Einstellungen): Dann schlägt die App sie hier nach. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.",
|
||||
"deviceDictionaryOfferTitle": "Wörter auf diesem Gerät nachschlagen?",
|
||||
"deviceDictionaryOfferBody": "Lade die japanischen Wörterbücher einmal herunter ({size}, eingerichtet mehr), um Zeilen hier in Wörter zu zerlegen, wie Jisho.org es tut. Ihr Text verlässt dieses Gerät dann nicht mehr.",
|
||||
"@deviceDictionaryOfferBody": {"placeholders": {"size": {"type": "String"}}},
|
||||
"deviceDictionaryOfferDownload": "Herunterladen",
|
||||
"deviceDictionaryOfferLater": "Nicht jetzt",
|
||||
"deviceDictionaryDownloading": "Wird heruntergeladen: {received} von {total}",
|
||||
"@deviceDictionaryDownloading": {"placeholders": {"received": {"type": "String"}, "total": {"type": "String"}}},
|
||||
"deviceDictionaryPreparing": "Wird eingerichtet…",
|
||||
"deviceDictionaryReady": "Wörter werden jetzt auf diesem Gerät nachgeschlagen.",
|
||||
"deviceDictionaryFailed": "Die Wörterbücher konnten nicht heruntergeladen werden. Versuch es in den Einstellungen noch einmal.",
|
||||
"settingsDeviceDictionaryTitle": "Japanisch auf diesem Gerät",
|
||||
"settingsDeviceDictionaryAbout": "Mit den japanischen Wörterbüchern auf diesem Gerät werden die Zeilen von Videos und Liedern hier in Wörter zerlegt, wie Jisho.org es tut, und ihr Text verlässt es nicht. Ohne sie fragt die App die Instanz, die antwortet und nichts speichert.",
|
||||
"settingsDeviceDictionaryHeld": "Auf diesem Gerät: {size}",
|
||||
"@settingsDeviceDictionaryHeld": {"placeholders": {"size": {"type": "String"}}},
|
||||
"settingsDeviceDictionaryDownload": "Herunterladen ({size})",
|
||||
"@settingsDeviceDictionaryDownload": {"placeholders": {"size": {"type": "String"}}},
|
||||
"settingsDeviceDictionaryRemove": "Entfernen",
|
||||
"settingsDeviceDictionaryFailed": "Das Herunterladen ist fehlgeschlagen.",
|
||||
"jellyfinSignedInAs": "{user} auf {server}",
|
||||
"jellyfinSignOut": "Von Jellyfin abmelden",
|
||||
"jellyfinAddress": "Serveradresse",
|
||||
|
|
|
|||
|
|
@ -2217,7 +2217,25 @@
|
|||
"examPlanDelete": "Delete plan",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
"jellyfinSubtitle": "Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset's Add material offers From Jellyfin.",
|
||||
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.",
|
||||
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep, unless Japanese's dictionaries are on this device (Settings): then it looks them up here. Words you add are tagged with where you met them, kept with your flashcards like any tag.",
|
||||
"deviceDictionaryOfferTitle": "Look words up on this device?",
|
||||
"deviceDictionaryOfferBody": "Download Japanese's dictionaries once ({size}, more once set up) to cut lines into words here, as Jisho.org does. Their text then never leaves this device.",
|
||||
"@deviceDictionaryOfferBody": {"placeholders": {"size": {"type": "String"}}},
|
||||
"deviceDictionaryOfferDownload": "Download",
|
||||
"deviceDictionaryOfferLater": "Not now",
|
||||
"deviceDictionaryDownloading": "Downloading: {received} of {total}",
|
||||
"@deviceDictionaryDownloading": {"placeholders": {"received": {"type": "String"}, "total": {"type": "String"}}},
|
||||
"deviceDictionaryPreparing": "Setting them up…",
|
||||
"deviceDictionaryReady": "Words are now looked up on this device.",
|
||||
"deviceDictionaryFailed": "Couldn't download the dictionaries. Try again in Settings.",
|
||||
"settingsDeviceDictionaryTitle": "Japanese on this device",
|
||||
"settingsDeviceDictionaryAbout": "With Japanese's dictionaries on this device, the lines of videos and songs are cut into words here, as Jisho.org does, and their text never leaves it. Without them, the app asks the instance, which answers and keeps nothing.",
|
||||
"settingsDeviceDictionaryHeld": "On this device: {size}",
|
||||
"@settingsDeviceDictionaryHeld": {"placeholders": {"size": {"type": "String"}}},
|
||||
"settingsDeviceDictionaryDownload": "Download ({size})",
|
||||
"@settingsDeviceDictionaryDownload": {"placeholders": {"size": {"type": "String"}}},
|
||||
"settingsDeviceDictionaryRemove": "Remove",
|
||||
"settingsDeviceDictionaryFailed": "The download failed.",
|
||||
"jellyfinSignedInAs": "{user} on {server}",
|
||||
"@jellyfinSignedInAs": {"placeholders": {"user": {"type": "String"}, "server": {"type": "String"}}},
|
||||
"jellyfinSignOut": "Sign out of Jellyfin",
|
||||
|
|
|
|||
|
|
@ -11112,9 +11112,93 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @jellyfinPrivacy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your server\'s address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.'**
|
||||
/// **'Your server\'s address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn\'t keep, unless Japanese\'s dictionaries are on this device (Settings): then it looks them up here. Words you add are tagged with where you met them, kept with your flashcards like any tag.'**
|
||||
String get jellyfinPrivacy;
|
||||
|
||||
/// No description provided for @deviceDictionaryOfferTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Look words up on this device?'**
|
||||
String get deviceDictionaryOfferTitle;
|
||||
|
||||
/// No description provided for @deviceDictionaryOfferBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download Japanese\'s dictionaries once ({size}, more once set up) to cut lines into words here, as Jisho.org does. Their text then never leaves this device.'**
|
||||
String deviceDictionaryOfferBody(String size);
|
||||
|
||||
/// No description provided for @deviceDictionaryOfferDownload.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get deviceDictionaryOfferDownload;
|
||||
|
||||
/// No description provided for @deviceDictionaryOfferLater.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get deviceDictionaryOfferLater;
|
||||
|
||||
/// No description provided for @deviceDictionaryDownloading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloading: {received} of {total}'**
|
||||
String deviceDictionaryDownloading(String received, String total);
|
||||
|
||||
/// No description provided for @deviceDictionaryPreparing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Setting them up…'**
|
||||
String get deviceDictionaryPreparing;
|
||||
|
||||
/// No description provided for @deviceDictionaryReady.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Words are now looked up on this device.'**
|
||||
String get deviceDictionaryReady;
|
||||
|
||||
/// No description provided for @deviceDictionaryFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t download the dictionaries. Try again in Settings.'**
|
||||
String get deviceDictionaryFailed;
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Japanese on this device'**
|
||||
String get settingsDeviceDictionaryTitle;
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryAbout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'With Japanese\'s dictionaries on this device, the lines of videos and songs are cut into words here, as Jisho.org does, and their text never leaves it. Without them, the app asks the instance, which answers and keeps nothing.'**
|
||||
String get settingsDeviceDictionaryAbout;
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryHeld.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On this device: {size}'**
|
||||
String settingsDeviceDictionaryHeld(String size);
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryDownload.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download ({size})'**
|
||||
String settingsDeviceDictionaryDownload(String size);
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get settingsDeviceDictionaryRemove;
|
||||
|
||||
/// No description provided for @settingsDeviceDictionaryFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The download failed.'**
|
||||
String get settingsDeviceDictionaryFailed;
|
||||
|
||||
/// No description provided for @jellyfinSignedInAs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -7064,7 +7064,62 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get jellyfinPrivacy =>
|
||||
'Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.';
|
||||
'Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht, außer die japanischen Wörterbücher sind auf diesem Gerät (Einstellungen): Dann schlägt die App sie hier nach. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.';
|
||||
|
||||
@override
|
||||
String get deviceDictionaryOfferTitle =>
|
||||
'Wörter auf diesem Gerät nachschlagen?';
|
||||
|
||||
@override
|
||||
String deviceDictionaryOfferBody(String size) {
|
||||
return 'Lade die japanischen Wörterbücher einmal herunter ($size, eingerichtet mehr), um Zeilen hier in Wörter zu zerlegen, wie Jisho.org es tut. Ihr Text verlässt dieses Gerät dann nicht mehr.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deviceDictionaryOfferDownload => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get deviceDictionaryOfferLater => 'Nicht jetzt';
|
||||
|
||||
@override
|
||||
String deviceDictionaryDownloading(String received, String total) {
|
||||
return 'Wird heruntergeladen: $received von $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get deviceDictionaryPreparing => 'Wird eingerichtet…';
|
||||
|
||||
@override
|
||||
String get deviceDictionaryReady =>
|
||||
'Wörter werden jetzt auf diesem Gerät nachgeschlagen.';
|
||||
|
||||
@override
|
||||
String get deviceDictionaryFailed =>
|
||||
'Die Wörterbücher konnten nicht heruntergeladen werden. Versuch es in den Einstellungen noch einmal.';
|
||||
|
||||
@override
|
||||
String get settingsDeviceDictionaryTitle => 'Japanisch auf diesem Gerät';
|
||||
|
||||
@override
|
||||
String get settingsDeviceDictionaryAbout =>
|
||||
'Mit den japanischen Wörterbüchern auf diesem Gerät werden die Zeilen von Videos und Liedern hier in Wörter zerlegt, wie Jisho.org es tut, und ihr Text verlässt es nicht. Ohne sie fragt die App die Instanz, die antwortet und nichts speichert.';
|
||||
|
||||
@override
|
||||
String settingsDeviceDictionaryHeld(String size) {
|
||||
return 'Auf diesem Gerät: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String settingsDeviceDictionaryDownload(String size) {
|
||||
return 'Herunterladen ($size)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsDeviceDictionaryRemove => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get settingsDeviceDictionaryFailed =>
|
||||
'Das Herunterladen ist fehlgeschlagen.';
|
||||
|
||||
@override
|
||||
String jellyfinSignedInAs(String user, String server) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue