feat(dictionary): a German verb is found with its particle at the clause's end
"Ich rufe dich morgen an": where a clause ends in a separable particle, each word before it is also asked for with the particle joined on, as a subordinate clause writes the verb (anrufe), which Wiktionary lists among anrufen's forms. The verb is offered beside its stem and its particle, as the longer words running over a Japanese word are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
87bebd1953
commit
93c33ce079
4 changed files with 168 additions and 4 deletions
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
|
||||||
|
|
||||||
|
/// The particles German separates from their verbs, which end a main
|
||||||
|
/// clause (`Ich rufe dich morgen an`: anrufen).
|
||||||
|
const _particles = {
|
||||||
|
'ab', 'an', 'auf', 'aus', 'bei', 'dabei', 'dar', 'davon', 'ein', //
|
||||||
|
'empor', 'entgegen', 'entlang', 'fest', 'fort', 'frei', 'gegenüber', //
|
||||||
|
'heim', 'her', 'herab', 'heran', 'herauf', 'heraus', 'herbei', //
|
||||||
|
'herein', 'herüber', 'herum', 'herunter', 'hervor', 'hin', 'hinab', //
|
||||||
|
'hinauf', 'hinaus', 'hinein', 'hinüber', 'hinunter', 'hinzu', 'kennen', //
|
||||||
|
'los', 'mit', 'nach', 'nieder', 'statt', 'teil', 'um', 'vor', 'voran', //
|
||||||
|
'voraus', 'vorbei', 'vorüber', 'weg', 'weiter', 'wieder', 'zu', //
|
||||||
|
'zurecht', 'zurück', 'zusammen',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A word as German text writes it, and where a clause ends.
|
||||||
|
final _word = RegExp(r"[\p{L}\p{M}]+(?:['’\-][\p{L}\p{M}]+)*", unicode: true);
|
||||||
|
final _clauseEnd = RegExp('[.,;:!?…–—()"„“”«»]');
|
||||||
|
|
||||||
|
/// Each clause of [sentence] that ends in a separable particle: its words
|
||||||
|
/// before it (from where they start), and the particle with where it
|
||||||
|
/// starts.
|
||||||
|
Iterable<({List<Match> before, Match particle})> _separated(
|
||||||
|
String sentence,
|
||||||
|
) sync* {
|
||||||
|
var from = 0;
|
||||||
|
for (final end in [
|
||||||
|
for (final match in _clauseEnd.allMatches(sentence)) match.start,
|
||||||
|
sentence.length,
|
||||||
|
]) {
|
||||||
|
final words = _word.allMatches(sentence.substring(from, end)).toList();
|
||||||
|
final offset = from;
|
||||||
|
from = end + 1;
|
||||||
|
if (words.length < 2 ||
|
||||||
|
!_particles.contains(words.last[0]!.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The matches are of the clause alone: moved to the sentence's places.
|
||||||
|
final inSentence = [
|
||||||
|
for (final word in words)
|
||||||
|
_word.matchAsPrefix(sentence, offset + word.start)!,
|
||||||
|
];
|
||||||
|
yield (
|
||||||
|
before: inSentence.sublist(0, inSentence.length - 1),
|
||||||
|
particle: inSentence.last,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key of a separated verb's form, its particle joined before it as a
|
||||||
|
/// subordinate clause writes it (`rufe` … `an`: `anrufe`), which the
|
||||||
|
/// dictionary has among the verb's forms.
|
||||||
|
String _joined(Match particle, Match verb) =>
|
||||||
|
'${particle[0]!}${verb[0]!}'.toLowerCase();
|
||||||
|
|
||||||
|
/// Every key a dictionary is asked for to cut German [sentence]: each word
|
||||||
|
/// as it stands ([spacedSentenceKeys]), and where a clause ends in a
|
||||||
|
/// separable particle, each word before it with the particle joined on.
|
||||||
|
Set<String> germanSentenceKeys(String sentence) => {
|
||||||
|
...spacedSentenceKeys(sentence),
|
||||||
|
for (final (:before, :particle) in _separated(sentence))
|
||||||
|
for (final verb in before) _joined(particle, verb),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// German [sentence] cut into its words ([spacedSentenceParts]), a
|
||||||
|
/// separated verb (`rufe` … `an`) offered as the verb it is a form of
|
||||||
|
/// (anrufen) beside its stem and its particle, as the longer words running
|
||||||
|
/// over a word are ([SentencePart.wider]).
|
||||||
|
List<SentencePart> germanSentenceParts(
|
||||||
|
String sentence,
|
||||||
|
Map<String, List<DictionaryItem>> found,
|
||||||
|
) {
|
||||||
|
// Where each separated verb's stem and particle start, with the verb.
|
||||||
|
final verbs = <int, List<SentencePart>>{};
|
||||||
|
for (final (:before, :particle) in _separated(sentence)) {
|
||||||
|
for (final stem in before) {
|
||||||
|
final entries = [
|
||||||
|
for (final item in found[_joined(particle, stem)] ?? const [])
|
||||||
|
if (item is DictionaryWord) item,
|
||||||
|
];
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final verb = SentencePart(
|
||||||
|
text: '${stem[0]!} … ${particle[0]!}',
|
||||||
|
start: stem.start,
|
||||||
|
entries: entries,
|
||||||
|
);
|
||||||
|
(verbs[stem.start] ??= []).add(verb);
|
||||||
|
(verbs[particle.start] ??= []).add(verb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final part in spacedSentenceParts(sentence, found))
|
||||||
|
if (verbs[part.start] case final wider? when part.isWord)
|
||||||
|
SentencePart(
|
||||||
|
text: part.text,
|
||||||
|
start: part.start,
|
||||||
|
entries: part.entries,
|
||||||
|
steps: part.steps,
|
||||||
|
wider: [...wider, ...part.wider].take(widerWords).toList(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
part,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/chinese_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/chinese_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/german_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/korean_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/korean_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
|
||||||
|
|
@ -11,11 +12,15 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.
|
||||||
/// `sentence_words.dart`; Chinese, which doesn't inflect,
|
/// `sentence_words.dart`; Chinese, which doesn't inflect,
|
||||||
/// `chinese_words.dart`; decided 2026-10-14). Korean is cut at its spaces,
|
/// `chinese_words.dart`; decided 2026-10-14). Korean is cut at its spaces,
|
||||||
/// its particles taken off a word the dictionary lacks as it stands
|
/// its particles taken off a word the dictionary lacks as it stands
|
||||||
/// (`korean_words.dart`).
|
/// (`korean_words.dart`); German at its spaces, a verb whose particle ends
|
||||||
|
/// the clause found with it (`german_words.dart`).
|
||||||
enum WordCutting() {
|
enum WordCutting() {
|
||||||
/// At the spaces, each word looked up as it stands.
|
/// At the spaces, each word looked up as it stands.
|
||||||
spaces,
|
spaces,
|
||||||
|
|
||||||
|
/// At the spaces, a verb's separated particle joined on (German).
|
||||||
|
spacesGerman,
|
||||||
|
|
||||||
/// At the spaces, a word's particles taken off where it needs (Korean).
|
/// At the spaces, a word's particles taken off where it needs (Korean).
|
||||||
spacesKorean,
|
spacesKorean,
|
||||||
|
|
||||||
|
|
@ -31,12 +36,14 @@ enum WordCutting() {
|
||||||
'ja' => longestJapanese,
|
'ja' => longestJapanese,
|
||||||
'zh' || 'yue' => longestChinese,
|
'zh' || 'yue' => longestChinese,
|
||||||
'ko' => spacesKorean,
|
'ko' => spacesKorean,
|
||||||
|
'de' => spacesGerman,
|
||||||
_ => spaces,
|
_ => spaces,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The keys a dictionary is asked for to cut [sentence] into words.
|
/// The keys a dictionary is asked for to cut [sentence] into words.
|
||||||
Set<String> keysOf(String sentence) => switch (this) {
|
Set<String> keysOf(String sentence) => switch (this) {
|
||||||
spaces => spacedSentenceKeys(sentence),
|
spaces => spacedSentenceKeys(sentence),
|
||||||
|
spacesGerman => germanSentenceKeys(sentence),
|
||||||
spacesKorean => koreanSentenceKeys(sentence),
|
spacesKorean => koreanSentenceKeys(sentence),
|
||||||
longestJapanese => sentenceKeys(sentence),
|
longestJapanese => sentenceKeys(sentence),
|
||||||
longestChinese => chineseSentenceKeys(sentence),
|
longestChinese => chineseSentenceKeys(sentence),
|
||||||
|
|
@ -48,6 +55,7 @@ enum WordCutting() {
|
||||||
Map<String, List<DictionaryItem>> found,
|
Map<String, List<DictionaryItem>> found,
|
||||||
) => switch (this) {
|
) => switch (this) {
|
||||||
spaces => spacedSentenceParts(sentence, found),
|
spaces => spacedSentenceParts(sentence, found),
|
||||||
|
spacesGerman => germanSentenceParts(sentence, found),
|
||||||
spacesKorean => koreanSentenceParts(sentence, found),
|
spacesKorean => koreanSentenceParts(sentence, found),
|
||||||
longestJapanese => sentenceParts(sentence, found),
|
longestJapanese => sentenceParts(sentence, found),
|
||||||
longestChinese => chineseSentenceParts(sentence, found),
|
longestChinese => chineseSentenceParts(sentence, found),
|
||||||
|
|
@ -56,7 +64,7 @@ enum WordCutting() {
|
||||||
/// The keys a dictionary is asked for to look up [text], a stretch of a
|
/// The keys a dictionary is asked for to look up [text], a stretch of a
|
||||||
/// line chosen by hand, as one word.
|
/// line chosen by hand, as one word.
|
||||||
Set<String> stretchKeysOf(String text) => switch (this) {
|
Set<String> stretchKeysOf(String text) => switch (this) {
|
||||||
spaces => spacedStretchKeys(text),
|
spaces || spacesGerman => spacedStretchKeys(text),
|
||||||
spacesKorean => koreanStretchKeys(text),
|
spacesKorean => koreanStretchKeys(text),
|
||||||
longestJapanese => stretchKeys(text),
|
longestJapanese => stretchKeys(text),
|
||||||
longestChinese => chineseStretchKeys(text),
|
longestChinese => chineseStretchKeys(text),
|
||||||
|
|
@ -69,7 +77,7 @@ enum WordCutting() {
|
||||||
int start,
|
int start,
|
||||||
Map<String, List<DictionaryItem>> found,
|
Map<String, List<DictionaryItem>> found,
|
||||||
) => switch (this) {
|
) => switch (this) {
|
||||||
spaces => spacedStretchPart(text, start, found),
|
spaces || spacesGerman => spacedStretchPart(text, start, found),
|
||||||
spacesKorean => koreanStretchPart(text, start, found),
|
spacesKorean => koreanStretchPart(text, start, found),
|
||||||
longestJapanese => stretchPart(text, start, found),
|
longestJapanese => stretchPart(text, start, found),
|
||||||
longestChinese => chineseStretchPart(text, start, found),
|
longestChinese => chineseStretchPart(text, start, found),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/german_words.dart';
|
||||||
|
|
||||||
|
import '../../support/fake_dictionary_gateway.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final anrufen = germanWord(
|
||||||
|
'anrufen',
|
||||||
|
pos: 'verb',
|
||||||
|
glosses: ['to call'],
|
||||||
|
forms: ['anrufe', 'anruft', 'anrief'],
|
||||||
|
);
|
||||||
|
final rufen = germanWord('rufen', pos: 'verb', glosses: ['to shout']);
|
||||||
|
final an = germanWord('an', pos: 'preposition', glosses: ['at']);
|
||||||
|
|
||||||
|
test('a clause ending in a separable particle asks for each word before '
|
||||||
|
'it with the particle joined on, as subordinate clauses write it', () {
|
||||||
|
final keys = germanSentenceKeys('Ich rufe dich morgen an.');
|
||||||
|
|
||||||
|
expect(keys, containsAll(['ich', 'rufe', 'an', 'anrufe', 'anich']));
|
||||||
|
// Only where a particle ends the clause.
|
||||||
|
expect(germanSentenceKeys('Ich rufe dich.'), isNot(contains('anrufe')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the separated verb is offered beside its stem and its particle; '
|
||||||
|
'clauses each on their own', () {
|
||||||
|
final found = <String, List<DictionaryItem>>{
|
||||||
|
'rufe': [rufen],
|
||||||
|
'an': [an],
|
||||||
|
'anrufe': [anrufen],
|
||||||
|
};
|
||||||
|
|
||||||
|
final parts = germanSentenceParts(
|
||||||
|
'Ich rufe dich morgen an, dann gehe ich.',
|
||||||
|
found,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stem = parts.singleWhere((part) => part.text == 'rufe');
|
||||||
|
expect(stem.entries, [rufen]);
|
||||||
|
expect(stem.wider.single.entries, [anrufen]);
|
||||||
|
expect(stem.wider.single.text, 'rufe … an');
|
||||||
|
final particle = parts.singleWhere((part) => part.text == 'an');
|
||||||
|
expect(particle.wider.single.entries, [anrufen]);
|
||||||
|
// The particle's place in the line is where it stands.
|
||||||
|
expect(particle.start, 'Ich rufe dich morgen '.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ void main() {
|
||||||
|
|
||||||
test('Japanese is cut by its longest words, the rest at their spaces', () {
|
test('Japanese is cut by its longest words, the rest at their spaces', () {
|
||||||
expect(WordCutting.of('ja'), WordCutting.longestJapanese);
|
expect(WordCutting.of('ja'), WordCutting.longestJapanese);
|
||||||
expect(WordCutting.of('de'), WordCutting.spaces);
|
expect(WordCutting.of('de'), WordCutting.spacesGerman);
|
||||||
expect(WordCutting.of('el'), WordCutting.spaces);
|
expect(WordCutting.of('el'), WordCutting.spaces);
|
||||||
expect(WordCutting.of('en-GB'), WordCutting.spaces);
|
expect(WordCutting.of('en-GB'), WordCutting.spaces);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue