Skip to content

Commit ff8d9f0

Browse files
authored
Merge pull request #233 from pajoma/feat/177-smart-input-tokenizer
feat(notes): link note to specific day via temporal prefix (#232)
2 parents 885417f + 187df39 commit ff8d9f0

9 files changed

Lines changed: 338 additions & 112 deletions

File tree

src/features/entries/load-note.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ export class LoadNotes {
2424

2525
let document : vscode.TextDocument = await this.loadNote(path, content);
2626

27-
// inject reference to new note in today's journal page
28-
await this.ctrl.reader.loadEntryForInput(new J.Model.Input(0)) // triggered automatically by loading today's page (we don't show it though)
29-
.catch(reason => this.ctrl.logger.error("Failed to load today's page for injecting link to note.", reason));
27+
// inject reference to new note in the target day's journal page (offset from input, defaults to today)
28+
const entryInput = new J.Model.Input(this.input.offset);
29+
entryInput.date = this.input.date;
30+
entryInput.scope = this.input.scope;
31+
await this.ctrl.reader.loadEntryForInput(entryInput)
32+
.catch(reason => this.ctrl.logger.error("Failed to load target day's page for injecting link to note.", reason));
3033

3134
return document;
3235
}

src/journal/match-input.ts

Lines changed: 92 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -498,59 +498,105 @@ 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+
'mon', 'die', 'mit', 'don', 'fre', 'sam', 'son',
514+
],
515+
'fr': [
516+
'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche',
517+
'lun', 'mar', 'mer', 'jeu', 'ven', 'sam', 'dim',
518+
],
519+
'es': [
520+
'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo',
521+
'lun', 'mar', 'mié', 'jue', 'vie', 'sáb', 'dom',
522+
],
523+
'it': [
524+
'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
525+
'lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom',
526+
],
527+
'pt': [
528+
'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado', 'domingo',
529+
'seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom',
530+
],
531+
'nl': [
532+
'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag',
533+
'maa', 'din', 'woe', 'don', 'vri', 'zat', 'zon',
534+
],
535+
'ru': [
536+
'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье',
537+
'пон', 'вто', 'сре', 'чет', 'пят', 'суб', 'вос',
538+
],
539+
'zh': [
540+
'xīngqī yī', 'xīngqī èr', 'xīngqī sān', 'xīngqī sì', 'xīngqī wǔ', 'xīngqī liù', 'xīngqī rì',
541+
],
542+
'ja': [
543+
'getsuyōbi', 'kayōbi', 'suiyōbi', 'mokuyōbi', "kin'yōbi", 'doyōbi', 'nichiyōbi',
544+
],
545+
'ar': [
546+
'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت', 'الأحد',
547+
],
548+
};
549+
return [...english, ...(byLocale[this.primaryLocale()] ?? [])];
528550
}
529551

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

src/journal/parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class Parser {
4545

4646
this.logger.trace("Entering resolveNotePathForInput() in actions/parser.ts");
4747

48-
const date = new Date();
48+
const date = input.generateDate();
4949
input.extractScopeAndTags(this.config.getScopes());
5050
this.logger.trace("Tags in input: " + input.tags + ", scope: " + input.scope);
5151

src/journal/paths.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,12 @@ export async function getDateFromURI(uri: string, pathTemplate: string, fileTemp
8888
else { result = result.year(parsedDateFromPath.year()); }
8989
if (fileTemplate.indexOf("${month}") >= 0) { result = result.month(parsedDateFromFile.month()); }
9090
else { result = result.month(parsedDateFromPath.month()); }
91-
if (fileTemplate.indexOf("${day}") >= 0) { result = result.date(parsedDateFromFile.date()); }
92-
else { result = result.date(parsedDateFromPath.date()); }
91+
if (fileTemplate.indexOf("${day}") >= 0) {
92+
// Parse day directly as integer to avoid moment overflow when the current
93+
// month has fewer days than the target day (e.g. parsing "31" in June gives July 1).
94+
const dayInt = /^\d+$/.test(trimmedFileString) ? parseInt(trimmedFileString, 10) : parsedDateFromFile.date();
95+
result = result.date(dayInt);
96+
} else { result = result.date(parsedDateFromPath.date()); }
9397

9498
return result.toDate();
9599

src/test/suite/input.test.ts

Lines changed: 11 additions & 11 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
}
@@ -184,20 +184,20 @@ suite('Issue #170 — weekday/month/shortcut prefix collisions', () => {
184184
// invariant is that token recognition consumes the input so `text` is
185185
// empty (or, for combined inputs, only the residual remains).
186186

187-
test("lone 'do' is consumed as a weekday token (text is empty)", async () => {
188-
const input = await parse("do");
189-
assert.strictEqual(input.text, "", "weekday token 'do' should leave empty text, got " + JSON.stringify(input.text));
187+
test("lone 'don' is consumed as a weekday token (text is empty)", async () => {
188+
const input = await parse("don", "de");
189+
assert.strictEqual(input.text, "", "weekday token 'don' 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");
194-
assert.strictEqual(input.text, "", "weekday 'Donnerstag' should leave empty text, got " + JSON.stringify(input.text));
193+
const input = await parse("Donnerstag", "de");
194+
assert.strictEqual(input.text, "", "weekday 'Donnerstag' should leave empty text, got " + JSON.stringify(input.text));
195195
});
196196

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

203203
test("'w15' still resolves to week 15", async () => {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as assert from 'assert';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import * as vscode from 'vscode';
5+
import * as J from '../..';
6+
import { LoadNotes } from '../../features/entries/load-note';
7+
import { TestLogger } from '../test-logger';
8+
import { FakeWorkspaceConfig } from '../fake-workspace-config';
9+
10+
suite('Issue #232 — Note linked to specific day via temporal offset', () => {
11+
12+
let tmpBase: string;
13+
let ctrl: J.Util.Ctrl;
14+
let logger: TestLogger;
15+
16+
setup(async () => {
17+
tmpBase = path.join(os.tmpdir(), `issue232-${Date.now()}`);
18+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(tmpBase));
19+
ctrl = new J.Util.Ctrl(new FakeWorkspaceConfig({ base: tmpBase }));
20+
logger = new TestLogger(false);
21+
ctrl.initServices(logger);
22+
});
23+
24+
teardown(async () => {
25+
try { await vscode.workspace.fs.delete(vscode.Uri.file(tmpBase), { recursive: true }); } catch { /* ignore */ }
26+
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
27+
});
28+
29+
test('offset=0 (default) links note to today', async () => {
30+
const capturedOffsets: number[] = [];
31+
const orig = ctrl.reader.loadEntryForInput.bind(ctrl.reader);
32+
(ctrl.reader as any).loadEntryForInput = async (input: J.Model.Input) => {
33+
capturedOffsets.push(input.offset);
34+
return orig(input);
35+
};
36+
37+
const input = new J.Model.Input(0);
38+
input.text = 'TodayNote';
39+
await new LoadNotes(input as J.Model.NoteInput, ctrl).load();
40+
41+
assert.ok(capturedOffsets.some(o => o === 0), `expected offset 0, got ${JSON.stringify(capturedOffsets)}`);
42+
assert.strictEqual(logger.errors.length, 0, `errors: ${JSON.stringify(logger.errors)}`);
43+
});
44+
45+
test('offset=+2 links note to day-after-tomorrow', async () => {
46+
const capturedOffsets: number[] = [];
47+
const orig = ctrl.reader.loadEntryForInput.bind(ctrl.reader);
48+
(ctrl.reader as any).loadEntryForInput = async (input: J.Model.Input) => {
49+
capturedOffsets.push(input.offset);
50+
return orig(input);
51+
};
52+
53+
const input = new J.Model.Input(2);
54+
input.text = 'FutureNote';
55+
await new LoadNotes(input as J.Model.NoteInput, ctrl).load();
56+
57+
assert.ok(capturedOffsets.some(o => o === 2), `expected offset 2, got ${JSON.stringify(capturedOffsets)}`);
58+
assert.strictEqual(logger.errors.length, 0, `errors: ${JSON.stringify(logger.errors)}`);
59+
});
60+
61+
test('offset=-1 links note to yesterday', async () => {
62+
const capturedOffsets: number[] = [];
63+
const orig = ctrl.reader.loadEntryForInput.bind(ctrl.reader);
64+
(ctrl.reader as any).loadEntryForInput = async (input: J.Model.Input) => {
65+
capturedOffsets.push(input.offset);
66+
return orig(input);
67+
};
68+
69+
const input = new J.Model.Input(-1);
70+
input.text = 'YesterdayNote';
71+
await new LoadNotes(input as J.Model.NoteInput, ctrl).load();
72+
73+
assert.ok(capturedOffsets.some(o => o === -1), `expected offset -1, got ${JSON.stringify(capturedOffsets)}`);
74+
assert.strictEqual(logger.errors.length, 0, `errors: ${JSON.stringify(logger.errors)}`);
75+
});
76+
77+
test('resolveNotePathForInput uses generateDate() — path shifts with offset', async () => {
78+
const today = new Date();
79+
const future = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2);
80+
81+
const inputToday = new J.Model.Input(0);
82+
inputToday.text = 'SameNote';
83+
const inputFuture = new J.Model.Input(2);
84+
inputFuture.text = 'SameNote';
85+
86+
const pathToday = await ctrl.parser.resolveNotePathForInput(inputToday);
87+
const pathFuture = await ctrl.parser.resolveNotePathForInput(inputFuture);
88+
89+
assert.notStrictEqual(pathToday, pathFuture, 'note paths should differ when offset differs');
90+
assert.ok(pathFuture.includes(String(future.getDate()).padStart(2, '0')) ||
91+
pathFuture.includes(String(future.getMonth() + 1).padStart(2, '0')),
92+
`future path should contain day or month of day+2: ${pathFuture}`);
93+
});
94+
});

0 commit comments

Comments
 (0)