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:
Tizian 「ティツィアーン」 2026-10-15 21:55:00 +02:00
commit 1d2011a676
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
69 changed files with 5487 additions and 245 deletions

File diff suppressed because one or more lines are too long

View file

@ -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

View 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.

View file

@ -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>>(

View file

@ -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,
);
}
}

View file

@ -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']);
}

View file

@ -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/ ./

View file

@ -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`.

View file

@ -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>[];

View 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;
}

View file

@ -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:

View 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);
});
}

View 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.

View 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.

View 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.

View file

@ -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, '静か');
});
}

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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