Skip to content

Commit 2caf7ef

Browse files
committed
Fix subtask project merging from task cards
1 parent fd7e559 commit 2caf7ef

4 files changed

Lines changed: 209 additions & 71 deletions

File tree

docs/releases/unreleased.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ Example:
4040

4141
## Fixed
4242

43+
- (#1828) Fixed subtask creation from a task card dropping the parent task project when natural-language input also adds projects.
44+
- Thanks to @prepare4robots for reporting.
4345
- (#1786) Fixed TaskNotes task cards and relationships/subtasks widgets appearing inside embedded task-note heading or block sections.
4446
- Skips note-level widget injection in detached or embedded Markdown editor contexts used by plugins such as Block Link Plus.
4547
- Thanks to @3zra47 for reporting.

src/modals/TaskCreationModal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ export class TaskCreationModal extends TaskModal {
10491049

10501050
// Handle projects differently - they use file selection, not text input
10511051
if (parsed.projects && parsed.projects.length > 0) {
1052-
this.initializeProjectsFromStrings(parsed.projects);
1052+
this.addProjectsFromStrings(parsed.projects);
10531053
this.renderProjectsList();
10541054
}
10551055

src/modals/TaskModal.ts

Lines changed: 113 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,17 +1757,18 @@ export abstract class TaskModal extends Modal {
17571757
}
17581758

17591759
protected addProject(file: TAbstractFile): void {
1760-
// Avoid duplicates
1761-
if (this.selectedProjectItems.some((existing) => existing.file?.path === file.path)) {
1762-
return;
1763-
}
1764-
17651760
if (file instanceof TFile) {
1766-
this.selectedProjectItems.push({
1761+
const projectItem = {
17671762
file,
17681763
name: file.basename,
17691764
link: this.buildProjectReference(file, this.getCurrentTaskPath() || ""),
1770-
});
1765+
};
1766+
1767+
if (this.hasProjectItem(projectItem)) {
1768+
return;
1769+
}
1770+
1771+
this.selectedProjectItems.push(projectItem);
17711772
}
17721773
this.updateProjectsFromFiles();
17731774
this.renderProjectsList();
@@ -1798,91 +1799,133 @@ export abstract class TaskModal extends Modal {
17981799
}
17991800

18001801
protected initializeProjectsFromStrings(projects: string[]): void {
1801-
// Convert project string to ProjectItem objects
1802-
// This handles both old plain string projects and new [[link]] format
18031802
this.selectedProjectItems = [];
1803+
this.addProjectsFromStrings(projects);
1804+
// Don't render immediately - let the caller decide when to render
1805+
}
1806+
1807+
protected addProjectsFromStrings(projects: string[]): void {
1808+
// Convert project strings to ProjectItem objects.
1809+
// This handles both old plain string projects and new [[link]] format.
18041810

18051811
// Use the task's path as the source for resolving relative links
18061812
const sourcePath = this.getCurrentTaskPath() || "";
18071813

18081814
for (const projectString of projects) {
1809-
// Skip null, undefined, or empty strings
1810-
if (
1811-
!projectString ||
1812-
typeof projectString !== "string" ||
1813-
projectString.trim() === ""
1814-
) {
1815-
continue;
1816-
}
1815+
const projectItem = this.createProjectItemFromString(projectString, sourcePath);
1816+
if (!projectItem || this.hasProjectItem(projectItem)) continue;
1817+
this.selectedProjectItems.push(projectItem);
1818+
}
1819+
this.updateProjectsFromFiles();
1820+
// Don't render immediately - let the caller decide when to render
1821+
}
18171822

1818-
// Check if it's a wiki link format
1819-
const linkMatch = projectString.match(/^\[\[([^\]]+)\]\]$/);
1820-
if (linkMatch) {
1821-
const linkPath = linkMatch[1];
1823+
private createProjectItemFromString(projectString: string, sourcePath: string): ProjectItem | null {
1824+
// Skip null, undefined, or empty strings
1825+
if (
1826+
!projectString ||
1827+
typeof projectString !== "string" ||
1828+
projectString.trim() === ""
1829+
) {
1830+
return null;
1831+
}
1832+
1833+
// Check if it's a wiki link format
1834+
const linkMatch = projectString.match(/^\[\[([^\]]+)\]\]$/);
1835+
if (linkMatch) {
1836+
const linkPath = linkMatch[1];
1837+
const file = this.resolveLink(linkPath, sourcePath);
1838+
if (file) {
1839+
// Resolved link
1840+
return {
1841+
file,
1842+
name: file.basename,
1843+
link: projectString,
1844+
};
1845+
} else {
1846+
// Unresolved link - still add it!
1847+
const displayName = linkPath.split("|")[0]; // Strip alias if present
1848+
return {
1849+
name: displayName,
1850+
link: projectString,
1851+
unresolved: true,
1852+
};
1853+
}
1854+
} else {
1855+
// Check if it's a markdown link format [text](path)
1856+
const markdownMatch = projectString.match(/^\[([^\]]*)\]\(([^)]+)\)$/);
1857+
if (markdownMatch) {
1858+
const linkPath = parseLinkToPath(projectString);
18221859
const file = this.resolveLink(linkPath, sourcePath);
18231860
if (file) {
1824-
// Resolved link
1825-
this.selectedProjectItems.push({
1861+
// Resolved markdown link
1862+
return {
18261863
file,
18271864
name: file.basename,
18281865
link: projectString,
1829-
});
1866+
};
18301867
} else {
1831-
// Unresolved link - still add it!
1832-
const displayName = linkPath.split("|")[0]; // Strip alias if present
1833-
this.selectedProjectItems.push({
1868+
// Unresolved markdown link
1869+
const displayName = markdownMatch[1] || linkPath;
1870+
return {
18341871
name: displayName,
18351872
link: projectString,
18361873
unresolved: true,
1837-
});
1874+
};
18381875
}
18391876
} else {
1840-
// Check if it's a markdown link format [text](path)
1841-
const markdownMatch = projectString.match(/^\[([^\]]*)\]\(([^)]+)\)$/);
1842-
if (markdownMatch) {
1843-
const linkPath = parseLinkToPath(projectString);
1844-
const file = this.resolveLink(linkPath, sourcePath);
1845-
if (file) {
1846-
// Resolved markdown link
1847-
this.selectedProjectItems.push({
1848-
file,
1849-
name: file.basename,
1850-
link: projectString,
1851-
});
1852-
} else {
1853-
// Unresolved markdown link
1854-
const displayName = markdownMatch[1] || linkPath;
1855-
this.selectedProjectItems.push({
1856-
name: displayName,
1857-
link: projectString,
1858-
unresolved: true,
1859-
});
1860-
}
1877+
// For backwards compatibility, try to find a file with this name
1878+
const files = this.getMarkdownFiles();
1879+
const matchingFile = files.find(
1880+
(f) => f.basename === projectString || f.name === projectString + ".md"
1881+
);
1882+
if (matchingFile) {
1883+
return {
1884+
file: matchingFile,
1885+
name: matchingFile.basename,
1886+
link: `[[${matchingFile.basename}]]`,
1887+
};
18611888
} else {
1862-
// For backwards compatibility, try to find a file with this name
1863-
const files = this.getMarkdownFiles();
1864-
const matchingFile = files.find(
1865-
(f) => f.basename === projectString || f.name === projectString + ".md"
1866-
);
1867-
if (matchingFile) {
1868-
this.selectedProjectItems.push({
1869-
file: matchingFile,
1870-
name: matchingFile.basename,
1871-
link: `[[${matchingFile.basename}]]`,
1872-
});
1873-
} else {
1874-
// Plain text - preserve as-is
1875-
this.selectedProjectItems.push({
1876-
name: projectString,
1877-
link: projectString,
1878-
unresolved: true,
1879-
});
1880-
}
1889+
// Plain text - preserve as-is
1890+
return {
1891+
name: projectString,
1892+
link: projectString,
1893+
unresolved: true,
1894+
};
18811895
}
18821896
}
18831897
}
1884-
this.updateProjectsFromFiles();
1885-
// Don't render immediately - let the caller decide when to render
1898+
}
1899+
1900+
private hasProjectItem(candidate: ProjectItem): boolean {
1901+
const candidateKeys = this.getProjectDedupKeys(candidate);
1902+
return this.selectedProjectItems.some((existing) => {
1903+
const existingKeys = this.getProjectDedupKeys(existing);
1904+
return candidateKeys.some((key) => existingKeys.includes(key));
1905+
});
1906+
}
1907+
1908+
private getProjectDedupKeys(item: ProjectItem): string[] {
1909+
const keys = new Set<string>();
1910+
1911+
if (item.file?.path) {
1912+
keys.add(`path:${this.normalizeProjectPath(item.file.path)}`);
1913+
}
1914+
1915+
const parsedPath = parseLinkToPath(item.link);
1916+
if (parsedPath) {
1917+
keys.add(`path:${this.normalizeProjectPath(parsedPath)}`);
1918+
}
1919+
1920+
if (item.link) {
1921+
keys.add(`link:${item.link.trim().toLowerCase()}`);
1922+
}
1923+
1924+
return Array.from(keys);
1925+
}
1926+
1927+
private normalizeProjectPath(path: string): string {
1928+
return path.trim().replace(/\.md$/i, "").toLowerCase();
18861929
}
18871930

18881931
protected renderProjectsList(): void {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
2+
import { MockObsidian } from '../../__mocks__/obsidian';
3+
import type { App } from 'obsidian';
4+
5+
jest.mock('obsidian');
6+
7+
jest.mock('../../../src/services/NaturalLanguageParser', () => {
8+
const mockParserInstance = {
9+
parseInput: jest.fn(),
10+
getPreviewData: jest.fn(() => []),
11+
};
12+
13+
return {
14+
NaturalLanguageParser: {
15+
fromPlugin: jest.fn(() => mockParserInstance),
16+
},
17+
};
18+
});
19+
20+
const createMockApp = (mockApp: any): App => mockApp as unknown as App;
21+
22+
describe('Issue #1828: subtask project merging', () => {
23+
let mockApp: App;
24+
let mockPlugin: any;
25+
26+
beforeEach(() => {
27+
MockObsidian.reset();
28+
mockApp = createMockApp(MockObsidian.createMockApp());
29+
(mockApp.metadataCache as any).getFirstLinkpathDest = jest.fn().mockReturnValue(null);
30+
31+
mockPlugin = {
32+
app: mockApp,
33+
settings: {
34+
defaultTaskPriority: 'normal',
35+
defaultTaskStatus: 'open',
36+
taskTag: 'task',
37+
taskIdentificationMethod: 'property',
38+
taskCreationDefaults: {
39+
defaultDueDate: 'none',
40+
defaultScheduledDate: 'none',
41+
defaultContexts: '',
42+
defaultTags: '',
43+
defaultProjects: '',
44+
defaultTimeEstimate: 0,
45+
defaultRecurrence: 'none',
46+
defaultReminders: [],
47+
},
48+
userFields: [],
49+
enableNaturalLanguageInput: true,
50+
useFrontmatterMarkdownLinks: false,
51+
},
52+
i18n: {
53+
translate: jest.fn((key: string) => key),
54+
},
55+
};
56+
});
57+
58+
it('preserves the pre-populated parent project when parsed projects are added', async () => {
59+
const modal = new TaskCreationModal(mockApp, mockPlugin, {
60+
prePopulatedValues: {
61+
projects: ['[[TaskNote A]]'],
62+
},
63+
});
64+
65+
await (modal as any).initializeFormData();
66+
(modal as any).applyParsedData({
67+
title: 'Child task',
68+
projects: ['[[TaskNote B]]'],
69+
});
70+
71+
const taskData = (modal as any).buildTaskData();
72+
73+
expect(taskData.projects).toEqual(['[[TaskNote A]]', '[[TaskNote B]]']);
74+
});
75+
76+
it('does not duplicate a project already present before parsing', async () => {
77+
const modal = new TaskCreationModal(mockApp, mockPlugin, {
78+
prePopulatedValues: {
79+
projects: ['[[TaskNote A]]'],
80+
},
81+
});
82+
83+
await (modal as any).initializeFormData();
84+
(modal as any).applyParsedData({
85+
title: 'Child task',
86+
projects: ['[[TaskNote A]]', '[[TaskNote B]]'],
87+
});
88+
89+
const taskData = (modal as any).buildTaskData();
90+
91+
expect(taskData.projects).toEqual(['[[TaskNote A]]', '[[TaskNote B]]']);
92+
});
93+
});

0 commit comments

Comments
 (0)