Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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.
- Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution.
- (#2191) Completing or skipping a recurring occurrence that was moved earlier
than its original date no longer schedules that original date again. Thanks to
Expand Down
10 changes: 9 additions & 1 deletion src/services/task-service/TaskCreationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskInfo> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TaskInfo> = {
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"');
});
});
Loading