From 14d49c6568a05932aa919b5b3ef85ff922f2b2fb Mon Sep 17 00:00:00 2001 From: Raphael Faouakhiri Date: Thu, 20 Aug 2026 21:32:29 -0300 Subject: [PATCH 1/2] fix: templated occurrence filename counts as representing the title (#2246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With storeTitleInFilename enabled, occurrences named by the occurrence filename template were always born with the title property, because the titleIsRepresentedByFilename check compared the unique filename against the plain title — which a templated name never matches. The title property then wins over the filename when reading, so every view showed the occurrence without its period suffix. Compare against the generated occurrence filename instead when the template was used. Collision suffixes and sanitization losses still preserve the title property, keeping the intent of the collision handling fix. --- .../task-service/TaskCreationService.ts | 10 +- ...urrence-template-title-frontmatter.test.ts | 129 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index a60445c3b..2fe83ca37 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -201,9 +201,17 @@ export class TaskCreationService { runtime.app.vault ); const fullPath = folder ? `${folder}/${uniqueFilename}.md` : `${uniqueFilename}.md`; + // A templated occurrence filename intentionally differs from the + // title, but still represents it as long as the generated name was + // used as-is (no collision suffix) and nothing was lost to + // filename sanitization. + const expectedFilename = + occurrenceFilenameTemplate && taskData.occurrence_date + ? baseFilename + : filenameTitle; const titleIsRepresentedByFilename = runtime.settings.storeTitleInFilename && - uniqueFilename === filenameTitle && + uniqueFilename === expectedFilename && title === filenameTitle; const completeTaskData: Partial = { diff --git a/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts b/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts new file mode 100644 index 000000000..b44a8fc6b --- /dev/null +++ b/tests/unit/issues/issue-2246-occurrence-template-title-frontmatter.test.ts @@ -0,0 +1,129 @@ +/** + * Issue #2246: Occurrence filename template writes title frontmatter, + * so views display the title without the period suffix + * + * @see https://github.com/callumalpass/tasknotes/issues/2246 + * + * With `storeTitleInFilename` enabled, a templated occurrence filename + * (e.g. "Pay rent — 2026-09") never equals the plain title, so the + * `titleIsRepresentedByFilename` check from the collision-handling fix + * always fails and every occurrence is born with a title property. + * The title property then wins over the filename when reading, hiding + * the period suffix in every view. + * + * Expected: when the templated filename is used as-is (no collision + * suffix, no sanitization loss), the filename represents the title and + * the title property should be omitted — matching the behavior for + * regular tasks whose filename equals their title. + */ + +import type { TaskInfo } from '../../../src/types'; +import { PluginFactory } from '../../helpers/mock-factories'; +import { TaskCreationService } from '../../../src/services/task-service/TaskCreationService'; +import { + generateTaskFilename, + generateUniqueFilename, + generateOccurrenceFilename, +} from '../../../src/utils/filenameGenerator'; + +jest.mock('../../../src/utils/dateUtils', () => ({ + getCurrentTimestamp: jest.fn(() => '2026-08-20T00:00:00-03:00'), +})); + +jest.mock('../../../src/utils/filenameGenerator', () => ({ + generateTaskFilename: jest.fn(() => 'Pay rent'), + generateUniqueFilename: jest.fn(async (base) => base), + generateOccurrenceFilename: jest.fn(() => 'Pay rent — 2026-09'), +})); + +jest.mock('../../../src/utils/helpers', () => ({ + ensureFolderExists: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../src/utils/templateProcessor', () => ({ + mergeTemplateFrontmatter: jest.fn((base, template) => ({ ...base, ...template })), +})); + +describe('Issue #2246: occurrence filename template vs title frontmatter', () => { + const mockGenerateUniqueFilename = generateUniqueFilename as jest.MockedFunction< + typeof generateUniqueFilename + >; + const mockGenerateOccurrenceFilename = generateOccurrenceFilename as jest.MockedFunction< + typeof generateOccurrenceFilename + >; + const mockGenerateTaskFilename = generateTaskFilename as jest.MockedFunction< + typeof generateTaskFilename + >; + + beforeEach(() => { + mockGenerateTaskFilename.mockReturnValue('Pay rent'); + mockGenerateOccurrenceFilename.mockReturnValue('Pay rent — 2026-09'); + mockGenerateUniqueFilename.mockImplementation(async (base) => base); + }); + + function createService(overrides: { sanitizeForFilename?: (input: string) => string } = {}) { + const mockPlugin = PluginFactory.createMockPlugin(); + mockPlugin.settings.storeTitleInFilename = true; + + const service = new TaskCreationService({ + runtime: mockPlugin, + applyTaskCreationDefaults: jest.fn(async (taskData) => taskData), + applyTemplate: jest.fn(async () => ({ frontmatter: {}, body: '' })), + processFolderTemplate: jest.fn((folderTemplate) => folderTemplate), + sanitizeTitleForFilename: jest.fn(overrides.sanitizeForFilename ?? ((input) => input)), + sanitizeTitleForStorage: jest.fn((input) => input), + }); + + return { mockPlugin, service }; + } + + const occurrenceTaskData: Partial = { + title: 'Pay rent', + recurrence_parent: '[[Tasks/Pay rent]]', + occurrence_date: '2026-09-01', + occurrenceFilenameTemplate: '{{title}} — {{occurrenceMonth}}', + }; + + it('omits title frontmatter when the templated occurrence filename is used as-is', async () => { + const { mockPlugin, service } = createService(); + + await service.createTask({ ...occurrenceTaskData }, { applyDefaults: false }); + + const [path, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string]; + + expect(path).toBe('Tasks/Pay rent — 2026-09.md'); + expect(content).not.toContain('title:'); + expect(mockPlugin.cacheManager.updateTaskInfoInCache).toHaveBeenCalledWith( + 'Tasks/Pay rent — 2026-09.md', + expect.objectContaining({ title: 'Pay rent' }) + ); + }); + + it('preserves title frontmatter when the templated filename needs a collision suffix', async () => { + mockGenerateUniqueFilename.mockResolvedValue('Pay rent — 2026-09-1'); + const { mockPlugin, service } = createService(); + + await service.createTask({ ...occurrenceTaskData }, { applyDefaults: false }); + + const [path, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string]; + + expect(path).toBe('Tasks/Pay rent — 2026-09-1.md'); + expect(content).toContain('title: Pay rent'); + }); + + it('preserves title frontmatter when filename sanitization changes the title', async () => { + mockGenerateOccurrenceFilename.mockReturnValue('Pay rent — 2026-09'); + const { mockPlugin, service } = createService({ + sanitizeForFilename: (input) => input.replace(/:/g, ''), + }); + + await service.createTask( + { ...occurrenceTaskData, title: 'Pay: rent' }, + { applyDefaults: false } + ); + + const [, content] = mockPlugin.app.vault.create.mock.calls[0] as [string, string]; + + expect(content).toContain('title: "Pay: rent"'); + }); +}); From 191852e6302256aa7cddde9862bcb40c01581bb3 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 22 Aug 2026 00:38:53 +1000 Subject: [PATCH 2/2] docs: add PR 2248 release note --- docs/releases/unreleased.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..ed6867b31 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#2246) Fixed materialized recurring occurrences hiding occurrence-template filename suffixes in TaskNotes views. Thanks to @raphaelfaouakhiri for reporting and fixing this issue.