Skip to content

Commit 1cee3d6

Browse files
authored
Merge pull request #203 from pajoma/refactor/200-week-nav-dedup
refactor(navigation): extract getAdjacentWeekInput — closes #200
2 parents 4452b27 + 0c37b21 commit 1cee3d6

10 files changed

Lines changed: 175 additions & 40 deletions

File tree

src/actions/navigation.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import * as vscode from 'vscode';
2121
import * as Path from 'path';
2222
import { Ctrl } from '../util/controller';
2323
import { SCOPE_DEFAULT, ScopeDefinitionLite } from '../ext/conf';
24-
import { getDateFromURIAndConfig } from '../util/paths';
24+
import { getDateFromURIAndConfig, getWeekFromURIAndConfig } from '../util/paths';
25+
import { Input } from '../model';
26+
import moment = require("moment");
2527

2628
/** Direction of entry navigation — step backward or forward relative to the anchor. */
2729
export type Direction = 'previous' | 'next';
@@ -189,6 +191,30 @@ async function readDirSafe(uri: vscode.Uri): Promise<[string, vscode.FileType][]
189191
}
190192
}
191193

194+
/**
195+
* When the active editor contains a weekly note, returns an `Input` with `week` set to the
196+
* adjacent week number (direction `'next'` adds one week, `'previous'` subtracts one).
197+
* Returns `undefined` when the editor is absent or the file is not a weekly note — callers
198+
* should fall through to daily-entry navigation.
199+
*/
200+
export async function getAdjacentWeekInput(
201+
editor: vscode.TextEditor | undefined,
202+
ctrl: Ctrl,
203+
direction: Direction,
204+
): Promise<Input | undefined> {
205+
if (!editor) { return undefined; }
206+
const weekInfo = await getWeekFromURIAndConfig(editor.document.uri, ctrl.config);
207+
if (!weekInfo) { return undefined; }
208+
const adj = moment()
209+
.week(weekInfo.week)
210+
.weekYear(weekInfo.year)
211+
[direction === 'previous' ? 'subtract' : 'add'](1, 'week');
212+
const input = new Input();
213+
input.week = adj.week();
214+
input.scope = weekInfo.scope;
215+
return input;
216+
}
217+
192218
function escapeRegex(s: string): string {
193219
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194220
}

src/actions/reader.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ export class Reader {
3939
public async loadEntryForInput(input: J.Model.Input): Promise<vscode.TextDocument> {
4040

4141
if (input.hasOffset()) {
42-
return this.loadEntryForDay(input.generateDate());
42+
return this.loadEntryForDay(input.generateDate(), input.scope);
4343
}
4444
if (input.hasWeek()) {
45-
return this.loadEntryForWeek(input.week);
45+
return this.loadEntryForWeek(input.week, input.scope);
4646
}
4747
throw Error("Neither offset nor week are defined in input, we abort.");
4848

@@ -53,12 +53,12 @@ export class Reader {
5353
* Loads the weekly page for the given week number (of the year)
5454
* @param week the week of the current year
5555
*/
56-
public async loadEntryForWeek(week: Number): Promise<vscode.TextDocument> {
56+
public async loadEntryForWeek(week: Number, scope?: string): Promise<vscode.TextDocument> {
5757
this.ctrl.logger.trace("Entering loadEntryForWeek() in actions/reader.ts for week " + week);
5858

5959
const [pathname, filename] = await Promise.all([
60-
this.ctrl.config.getWeekPathPattern(week),
61-
this.ctrl.config.getWeekFilePattern(week),
60+
this.ctrl.config.getWeekPathPattern(week, scope),
61+
this.ctrl.config.getWeekFilePattern(week, scope),
6262
]);
6363
const path = J.Util.resolvePath(pathname.value!, filename.value!);
6464

@@ -78,15 +78,15 @@ export class Reader {
7878
* @returns {Promise<vscode.TextDocument>} the document
7979
* @memberof Reader
8080
*/
81-
public async loadEntryForDay(date: Date): Promise<vscode.TextDocument> {
81+
public async loadEntryForDay(date: Date, scope?: string): Promise<vscode.TextDocument> {
8282
if (J.Util.isNullOrUndefined(date) || date!.toString().includes("Invalid")) {
8383
throw new Error("Invalid date");
8484
}
8585
this.ctrl.logger.trace("Entering loadEntryforDate() in actions/reader.ts for date " + date.toISOString());
8686

8787
const [pathname, filename] = await Promise.all([
88-
this.ctrl.config.getResolvedEntryPath(date),
89-
this.ctrl.config.getEntryFilePattern(date),
88+
this.ctrl.config.getResolvedEntryPath(date, scope),
89+
this.ctrl.config.getEntryFilePattern(date, scope),
9090
]);
9191
const path = J.Util.resolvePath(pathname.value!, filename.value!);
9292

src/ext/conf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const defaultPatternDefinition: PatternDefinition =
6262
},
6363
weeks: {
6464
path: "${base}/${year}",
65-
file: "w${week}.${ext}"
65+
file: "week_${week}.${ext}"
6666
},
6767
weeklyNotes: {
6868
path: "${base}/${year}/w${week}",

src/model/input.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class Input {
3030
private _text: string = "";
3131
private _scope: string = "";
3232
private _week: number;
33+
private _date: Date | undefined;
3334

3435
private _tags: string[] = [];
3536

@@ -77,6 +78,13 @@ export class Input {
7778
return this._scope;
7879
}
7980

81+
/**
82+
* Getter date
83+
*/
84+
public get date(): Date | undefined {
85+
return this._date;
86+
}
87+
8088
/**
8189
* Setter offset
8290
* @param {number } value
@@ -113,6 +121,13 @@ export class Input {
113121
this._scope = value;
114122
}
115123

124+
/**
125+
* Setter date
126+
*/
127+
public set date(value: Date | undefined) {
128+
this._date = value;
129+
}
130+
116131
/**
117132
* Return the week of year
118133
*/
@@ -138,7 +153,7 @@ export class Input {
138153
}
139154

140155
public hasOffset(): boolean {
141-
return !isNaN(this.offset) && this._week === -1;
156+
return (this.date !== undefined) || (!isNaN(this.offset) && this._week === -1);
142157
}
143158

144159
public hasTask(): boolean {
@@ -151,6 +166,9 @@ export class Input {
151166
}
152167

153168
public generateDate(): Date {
169+
if (this.date) {
170+
return this.date;
171+
}
154172
let date = new Date();
155173
date.setDate(date.getDate() + this.offset);
156174
return date;

src/provider/commands/open-next-entry.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import * as vscode from 'vscode';
1313
import * as J from '../..';
1414
import { AbstractLoadEntryForDateCommand } from './show-entry-for-date';
15-
import { daysBetween, findAdjacentEntry, resolveAnchor, Mode } from '../../actions/navigation';
15+
import { daysBetween, findAdjacentEntry, getAdjacentWeekInput, resolveAnchor, Mode } from '../../actions/navigation';
1616

1717
export class OpenNextEntryCommand extends AbstractLoadEntryForDateCommand {
1818
title: string = "Open the next journal entry";
@@ -25,15 +25,20 @@ export class OpenNextEntryCommand extends AbstractLoadEntryForDateCommand {
2525
}
2626

2727
public async run(): Promise<void> {
28+
const editor = vscode.window.activeTextEditor;
29+
const weekInput = await getAdjacentWeekInput(editor, this.ctrl, 'next');
30+
if (weekInput) { await this.execute(weekInput); return; }
31+
2832
const mode: Mode = this.ctrl.config.getNavigationMode();
29-
const anchor = await resolveAnchor(this.ctrl, vscode.window.activeTextEditor);
33+
const anchor = await resolveAnchor(this.ctrl, editor);
3034
const target = await findAdjacentEntry(this.ctrl, anchor, 'next', mode);
3135
if (target === null) {
3236
await vscode.window.showInformationMessage(vscode.l10n.t("No later journal entry found."));
3337
return;
3438
}
3539
const input = new J.Model.Input();
36-
input.offset = daysBetween(new Date(), target);
40+
input.date = target;
41+
input.scope = anchor.scope;
3742
await this.execute(input);
3843
}
3944
}

src/provider/commands/open-previous-entry.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import * as vscode from 'vscode';
1313
import * as J from '../..';
1414
import { AbstractLoadEntryForDateCommand } from './show-entry-for-date';
15-
import { daysBetween, findAdjacentEntry, resolveAnchor, Mode } from '../../actions/navigation';
15+
import { daysBetween, findAdjacentEntry, getAdjacentWeekInput, resolveAnchor, Mode } from '../../actions/navigation';
1616

1717
export class OpenPreviousEntryCommand extends AbstractLoadEntryForDateCommand {
1818
title: string = "Open the previous journal entry";
@@ -25,15 +25,20 @@ export class OpenPreviousEntryCommand extends AbstractLoadEntryForDateCommand {
2525
}
2626

2727
public async run(): Promise<void> {
28+
const editor = vscode.window.activeTextEditor;
29+
const weekInput = await getAdjacentWeekInput(editor, this.ctrl, 'previous');
30+
if (weekInput) { await this.execute(weekInput); return; }
31+
2832
const mode: Mode = this.ctrl.config.getNavigationMode();
29-
const anchor = await resolveAnchor(this.ctrl, vscode.window.activeTextEditor);
33+
const anchor = await resolveAnchor(this.ctrl, editor);
3034
const target = await findAdjacentEntry(this.ctrl, anchor, 'previous', mode);
3135
if (target === null) {
3236
await vscode.window.showInformationMessage(vscode.l10n.t("No earlier journal entry found."));
3337
return;
3438
}
3539
const input = new J.Model.Input();
36-
input.offset = daysBetween(new Date(), target);
40+
input.date = target;
41+
input.scope = anchor.scope;
3742
await this.execute(input);
3843
}
3944
}

src/test/suite/command-test-helpers.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,17 @@ export function createMockCtrl(overrides: Partial<MockCtrl> = {}): MockCtrl {
3030
getTimeStringTemplate: async () => ({ value: '12:34' } as J.Model.ScopedTemplate),
3131
getTaskInlineTemplate: async () => ({ value: '- [ ] ${input}' } as J.Model.InlineTemplate),
3232
getBasePath: () => '/tmp/journal-tests',
33-
getBasePathForLocalOpen: () => '/tmp/journal-tests'
33+
getBasePathForLocalOpen: () => '/tmp/journal-tests',
34+
getFileExtension: () => 'md',
35+
getScopes: () => ['default'],
36+
getWeeksPathPatternRaw: () => '${base}/${year}',
37+
getWeeksFilePatternRaw: () => 'week_${week}.${ext}',
38+
getWeeklySyncConfig: () => ({ enabled: true, anchor: '## Daily Entries', template: '- [${weekday}](${link})', sortOrder: 'ascending' }),
39+
getResolvedEntryPath: async () => ({ value: '/tmp/journal-tests/2026/05' }),
40+
getEntryFilePattern: async () => ({ value: '17.md' }),
41+
getResolvedEntryPathForLocalOpen: async () => ({ value: '/tmp/journal-tests/2026/05' }),
42+
getWeekFilePattern: async () => ({ value: 'week_20.md' }),
43+
getWeekPathPatternForLocalOpen: async () => ({ value: '/tmp/journal-tests/2026' })
3444
},
3545
parser: {
3646
parseInput: async (input: string) => {

src/test/suite/commands-entry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ suite('Command suites - entry commands', () => {
170170
getBasePathForLocalOpen: () => 'C:\\Users\\patrick.maue\\Git\\journal',
171171
getResolvedEntryPath: async (_date: Date) => ({ value: 'C:\\Users\\patrick.maue\\Git\\journal\\2026\\02' }),
172172
getEntryFilePattern: async (_date: Date) => ({ value: '2026-02-17.md' }),
173+
getResolvedEntryPathForLocalOpen: async (_date: Date) => ({ value: 'C:\\Users\\patrick.maue\\Git\\journal\\2026\\02' }),
173174
isWindowsStyleBaseConfigured: () => true
174175
},
175176
reader: {

src/test/suite/commands-prev-next.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
addDays,
99
daysBetween,
1010
findAdjacentEntry,
11+
getAdjacentWeekInput,
1112
resolveAnchor,
1213
stripTime,
1314
} from '../../actions/navigation';
@@ -29,7 +30,14 @@ async function seedEntry(base: string, year: number, month: number, day: number,
2930
async function buildCtrl(tmpBase: string): Promise<{ ctrl: J.Util.Ctrl; logger: TestLogger }> {
3031
const config = vscode.workspace.getConfiguration('journal');
3132
await config.update('base', tmpBase, vscode.ConfigurationTarget.Workspace);
32-
const refreshed = vscode.workspace.getConfiguration('journal');
33+
// On CI the config write is async; poll until the value is visible.
34+
const deadline = Date.now() + 3000;
35+
let refreshed: vscode.WorkspaceConfiguration;
36+
do {
37+
refreshed = vscode.workspace.getConfiguration('journal');
38+
if (refreshed.get<string>('base') === tmpBase) { break; }
39+
await new Promise<void>(r => setTimeout(r, 50));
40+
} while (Date.now() < deadline);
3341
const ctrl = new J.Util.Ctrl(refreshed);
3442
const logger = new TestLogger(false);
3543
ctrl.logger = logger;
@@ -365,4 +373,65 @@ suite('Issue #144 — Open Previous / Open Next navigation', () => {
365373
assert.ok(prev === null || prev === undefined, `expected null at start of history, got ${prev}`);
366374
});
367375
});
376+
377+
suite('getAdjacentWeekInput (#200)', () => {
378+
let originalBase: string | undefined;
379+
let tmpBase: string;
380+
let ctrl: J.Util.Ctrl;
381+
382+
setup(async () => {
383+
const config = vscode.workspace.getConfiguration('journal');
384+
originalBase = config.get<string>('base');
385+
tmpBase = path.join(os.tmpdir(), `issue200-week-${Date.now()}`);
386+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(tmpBase));
387+
({ ctrl } = await buildCtrl(tmpBase));
388+
});
389+
390+
teardown(async () => {
391+
const config = vscode.workspace.getConfiguration('journal');
392+
await config.update('base', originalBase, vscode.ConfigurationTarget.Workspace);
393+
try { await vscode.workspace.fs.delete(vscode.Uri.file(tmpBase), { recursive: true }); } catch { /* ignore */ }
394+
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
395+
});
396+
397+
async function seedWeeklyFile(base: string, year: number, week: number): Promise<string> {
398+
const yearDir = vscode.Uri.file(path.join(base, String(year).padStart(4, '0')));
399+
await vscode.workspace.fs.createDirectory(yearDir);
400+
const file = vscode.Uri.file(path.join(base, String(year).padStart(4, '0'), `week_${week}.md`));
401+
await vscode.workspace.fs.writeFile(file, new TextEncoder().encode(`# Week ${week}\n`));
402+
return file.fsPath;
403+
}
404+
405+
test('T1: editor=undefined returns undefined', async () => {
406+
const result = await getAdjacentWeekInput(undefined, ctrl, 'next');
407+
assert.strictEqual(result, undefined);
408+
});
409+
410+
test('T2: non-weekly day file returns undefined', async () => {
411+
const dayPath = await seedEntry(tmpBase, 2026, 5, 16);
412+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(dayPath));
413+
const editor = await vscode.window.showTextDocument(doc);
414+
const result = await getAdjacentWeekInput(editor, ctrl, 'next');
415+
assert.strictEqual(result, undefined);
416+
});
417+
418+
test('T3: weekly file + direction next → week+1', async () => {
419+
const weeklyPath = await seedWeeklyFile(tmpBase, 2026, 20);
420+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(weeklyPath));
421+
const editor = await vscode.window.showTextDocument(doc);
422+
const diag = `tmpBase=${tmpBase} base=${ctrl.config.getBasePath()} weeksPat=${ctrl.config.getWeeksFilePatternRaw()} uri=${editor.document.uri.fsPath}`;
423+
const result = await getAdjacentWeekInput(editor, ctrl, 'next');
424+
assert.ok(result, `expected an Input back [${diag}]`);
425+
assert.strictEqual(result!.week, 21);
426+
});
427+
428+
test('T4: weekly file + direction previous → week-1', async () => {
429+
const weeklyPath = await seedWeeklyFile(tmpBase, 2026, 20);
430+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(weeklyPath));
431+
const editor = await vscode.window.showTextDocument(doc);
432+
const result = await getAdjacentWeekInput(editor, ctrl, 'previous');
433+
assert.ok(result, 'expected an Input back');
434+
assert.strictEqual(result!.week, 19);
435+
});
436+
});
368437
});

src/util/paths.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,35 +52,36 @@ export async function getDateFromURI(uri: string, pathTemplate: string, fileTemp
5252
if (fileTemplate.indexOf(".") > 0) { fileTemplate = fileTemplate.substring(0, fileTemplate.lastIndexOf(".")); }
5353
if (pathTemplate.startsWith("${base}/")) { pathTemplate = pathTemplate.substring("${base}/".length); }
5454

55-
// go through each element in path and assign it to a date part or skip it
56-
let pathParts = uri.split("/");
57-
58-
// check if part is in base path (if yes, we ignore)
59-
// for the rest: last part is file, everything else path pattern
60-
let pathElements: string[] = [];
61-
let trimmedPathString: string = "";
62-
let trimmedFileString = "";
63-
64-
pathParts.forEach((element, index) => {
65-
if (element.trim().length === 0) { return; }
66-
else if (element.startsWith("file:")) { return; }
67-
else if (basePath.search(element) >= 0) { return; }
68-
else if (index + 1 === pathParts.length) { trimmedFileString = element.substr(0, element.lastIndexOf(".")); }
69-
else {
70-
pathElements.concat(element);
71-
if (trimmedPathString.length > 1) { trimmedPathString += "/"; }
72-
trimmedPathString += element;
73-
}
74-
});
55+
// Normalize paths to use forward slashes and ensure absolute path for URI
56+
const normalizedUri = uri.replace(/\\/g, '/');
57+
const normalizedBase = basePath.replace(/\\/g, '/');
58+
59+
// Strip the base path prefix from the URI
60+
let relativePath = normalizedUri;
61+
if (normalizedUri.startsWith(normalizedBase)) {
62+
relativePath = normalizedUri.substring(normalizedBase.length).replace(/^\/+/, '');
63+
} else if (normalizedUri.includes('://')) {
64+
// Handle URI schemes (file:///...)
65+
try {
66+
const uriObj = vscode.Uri.parse(normalizedUri);
67+
const fsPath = uriObj.fsPath.replace(/\\/g, '/');
68+
if (fsPath.startsWith(normalizedBase)) {
69+
relativePath = fsPath.substring(normalizedBase.length).replace(/^\/+/, '');
70+
}
71+
} catch { /* fallback to path split */ }
72+
}
7573

74+
const pathParts = relativePath.split('/');
75+
const trimmedFileString = pathParts.length > 0 ? pathParts[pathParts.length - 1].split('.')[0] : "";
76+
const trimmedPathString = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : "";
7677

7778
const entryDateFormat = replaceDateTemplatesWithMomentsFormats(fileTemplate);
7879
const pathDateFormat = replaceDateTemplatesWithMomentsFormats(pathTemplate);
7980

8081
let parsedDateFromFile = moment(trimmedFileString, entryDateFormat);
8182
let parsedDateFromPath = moment(trimmedPathString, pathDateFormat);
8283

83-
let result = moment();
84+
let result = moment().startOf('day');
8485

8586
// consolidate the two
8687
if (fileTemplate.indexOf("${year}") >= 0) { result = result.year(parsedDateFromFile.year()); }

0 commit comments

Comments
 (0)