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/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/sentence_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,
|
||||
/// `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
|
||||
/// (`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() {
|
||||
/// At the spaces, each word looked up as it stands.
|
||||
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).
|
||||
spacesKorean,
|
||||
|
||||
|
|
@ -31,12 +36,14 @@ enum WordCutting() {
|
|||
'ja' => longestJapanese,
|
||||
'zh' || 'yue' => longestChinese,
|
||||
'ko' => spacesKorean,
|
||||
'de' => spacesGerman,
|
||||
_ => spaces,
|
||||
};
|
||||
|
||||
/// The keys a dictionary is asked for to cut [sentence] into words.
|
||||
Set<String> keysOf(String sentence) => switch (this) {
|
||||
spaces => spacedSentenceKeys(sentence),
|
||||
spacesGerman => germanSentenceKeys(sentence),
|
||||
spacesKorean => koreanSentenceKeys(sentence),
|
||||
longestJapanese => sentenceKeys(sentence),
|
||||
longestChinese => chineseSentenceKeys(sentence),
|
||||
|
|
@ -48,6 +55,7 @@ enum WordCutting() {
|
|||
Map<String, List<DictionaryItem>> found,
|
||||
) => switch (this) {
|
||||
spaces => spacedSentenceParts(sentence, found),
|
||||
spacesGerman => germanSentenceParts(sentence, found),
|
||||
spacesKorean => koreanSentenceParts(sentence, found),
|
||||
longestJapanese => sentenceParts(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
|
||||
/// line chosen by hand, as one word.
|
||||
Set<String> stretchKeysOf(String text) => switch (this) {
|
||||
spaces => spacedStretchKeys(text),
|
||||
spaces || spacesGerman => spacedStretchKeys(text),
|
||||
spacesKorean => koreanStretchKeys(text),
|
||||
longestJapanese => stretchKeys(text),
|
||||
longestChinese => chineseStretchKeys(text),
|
||||
|
|
@ -69,7 +77,7 @@ enum WordCutting() {
|
|||
int start,
|
||||
Map<String, List<DictionaryItem>> found,
|
||||
) => switch (this) {
|
||||
spaces => spacedStretchPart(text, start, found),
|
||||
spaces || spacesGerman => spacedStretchPart(text, start, found),
|
||||
spacesKorean => koreanStretchPart(text, start, found),
|
||||
longestJapanese => stretchPart(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', () {
|
||||
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('en-GB'), WordCutting.spaces);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue