Skip to content

Commit ec29598

Browse files
pajomaclaude
andcommitted
feat(match-input): locale-aware weekday/month vocab (English + configured locale only)
weekdayVocab() and monthVocab() now return English entries plus the entries for the user's configured locale only (primary subtag matched). A German user sees 'so'/'di'/'do' etc; a Dutch user sees 'zo'/'vr'/'za'; neither bleeds into the other. Adds 10 locale-isolation regression tests. Updates input.test.ts German positive-control tests to pass locale='de' explicitly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5968e72 commit ec29598

3 files changed

Lines changed: 139 additions & 51 deletions

File tree

src/journal/match-input.ts

Lines changed: 87 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -498,59 +498,100 @@ export class MatchInput {
498498
// The pattern cache (_weekdayPatterns / _monthPatterns) wraps each entry with
499499
// ^(?:entry)(?=\s|$) for the recognizer scan.
500500

501+
private primaryLocale(): string {
502+
return this.locale.toLowerCase().split(/[-_]/)[0];
503+
}
504+
501505
private weekdayVocab(): string[] {
502-
return [
503-
// English
506+
const english = [
504507
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
505508
'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun',
506-
// German
507-
'montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag',
508-
'mit', 'di', 'do', 'fr', 'sa', 'so',
509-
// French
510-
'lun(?:di)?', 'mar(?:di)?', 'mer(?:credi)?', 'jeu(?:di)?', 'ven(?:dredi)?', 'sam(?:edi)?', 'dim(?:anche)?',
511-
// Spanish
512-
'lunes?', 'martes?', 'mié(?:rcoles)?', 'jueves?', 'viernes?', 'sáb(?:ado)?', 'dom(?:ingo)?',
513-
// Italian
514-
'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
515-
// Portuguese
516-
'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado', 'domingo',
517-
// Dutch
518-
'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag',
519-
// Russian
520-
'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье',
521-
// Chinese (Pinyin)
522-
'xīngqī yī', 'xīngqī èr', 'xīngqī sān', 'xīngqī sì', 'xīngqī wǔ', 'xīngqī liù', 'xīngqī rì',
523-
// Japanese (Romaji)
524-
'getsuyōbi', 'kayōbi', 'suiyōbi', 'mokuyōbi', "kin'yōbi", 'doyōbi', 'nichiyōbi',
525-
// Arabic
526-
'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت', 'الأحد',
527509
];
510+
const byLocale: Record<string, string[]> = {
511+
'de': [
512+
'montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag',
513+
'mit', 'di', 'do', 'fr', 'sa', 'so',
514+
],
515+
'fr': [
516+
'lun(?:di)?', 'mar(?:di)?', 'mer(?:credi)?', 'jeu(?:di)?', 'ven(?:dredi)?', 'sam(?:edi)?', 'dim(?:anche)?',
517+
],
518+
'es': [
519+
'lunes?', 'martes?', 'mié(?:rcoles)?', 'jueves?', 'viernes?', 'sáb(?:ado)?', 'dom(?:ingo)?',
520+
],
521+
'it': [
522+
'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
523+
],
524+
'pt': [
525+
'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado', 'domingo',
526+
],
527+
'nl': [
528+
'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag',
529+
'vr', 'za', 'zo',
530+
],
531+
'ru': [
532+
'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье',
533+
],
534+
'zh': [
535+
'xīngqī yī', 'xīngqī èr', 'xīngqī sān', 'xīngqī sì', 'xīngqī wǔ', 'xīngqī liù', 'xīngqī rì',
536+
],
537+
'ja': [
538+
'getsuyōbi', 'kayōbi', 'suiyōbi', 'mokuyōbi', "kin'yōbi", 'doyōbi', 'nichiyōbi',
539+
],
540+
'ar': [
541+
'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت', 'الأحد',
542+
],
543+
};
544+
return [...english, ...(byLocale[this.primaryLocale()] ?? [])];
528545
}
529546

530547
private monthVocab(): string[] {
531-
return [
532-
// English
533-
'Jan(?:uary)?', 'Feb(?:ruary)?', 'Mar(?:ch)?', 'Apr(?:il)?', 'May', 'June?', 'July?', 'Aug(?:ust)?', 'Sep(?:tember)?', 'Oct(?:ober)?', 'Nov(?:ember)?', 'Dec(?:ember)?',
534-
// German
535-
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'Okt(?:ober)?', 'Dez(?:ember)?',
536-
// French
537-
'Janv(?:ier)?', 'Fév(?:rier)?', 'Mars', 'Avr(?:il)?', 'Juin', 'Juil(?:let)?', 'Août', 'Sept(?:embre)?', 'Oct(?:obre)?', 'Nov(?:embre)?', 'Déc(?:embre)?',
538-
// Spanish
539-
'Ene(?:ro)?', 'Feb(?:rero)?', 'Mar(?:zo)?', 'Abr(?:il)?', 'May(?:o)?', 'Jun(?:io)?', 'Jul(?:io)?', 'Ago(?:sto)?', 'Sep(?:tiembre)?', 'Oct(?:ubre)?', 'Nov(?:iembre)?', 'Dic(?:iembre)?',
540-
// Italian
541-
'Gen(?:naio)?', 'Feb(?:braio)?', 'Mag(?:gio)?', 'Giu(?:gno)?', 'Lug(?:lio)?', 'Set(?:tembre)?', 'Ott(?:obre)?', 'Dic(?:embre)?',
542-
// Portuguese
543-
'Jan(?:eiro)?', 'Fev(?:ereiro)?', 'Mar(?:ço)?', 'Mai(?:o)?', 'Jun(?:ho)?', 'Jul(?:ho)?', 'Set(?:embro)?', 'Out(?:ubro)?', 'Nov(?:embro)?', 'Dez(?:embro)?',
544-
// Dutch
545-
'Jan(?:uari)?', 'Feb(?:ruari)?', 'Mrt', 'Mei', 'Jun(?:i)?', 'Jul(?:i)?', 'Aug(?:ustus)?',
546-
// Russian
547-
'Янв(?:арь)?', 'Фев(?:раль)?', 'Мар(?:т)?', 'Апр(?:ель)?', 'Май', 'Июн(?:ь)?', 'Июл(?:ь)?', 'Авг(?:уст)?', 'Сен(?:тябрь)?', 'Окт(?:ябрь)?', 'Ноя(?:брь)?', 'Дек(?:абрь)?',
548-
// Chinese (Pinyin)
549-
'yīyuè', 'èryuè', 'sānyuè', 'sìyuè', 'wǔyuè', 'liùyuè', 'qīyuè', 'bāyuè', 'jiǔyuè', 'shíyuè', 'shíyīyuè', "shí'èryuè",
550-
// Japanese (Romaji)
551-
'ichigatsu', 'nigatsu', 'sangatsu', 'shigatsu', 'gogatsu', 'rokugatsu', 'shichigatsu', 'hachigatsu', 'kugatsu', 'jugatsu', 'juichigatsu', 'juunigatsu',
552-
// Arabic
553-
'يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر',
548+
const english = [
549+
'Jan(?:uary)?', 'Feb(?:ruary)?', 'Mar(?:ch)?', 'Apr(?:il)?', 'May', 'June?', 'July?',
550+
'Aug(?:ust)?', 'Sep(?:tember)?', 'Oct(?:ober)?', 'Nov(?:ember)?', 'Dec(?:ember)?',
554551
];
552+
const byLocale: Record<string, string[]> = {
553+
'de': [
554+
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli',
555+
'Okt(?:ober)?', 'Dez(?:ember)?',
556+
],
557+
'fr': [
558+
'Janv(?:ier)?', 'Fév(?:rier)?', 'Mars', 'Avr(?:il)?', 'Juin', 'Juil(?:let)?',
559+
'Août', 'Sept(?:embre)?', 'Oct(?:obre)?', 'Nov(?:embre)?', 'Déc(?:embre)?',
560+
],
561+
'es': [
562+
'Ene(?:ro)?', 'Feb(?:rero)?', 'Mar(?:zo)?', 'Abr(?:il)?', 'May(?:o)?',
563+
'Jun(?:io)?', 'Jul(?:io)?', 'Ago(?:sto)?', 'Sep(?:tiembre)?',
564+
'Oct(?:ubre)?', 'Nov(?:iembre)?', 'Dic(?:iembre)?',
565+
],
566+
'it': [
567+
'Gen(?:naio)?', 'Feb(?:braio)?', 'Mag(?:gio)?', 'Giu(?:gno)?', 'Lug(?:lio)?',
568+
'Set(?:tembre)?', 'Ott(?:obre)?', 'Dic(?:embre)?',
569+
],
570+
'pt': [
571+
'Jan(?:eiro)?', 'Fev(?:ereiro)?', 'Mar(?:ço)?', 'Mai(?:o)?', 'Jun(?:ho)?',
572+
'Jul(?:ho)?', 'Set(?:embro)?', 'Out(?:ubro)?', 'Nov(?:embro)?', 'Dez(?:embro)?',
573+
],
574+
'nl': [
575+
'Jan(?:uari)?', 'Feb(?:ruari)?', 'Mrt', 'Mei', 'Jun(?:i)?', 'Jul(?:i)?', 'Aug(?:ustus)?',
576+
],
577+
'ru': [
578+
'Янв(?:арь)?', 'Фев(?:раль)?', 'Мар(?:т)?', 'Апр(?:ель)?', 'Май',
579+
'Июн(?:ь)?', 'Июл(?:ь)?', 'Авг(?:уст)?', 'Сен(?:тябрь)?',
580+
'Окт(?:ябрь)?', 'Ноя(?:брь)?', 'Дек(?:абрь)?',
581+
],
582+
'zh': [
583+
'yīyuè', 'èryuè', 'sānyuè', 'sìyuè', 'wǔyuè', 'liùyuè',
584+
'qīyuè', 'bāyuè', 'jiǔyuè', 'shíyuè', 'shíyīyuè', "shí'èryuè",
585+
],
586+
'ja': [
587+
'ichigatsu', 'nigatsu', 'sangatsu', 'shigatsu', 'gogatsu', 'rokugatsu',
588+
'shichigatsu', 'hachigatsu', 'kugatsu', 'jugatsu', 'juichigatsu', 'juunigatsu',
589+
],
590+
'ar': [
591+
'يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو',
592+
'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر',
593+
],
594+
};
595+
return [...english, ...(byLocale[this.primaryLocale()] ?? [])];
555596
}
556597
}

src/test/suite/input.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ suite('Open Journal Entries', () => {
127127

128128
suite('Issue #170 — weekday/month/shortcut prefix collisions', () => {
129129

130-
async function parse(text: string): Promise<J.Model.Input> {
131-
const ctrl = new J.Util.Ctrl(new FakeWorkspaceConfig({}));
130+
async function parse(text: string, locale = ''): Promise<J.Model.Input> {
131+
const ctrl = new J.Util.Ctrl(new FakeWorkspaceConfig(locale ? { locale } : {}));
132132
ctrl.initServices(new TestLogger(false));
133133
return ctrl.parser.parseInput(text);
134134
}
@@ -185,17 +185,17 @@ suite('Issue #170 — weekday/month/shortcut prefix collisions', () => {
185185
// empty (or, for combined inputs, only the residual remains).
186186

187187
test("lone 'do' is consumed as a weekday token (text is empty)", async () => {
188-
const input = await parse("do");
188+
const input = await parse("do", "de");
189189
assert.strictEqual(input.text, "", "weekday token 'do' should leave empty text, got " + JSON.stringify(input.text));
190190
});
191191

192192
test("'Donnerstag' (full German weekday) is consumed (text is empty)", async () => {
193-
const input = await parse("Donnerstag");
193+
const input = await parse("Donnerstag", "de");
194194
assert.strictEqual(input.text, "", "weekday 'Donnerstag' should leave empty text, got " + JSON.stringify(input.text));
195195
});
196196

197197
test("'do task fix the regex' keeps task flag and residual text", async () => {
198-
const input = await parse("do task fix the regex");
198+
const input = await parse("do task fix the regex", "de");
199199
assert.ok(input.hasTask(), "task flag missing: " + JSON.stringify(input));
200200
assert.strictEqual(input.text, "fix the regex", "expected residual 'fix the regex', got " + JSON.stringify(input.text));
201201
});

src/test/suite/match-input-vocab.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,53 @@ suite('MatchInput — tokenizer vocab & regression', () => {
260260
assert.strictEqual(r.offset, 0);
261261
});
262262

263+
// --- locale isolation: only English + configured locale fire ---
264+
265+
test("locale=de: 'so' (Sonntag) resolves to Sunday", async () => {
266+
const r = await make('de').parseInput('so');
267+
assert.ok(typeof r.offset === 'number');
268+
});
269+
270+
test("locale=de: 'son' does NOT match (not a German abbrev)", async () => {
271+
const r = await make('de').parseInput('son');
272+
assert.strictEqual(r.offset, 0, "'son' is text-only for German locale");
273+
});
274+
275+
test("locale=de: 'zon' (Dutch Sunday) does NOT match", async () => {
276+
const r = await make('de').parseInput('zon');
277+
assert.strictEqual(r.offset, 0, "Dutch 'zon' must not fire for German locale");
278+
});
279+
280+
test("locale=nl: 'zo' (Dutch Sunday) resolves to Sunday", async () => {
281+
const r = await make('nl').parseInput('zo');
282+
assert.ok(typeof r.offset === 'number');
283+
});
284+
285+
test("locale=nl: 'so' (German Sonntag abbrev) does NOT match", async () => {
286+
const r = await make('nl').parseInput('so');
287+
assert.strictEqual(r.offset, 0, "German 'so' must not fire for Dutch locale");
288+
});
289+
290+
test("locale=en: 'sun' resolves to Sunday", async () => {
291+
const r = await make('en').parseInput('sun');
292+
assert.ok(typeof r.offset === 'number');
293+
});
294+
295+
test("locale=en: 'so' (German abbrev) does NOT match", async () => {
296+
const r = await make('en').parseInput('so');
297+
assert.strictEqual(r.offset, 0, "German 'so' must not fire for English locale");
298+
});
299+
300+
test("locale=en: 'zon' (Dutch Sunday) does NOT match", async () => {
301+
const r = await make('en').parseInput('zon');
302+
assert.strictEqual(r.offset, 0, "Dutch 'zon' must not fire for English locale");
303+
});
304+
305+
test("locale=de-DE (region tag): 'so' resolves (primary subtag 'de' used)", async () => {
306+
const r = await make('de-DE').parseInput('so');
307+
assert.ok(typeof r.offset === 'number');
308+
});
309+
263310
// --- vocab sweep: all English 3-letter abbreviations parse to a valid weekday ---
264311

265312
const engAbbrevs: Array<[string, number]> = [

0 commit comments

Comments
 (0)