import type { Plugin, TAbstractFile } from 'obsidian' import { App, Notice, TFile, TFolder, normalizePath } from 'obsidian' import type { PMSettings, Project, ProjectPatch, ResolvedProjectConfig, StatusConfig, Task } from '../types' import { DEFAULT_SETTINGS, makeProject, makeTask } from '../types' import { today } from '../dates' import { isTerminalStatus } from '../utils' import { archiveTask as doArchiveTask, unarchiveTask as doUnarchiveTask } from './ArchiveOps' import { resolveProjectConfig } from './ProjectConfig' import { computeSchedule } from './Scheduler' import { findParentId, findTaskById, indexAddSubtree, indexRemoveSubtree, indexSetParent, rebuildTaskIndex } from './TaskIndex' import { addTaskToTree, cloneTaskSubtree, deleteTaskFromTree, flattenTasks, moveTaskInTree, updateTaskInTree } from './TaskTreeOps' import { hydrateProjectFromFrontmatter, hydrateTaskFromFile, hydrateTasks } from './YamlHydrator' import { FRONTMATTER_KEY, TASK_FRONTMATTER_KEY, parseFrontmatter, stripAutoGeneratedContent } from './YamlParser' import { buildTaskFrontmatter, serializeProject, serializeTask, taskFilePath, TASK_SLUG_MAX_LENGTH } from './YamlSerializer' import { ensureFolder, moveTaskAttachmentFolder } from './vaultFs' import type { ImportNoteOptions, TaskSource } from './TaskSource' /** 'fm' writes via processFrontMatter; 'full' rewrites the body too, via vault.process. */ type DirtyKind = 'fm' | 'full' function patchNeedsBodyRewrite(patch: Partial): boolean { // `subtasks` changes the parent's `## Subtasks` list, which lives in the body. return patch.description !== undefined || patch.archived !== undefined || patch.subtasks !== undefined } /** A basename of this exact length that prefixes the title's slug is kept as-is. */ const LEGACY_SLUG_CAP = 40 /** New tasks get the bare slug; an existing file stays put while its name still matches the title. */ function resolveTaskPath(task: Task, folder: string, previousPath: string | undefined): string { const desired = taskFilePath(task.title, folder) if (!previousPath) return desired const desiredBasename = desired.slice(desired.lastIndexOf('/') + 1).replace(/\.md$/, '') const previousFolder = previousPath.slice(0, previousPath.lastIndexOf('/')) const previousBasename = previousPath.slice(previousPath.lastIndexOf('/') + 1).replace(/\.md$/, '') if (previousFolder !== folder) return desired const legacyBasename = `${desiredBasename}-${task.id.slice(0, 8)}` if (previousBasename === legacyBasename) return previousPath if (previousBasename.length === LEGACY_SLUG_CAP && previousBasename === desiredBasename.slice(0, LEGACY_SLUG_CAP)) { return previousPath } return desired } export class TaskFileNameConflictError extends Error { constructor(public readonly path: string) { super(`A note named "${fileNameFromPath(path)}" already exists.`) this.name = 'TaskFileNameConflictError' } get fileName(): string { return fileNameFromPath(this.path) } } function fileNameFromPath(path: string): string { return path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '') } /** * All read/write operations against the vault. `Projects/.md` holds project * metadata; `Projects/_tasks/.md` holds one file per task. The in-memory * `Project.tasks` tree is assembled from those files on load. */ export class ProjectStore implements TaskSource { private saveQueues = new Map>() /** Task IDs needing a write on the next save. Tasks with no filePath are always 'full'. */ private dirtyTasks = new Map>() /** * Objects whose in-memory `description` is known to match disk. Cache-loaded ones * start out absent: their description is empty until loadTaskBody / loadProjectBody * fills it, or a 'full' save reads it back inline. */ private hydratedBodies = new WeakSet() /** * The one live Project per path, held as the loading promise so concurrent loads * collapse onto a single instance. Mutators and reloads update that object in * place; it is replaced only when the project is deleted. */ private projectCache = new Map>() private changeHandlers = new Set<(path: string) => void>() private reloadTimers = new Map() private static readonly RELOAD_DEBOUNCE_MS = 300 /** Paths we wrote ourselves, timestamped, so vault listeners can ignore the echo. */ private selfWrites = new Map() private static readonly SELF_WRITE_WINDOW_MS = 5000 constructor( private app: App, private getSettings: () => PMSettings = () => DEFAULT_SETTINGS ) {} configFor(project: Project): ResolvedProjectConfig { return resolveProjectConfig(project, this.getSettings()) } private statusesFor(project: Project): StatusConfig[] { return this.configFor(project).statuses } private markDirty(project: Project, taskIds: Iterable, kind: DirtyKind): void { let map = this.dirtyTasks.get(project.filePath) if (!map) { map = new Map() this.dirtyTasks.set(project.filePath, map) } for (const id of taskIds) { // Upgrade-only: 'full' wins over 'fm', never the reverse. if (map.get(id) === 'full') continue map.set(id, kind) } } private markSubtreeDirty(project: Project, taskId: string, kind: DirtyKind): void { const task = findTaskById(project, taskId) if (!task) return this.markDirty(project, [taskId], kind) for (const ft of flattenTasks(task.subtasks)) { this.markDirty(project, [ft.task.id], kind) } } private markAllDirty(project: Project, kind: DirtyKind): void { const ids: string[] = [] for (const ft of flattenTasks(project.tasks)) ids.push(ft.task.id) this.markDirty(project, ids, kind) } private clearDirty(project: Project): void { this.dirtyTasks.delete(project.filePath) } private markSelfWrite(path: string): void { if (this.selfWrites.size > 256) { const cutoff = Date.now() - ProjectStore.SELF_WRITE_WINDOW_MS for (const [p, t] of this.selfWrites) { if (t < cutoff) this.selfWrites.delete(p) } } this.selfWrites.set(path, Date.now()) } private peekSelfWrite(path: string): boolean { const ts = this.selfWrites.get(path) return ts !== undefined && Date.now() - ts < ProjectStore.SELF_WRITE_WINDOW_MS } /** Returns the unsubscribe function. */ onProjectChanged(handler: (path: string) => void): () => void { this.changeHandlers.add(handler) return () => this.changeHandlers.delete(handler) } private emitChange(path: string): void { for (const handler of this.changeHandlers) handler(path) } /** Call once from onload. The only place the plugin listens for project file changes. */ registerVaultSync(plugin: Plugin): void { const onChange = (file: TAbstractFile): void => this.syncPath(file.path) plugin.registerEvent(this.app.vault.on('create', onChange)) plugin.registerEvent(this.app.vault.on('modify', onChange)) plugin.registerEvent(this.app.vault.on('delete', onChange)) plugin.registerEvent( this.app.vault.on('rename', (file, oldPath) => { this.syncPath(file.path) this.syncPath(oldPath) }) ) plugin.register(() => { for (const timer of this.reloadTimers.values()) window.clearTimeout(timer) this.reloadTimers.clear() }) } /** An external write landed: reload the live project so every holder sees it. */ private syncPath(path: string): void { if (this.projectCache.size === 0) return if (this.peekSelfWrite(path)) return for (const key of this.projectCache.keys()) { if (path === key || path.startsWith(key.replace(/\.md$/, '_tasks') + '/')) { const pending = this.reloadTimers.get(key) if (pending !== undefined) window.clearTimeout(pending) this.reloadTimers.set( key, window.setTimeout(() => { this.reloadTimers.delete(key) void this.reloadProject(key) }, ProjectStore.RELOAD_DEBOUNCE_MS) ) } } } private async reloadProject(path: string): Promise { const live = await this.projectCache.get(path) if (!live) return await this.queue(path, async () => { const file = this.app.vault.getAbstractFileByPath(path) if (!(file instanceof TFile)) { this.projectCache.delete(path) this.emitChange(path) return } const fresh = await this.readProject(file) if (!fresh) return this.adopt(live, fresh) this.emitChange(path) }) } /** Copy a freshly read project onto the live instance every holder already has. */ private adopt(live: Project, fresh: Project): void { const { taskIndex, ...fields } = fresh Object.assign(live, fields) rebuildTaskIndex(live) if (this.hydratedBodies.has(fresh)) this.hydratedBodies.add(live) else this.hydratedBodies.delete(live) } /** Serialize work on one project: saves and reloads never interleave. */ private queue(key: string, work: () => Promise): Promise { const prev = this.saveQueues.get(key) ?? Promise.resolve() const next = (async () => { await prev return work() })() this.saveQueues.set( key, (async () => { try { await next } catch { // swallow so the next queued item still runs after a failure } })() ) return next } async ensureFolder(folderPath: string): Promise { await ensureFolder(this.app, folderPath) } private projectTaskFolder(project: Project): string { return project.filePath.replace(/\.md$/, '_tasks') } async loadAllProjects(folder: string): Promise { await this.ensureFolder(folder) const folderObj = this.app.vault.getAbstractFileByPath(folder) const files: TFile[] = [] if (folderObj instanceof TFolder) { for (const child of folderObj.children) { if (child instanceof TFile && child.extension === 'md') files.push(child) } } const loaded = await Promise.all(files.map((f) => this.loadProject(f))) const projects = loaded.filter((p): p is Project => p !== null) return projects.sort((a, b) => a.title.localeCompare(b.title)) } /** Concurrent callers share the one in-flight read, so a project never exists twice. */ async loadProject(file: TFile): Promise { const live = this.projectCache.get(file.path) if (live) return live const loading = this.readProject(file) this.projectCache.set(file.path, loading) const project = await loading if (!project) this.projectCache.delete(file.path) return project } private async readProject(file: TFile): Promise { try { // metadataCache lets us skip the disk read, but only for new-format projects; // old-format ones need the body to migrate. const cached = this.app.metadataCache.getFileCache(file)?.frontmatter const cacheUsable = cached && cached[FRONTMATTER_KEY] === true && !Array.isArray(cached.tasks) && Array.isArray(cached.taskIds) let frontmatter: Record | null = null let body = '' let bodyRead = false if (cacheUsable) { frontmatter = cached } else { const content = await this.app.vault.cachedRead(file) const parsed = parseFrontmatter(content) frontmatter = parsed.frontmatter body = parsed.body bodyRead = true } if (!frontmatter || frontmatter[FRONTMATTER_KEY] !== true) return null const hasEmbeddedTasks = Array.isArray(frontmatter.tasks) && frontmatter.tasks.length > 0 const project = hydrateProjectFromFrontmatter(frontmatter, body, file.path, file.basename) if (bodyRead) this.hydratedBodies.add(project) if (hasEmbeddedTasks) { project.tasks = hydrateTasks((frontmatter.tasks as unknown[]) ?? []) rebuildTaskIndex(project) // Old format: no per-task files on disk yet. this.markAllDirty(project, 'full') } else { const taskFolder = this.projectTaskFolder(project) const taskIds = Array.isArray(frontmatter.taskIds) ? (frontmatter.taskIds as string[]) : [] project.tasks = await this.loadTasksFromFolder(taskFolder, taskIds) rebuildTaskIndex(project) this.clearDirty(project) } return project } catch (e) { console.error(`[PM] Failed to load project ${file.path}:`, e) new Notice(`Project Manager: Failed to load "${file.basename}". Check console for details.`) return null } } private async loadTasksFromFolder(folderPath: string, topLevelIds: string[]): Promise { const folder = this.app.vault.getAbstractFileByPath(folderPath) if (!(folder instanceof TFolder)) return [] const taskMap = new Map() const subtaskIdsMap = new Map() const parentIdMap = new Map() const archivePrefix = normalizePath(folderPath + '/Archive') + '/' const files: TFile[] = [] const collect = (f: TFolder): void => { for (const child of f.children) { if (child instanceof TFile && child.extension === 'md') files.push(child) else if (child instanceof TFolder) collect(child) } } collect(folder) const results = await Promise.all(files.map((file) => this.loadTaskFile(file))) for (let i = 0; i < files.length; i++) { const { task, subtaskIds, parentId } = results[i] if (task) { if (files[i].path.startsWith(archivePrefix)) { task.archived = true } taskMap.set(task.id, task) if (subtaskIds.length) subtaskIdsMap.set(task.id, subtaskIds) if (parentId) parentIdMap.set(task.id, parentId) } } for (const [taskId, sids] of subtaskIdsMap) { const task = taskMap.get(taskId) if (!task) continue task.subtasks = [] for (const sid of sids) { const sub = taskMap.get(sid) if (sub) task.subtasks.push(sub) } } // Re-parent orphans from the parentId in their own file. const childIds = new Set() for (const t of taskMap.values()) { for (const s of t.subtasks) childIds.add(s.id) } for (const [taskId, pid] of parentIdMap) { if (childIds.has(taskId)) continue const parent = taskMap.get(pid) if (!parent) continue const task = taskMap.get(taskId) if (!task) continue parent.subtasks.push(task) childIds.add(taskId) if (!subtaskIdsMap.has(pid)) subtaskIdsMap.set(pid, []) const sids = subtaskIdsMap.get(pid) if (sids && !sids.includes(taskId)) sids.push(taskId) console.warn( `[PM] Self-healed orphan: re-parented task "${task.title}" (${taskId}) under "${parent.title}" (${pid})` ) } const result: Task[] = [] const pushed = new Set() for (const id of topLevelIds) { if (pushed.has(id)) continue const task = taskMap.get(id) if (task) { result.push(task) pushed.add(id) } } for (const task of taskMap.values()) { if (pushed.has(task.id)) continue if (!childIds.has(task.id)) result.push(task) } return result } async loadTaskFile(file: TFile): Promise<{ task: Task | null; subtaskIds: string[]; parentId: string | null }> { try { // metadataCache lets us skip the body read; description stays empty until // loadTaskBody fills it. const cached = this.app.metadataCache.getFileCache(file)?.frontmatter if (cached && cached[TASK_FRONTMATTER_KEY] === true) { return hydrateTaskFromFile(cached, '', file.path) } const content = await this.app.vault.cachedRead(file) const { frontmatter, body } = parseFrontmatter(content) if (!frontmatter || frontmatter[TASK_FRONTMATTER_KEY] !== true) { return { task: null, subtaskIds: [], parentId: null } } const result = hydrateTaskFromFile(frontmatter, body, file.path) this.hydratedBodies.add(result.task) return result } catch (e) { if (e instanceof Error && e.message.includes('ENOENT')) { console.warn(`[PM] Task file no longer exists, skipping: ${file.path}`) } else { console.error(`[PM] Failed to load task ${file.path}:`, e) new Notice(`Project Manager: Failed to load task "${file.basename}". Check console for details.`) } return { task: null, subtaskIds: [], parentId: null } } } /** Fill in the description from the file body. No-op once it matches disk. */ async loadTaskBody(task: Task): Promise { if (this.hydratedBodies.has(task)) return if (!task.filePath) { this.hydratedBodies.add(task) return } const file = this.app.vault.getAbstractFileByPath(task.filePath) if (!(file instanceof TFile)) { this.hydratedBodies.add(task) return } const content = await this.app.vault.cachedRead(file) const { body } = parseFrontmatter(content) task.description = stripAutoGeneratedContent(body) this.hydratedBodies.add(task) } /** Same as loadTaskBody but for the project file's body. */ async loadProjectBody(project: Project): Promise { if (this.hydratedBodies.has(project)) return const file = this.app.vault.getAbstractFileByPath(project.filePath) if (!(file instanceof TFile)) { this.hydratedBodies.add(project) return } const content = await this.app.vault.cachedRead(file) const { frontmatter, body } = parseFrontmatter(content) const fmDesc = frontmatter?.description project.description = typeof fmDesc === 'string' ? fmDesc : body.trim() this.hydratedBodies.add(project) } async saveProject(project: Project): Promise { return this.queue(project.filePath, () => this.doSaveProject(project)) } /** Patch-only, so the editor's stale draft can't overwrite fields it didn't change. */ async updateProject(project: Project, patch: ProjectPatch): Promise { Object.assign(project, patch) if (patch.description !== undefined) this.hydratedBodies.add(project) await this.saveProject(project) } private async doSaveProject(project: Project): Promise { // Snapshot before any await, so concurrent markDirty calls land in the next save. const dirty = this.dirtyTasks.get(project.filePath) ?? new Map() this.dirtyTasks.delete(project.filePath) try { project.updatedAt = new Date().toISOString() const taskFolder = this.projectTaskFolder(project) await this.ensureFolder(taskFolder) await this.saveDirtyTasks(project, taskFolder, dirty) const file = this.app.vault.getAbstractFileByPath(project.filePath) if (file instanceof TFile) { this.markSelfWrite(project.filePath) await this.app.vault.process(file, (content) => { if (!this.hydratedBodies.has(project)) { const { frontmatter, body } = parseFrontmatter(content) const fmDesc = frontmatter?.description project.description = typeof fmDesc === 'string' ? fmDesc : body.trim() } return serializeProject(project, this.statusesFor(project)) }) this.hydratedBodies.add(project) } else { const content = serializeProject(project, this.statusesFor(project)) this.markSelfWrite(project.filePath) await this.app.vault.create(project.filePath, content) this.hydratedBodies.add(project) } // A project saved before it was ever loaded (creation, migration) becomes the // live instance, but a save never replaces an instance others already hold. if (!this.projectCache.has(project.filePath)) { this.projectCache.set(project.filePath, Promise.resolve(project)) } this.emitChange(project.filePath) } catch (e) { // Merge the snapshot back so the next save retries. for (const [id, kind] of dirty) this.markDirty(project, [id], kind) if (e instanceof TaskFileNameConflictError) throw e console.error(`[PM] Failed to save project "${project.title}":`, e) new Notice(`Project Manager: Failed to save "${project.title}". Check console for details.`) throw e } } private async saveDirtyTasks(project: Project, folder: string, dirty: Map): Promise { // Safety net: a task never written to disk gets a file even if nothing marked it. for (const [id, entry] of project.taskIndex) { if (!entry.task.filePath && !dirty.has(id)) dirty.set(id, 'full') } if (dirty.size === 0) return const jobs: { task: Task; parentTask: Task | null; folder: string; kind: DirtyKind }[] = [] const targetPaths = new Set() let hasArchived = false for (const [id, kind] of dirty) { const entry = project.taskIndex.get(id) if (!entry) continue // deleted after being marked dirty const { task, parentId } = entry const targetFolder = task.archived ? normalizePath(folder + '/Archive') : folder if (task.archived) hasArchived = true // Two dirty tasks resolving to one file would race below into a generic // create error; catching it here keeps the typed one. const path = normalizePath(resolveTaskPath(task, targetFolder, task.filePath)) if (targetPaths.has(path)) throw new TaskFileNameConflictError(path) targetPaths.add(path) jobs.push({ task, parentTask: parentId ? findTaskById(project, parentId) : null, folder: targetFolder, kind }) } if (hasArchived) await this.ensureFolder(normalizePath(folder + '/Archive')) const errors: Error[] = [] const batchSize = 16 for (let i = 0; i < jobs.length; i += batchSize) { const results = await Promise.allSettled( jobs.slice(i, i + batchSize).map((j) => this.saveTaskFile(j.task, project, j.parentTask, j.folder, j.kind)) ) for (const r of results) { if (r.status === 'rejected') errors.push(r.reason instanceof Error ? r.reason : new Error(String(r.reason))) } } if (errors.length) { if (errors.length === 1 && errors[0] instanceof TaskFileNameConflictError) throw errors[0] throw new Error(`Failed to save ${errors.length} task(s): ${errors.map((e) => e.message).join('; ')}`) } } private async saveTaskFile( task: Task, project: Project, parentTask: Task | null, folder: string, kind: DirtyKind ): Promise { const previousPath = task.filePath const filePath = normalizePath(resolveTaskPath(task, folder, previousPath)) const renamed = previousPath !== undefined && previousPath !== filePath try { if (kind === 'fm' && previousPath && !renamed) { const existing = this.app.vault.getAbstractFileByPath(filePath) if (existing instanceof TFile) { this.markSelfWrite(filePath) const next = buildTaskFrontmatter(task, project, parentTask) await this.app.fileManager.processFrontMatter(existing, (fm: Record) => { for (const k of Object.keys(fm)) Reflect.deleteProperty(fm, k) Object.assign(fm, next) }) return } // File is missing; fall through and recreate it. } const existing = this.app.vault.getAbstractFileByPath(filePath) if (existing instanceof TFile && existing.path !== previousPath) { throw new TaskFileNameConflictError(filePath) } if (existing instanceof TFile) { this.markSelfWrite(filePath) await this.app.vault.process(existing, (content) => { if (!this.hydratedBodies.has(task)) { task.description = stripAutoGeneratedContent(parseFrontmatter(content).body) } return serializeTask(task, project, parentTask, this.statusesFor(project)) }) } else { // Renaming an unhydrated task: recover the description from the old file // before the new one is created. if (!this.hydratedBodies.has(task) && previousPath) { const oldFile = this.app.vault.getAbstractFileByPath(previousPath) if (oldFile instanceof TFile) { const content = await this.app.vault.cachedRead(oldFile) task.description = stripAutoGeneratedContent(parseFrontmatter(content).body) } } const content = serializeTask(task, project, parentTask, this.statusesFor(project)) this.markSelfWrite(filePath) await this.app.vault.create(filePath, content) } task.filePath = filePath this.hydratedBodies.add(task) if (renamed && previousPath) { const oldFile = this.app.vault.getAbstractFileByPath(previousPath) if (oldFile instanceof TFile) { this.markSelfWrite(previousPath) await this.app.fileManager.trashFile(oldFile) } // Keep the task's attachment folder with the renamed note. this.markSelfWrite(this.taskFolder(previousPath)) this.markSelfWrite(this.taskFolder(filePath)) await moveTaskAttachmentFolder(this.app, previousPath, filePath) } } catch (e) { if (!(e instanceof TaskFileNameConflictError)) { console.error(`[PM] Failed to save task "${task.title}" (${task.id}):`, e) } throw e } } /** Pre-flight check so callers can surface the conflict inline instead of on save. */ findTaskFileConflict(project: Project, task: Task): TaskFileNameConflictError | null { const baseFolder = this.projectTaskFolder(project) const folder = task.archived ? normalizePath(baseFolder + '/Archive') : baseFolder const desired = normalizePath(resolveTaskPath(task, folder, task.filePath)) if (desired === task.filePath) return null const existing = this.app.vault.getAbstractFileByPath(desired) return existing instanceof TFile ? new TaskFileNameConflictError(desired) : null } async createProject(title: string, folder: string): Promise { const safeName = title.replace(/[\\/:*?"<>|]/g, '-') const filePath = normalizePath(`${folder}/${safeName}.md`) const project = makeProject(title, filePath) await this.ensureFolder(this.projectTaskFolder(project)) await this.saveProject(project) return project } async insertTask(project: Project, task: Task, parentId: string | null = null): Promise { if (!task.completed && isTerminalStatus(task.status, this.statusesFor(project))) { task.completed = today().toString() } this.hydratedBodies.add(task) addTaskToTree(project.tasks, task, parentId) indexAddSubtree(project, task, parentId) this.markDirty(project, [task.id], 'full') if (parentId) this.markDirty(project, [parentId], 'full') await this.saveProject(project) } /** * Turn a vault note into a top-level task file; its body becomes the description. * The project file isn't rewritten: the next load picks the task up as an orphan. */ async importNoteAsTask(project: Project, file: TFile, opts: ImportNoteOptions): Promise<'imported' | 'skipped'> { const content = await this.app.vault.read(file) const { frontmatter, body } = parseFrontmatter(content) if (frontmatter?.[TASK_FRONTMATTER_KEY] === true) return 'skipped' const task = makeTask({ title: file.basename, description: body, status: opts.status, priority: opts.priority }) const folder = this.projectTaskFolder(project) await this.ensureFolder(folder) const newFilePath = taskFilePath(task.title, folder) const newContent = serializeTask(task, project, null, this.statusesFor(project)) if (opts.handling === 'move') { await this.app.fileManager.renameFile(file, newFilePath) const moved = this.app.vault.getAbstractFileByPath(newFilePath) if (moved instanceof TFile) { await this.app.vault.process(moved, () => newContent) } } else { await this.app.vault.create(newFilePath, newContent) } return 'imported' } /** * Persist a converted task forest as task files. 'move' turns each source note into * the task file, 'copy' leaves sources untouched. The project file isn't rewritten: * the next load recovers order and parenting from the written parentId/subtaskIds. */ async importTaskForest( project: Project, roots: Task[], sources: Map, handling: 'move' | 'copy' ): Promise { const baseFolder = this.projectTaskFolder(project) await this.ensureFolder(baseFolder) let imported = 0 const writeNode = async (task: Task, parent: Task | null): Promise => { const folder = task.archived ? normalizePath(baseFolder + '/Archive') : baseFolder if (task.archived) await this.ensureFolder(folder) const source = sources.get(task.id) if (source) { const { body } = parseFrontmatter(await this.app.vault.read(source)) task.description = body } const desired = taskFilePath(task.title, folder) const dest = this.uniqueChildPath(folder, desired.slice(desired.lastIndexOf('/') + 1)) const content = serializeTask(task, project, parent, this.statusesFor(project)) if (handling === 'move' && source) { await this.app.fileManager.renameFile(source, dest) const moved = this.app.vault.getAbstractFileByPath(dest) if (moved instanceof TFile) { await this.app.vault.process(moved, () => content) } } else { await this.app.vault.create(dest, content) } imported++ for (const child of task.subtasks) { await writeNode(child, task) } } for (const root of roots) { await writeNode(root, null) } return imported } async duplicateTask(project: Project, sourceId: string, includeSubtasks: boolean): Promise { const source = findTaskById(project, sourceId) if (!source) return null const copy = cloneTaskSubtree(source, includeSubtasks) // Filenames come from the title slug within one flat folder, so a clone keeping // the source title would write over the original. Reserve a free "(copy)" title // for every node, checking the clones we're adding so siblings don't collide. const baseFolder = this.projectTaskFolder(project) const claimed = new Set() const usedTitles = new Set(flattenTasks(project.tasks).map((f) => f.task.title)) const claimName = (task: Task): void => { const folder = task.archived ? normalizePath(baseFolder + '/Archive') : baseFolder this.assignCopyName(task, folder, usedTitles, claimed) } // A clone has no file yet, and its in-memory description must be written verbatim. claimName(copy) this.hydratedBodies.add(copy) for (const ft of flattenTasks(copy.subtasks)) { claimName(ft.task) this.hydratedBodies.add(ft.task) } const parentId = findParentId(project, sourceId) addTaskToTree(project.tasks, copy, parentId) moveTaskInTree(project.tasks, copy.id, sourceId, 'after') indexAddSubtree(project, copy, parentId) this.markSubtreeDirty(project, copy.id, 'full') if (parentId) this.markDirty(project, [parentId], 'full') await this.saveProject(project) return copy } private assignCopyName(task: Task, folder: string, usedTitles: Set, claimed: Set): void { const base = task.title.replace(/(?: \(copy(?: \d+)?\))+$/, '') for (let n = 1; ; n++) { const suffix = n === 1 ? ' (copy)' : ` (copy ${n})` const room = TASK_SLUG_MAX_LENGTH - suffix.length const title = (base.length > room ? base.slice(0, room).trimEnd() : base) + suffix const path = normalizePath(taskFilePath(title, folder)) if ( !usedTitles.has(title) && !claimed.has(path) && !(this.app.vault.getAbstractFileByPath(path) instanceof TFile) ) { usedTitles.add(title) claimed.add(path) task.title = title return } } } async moveTask(project: Project, taskId: string, newParentId: string | null): Promise { const task = findTaskById(project, taskId) if (!task) return const oldParentId = findParentId(project, taskId) deleteTaskFromTree(project.tasks, taskId) addTaskToTree(project.tasks, task, newParentId) indexSetParent(project, taskId, newParentId) // The moved task's Parent link and both parents' Subtasks lists live in the body. this.markDirty(project, [taskId], 'full') if (oldParentId) this.markDirty(project, [oldParentId], 'full') if (newParentId) this.markDirty(project, [newParentId], 'full') await this.saveProject(project) } async moveTasks(project: Project, taskIds: string[], newParentId: string | null): Promise { for (const id of taskIds) { const task = findTaskById(project, id) if (!task) continue const oldParentId = findParentId(project, id) deleteTaskFromTree(project.tasks, id) addTaskToTree(project.tasks, task, newParentId) indexSetParent(project, id, newParentId) this.markDirty(project, [id], 'full') if (oldParentId) this.markDirty(project, [oldParentId], 'full') } if (newParentId) this.markDirty(project, [newParentId], 'full') await this.saveProject(project) } /** * Stamp or clear `completed` when a status crosses the complete boundary, mutating * `patch` so it lands in the same save. An explicit `completed` in the patch wins. */ private stampCompletion(project: Project, task: Task, patch: Partial): void { if (patch.status === undefined) return if (patch.completed !== undefined && patch.completed !== task.completed) return const statuses = this.statusesFor(project) const wasComplete = isTerminalStatus(task.status, statuses) const nowComplete = isTerminalStatus(patch.status, statuses) if (nowComplete && !wasComplete) patch.completed = today().toString() else if (!nowComplete && wasComplete) patch.completed = '' } /** Call between `stampCompletion` and the patch reaching the tree, while `task` still holds its old value. */ private completionMoved(task: Task, patch: Partial): boolean { return patch.completed !== undefined && patch.completed !== task.completed } private async scheduleAfterEarlyFinish(project: Project, taskIds: string[]): Promise { if (taskIds.length === 0) return if (!this.configFor(project).pullForwardOnEarlyFinish) return for (const id of taskIds) await this.scheduleAfterChange(project, id) } async updateTask(project: Project, taskId: string, patch: Partial): Promise { const task = findTaskById(project, taskId) const oldTitle = task?.title if (task) this.stampCompletion(project, task, patch) const completionMoved = task !== null && this.completionMoved(task, patch) // The editor saves the whole task, so snapshot the subtree to diff against. const oldSubtree = task && patch.subtasks !== undefined ? flattenTasks(task.subtasks).map((f) => f.task) : [] updateTaskInTree(project.tasks, taskId, patch) const titleChanged = task && patch.title !== undefined && patch.title !== oldTitle // A title change renames the file, forcing saveTaskFile's rename branch. const kind: DirtyKind = patchNeedsBodyRewrite(patch) || titleChanged ? 'full' : 'fm' this.markDirty(project, [taskId], kind) if (task && patch.description !== undefined) this.hydratedBodies.add(task) if (task && patch.subtasks !== undefined) { await this.reconcileSubtasks(project, task, oldSubtree) } else if (task && titleChanged) { // The rename breaks direct children's Parent link. for (const sub of task.subtasks) this.markDirty(project, [sub.id], 'full') } await this.saveProject(project) if (completionMoved) await this.scheduleAfterEarlyFinish(project, [taskId]) } /** * Make the index and the on-disk files match a subtree the editor saved wholesale: * re-point the index, mark new/renamed/restatused subtasks for a write, and trash * files for the ones removed. Unchanged subtasks aren't rewritten. */ private async reconcileSubtasks(project: Project, parent: Task, oldSubtree: Task[]): Promise { indexAddSubtree(project, parent, findParentId(project, parent.id)) const old = new Map(oldSubtree.map((t) => [t.id, t])) const liveIds = new Set() for (const { task } of flattenTasks(parent.subtasks)) { liveIds.add(task.id) const prev = old.get(task.id) if (!prev) { this.hydratedBodies.add(task) this.markDirty(project, [task.id], 'full') } else if (prev.title !== task.title) { this.markDirty(project, [task.id], 'full') } else if (prev.status !== task.status || prev.completed !== task.completed || prev.progress !== task.progress) { this.markDirty(project, [task.id], 'fm') } } const folder = this.projectTaskFolder(project) for (const removed of oldSubtree) { if (liveIds.has(removed.id)) continue project.taskIndex.delete(removed.id) // The snapshot is flat and already lists every descendant. if (removed.filePath) await this.deleteTaskFiles({ ...removed, subtasks: [] }, folder) } } /** Patch several tasks in one save. A `patch` function returning null skips its task. */ async updateTasks( project: Project, taskIds: string[], patch: Partial | ((task: Task) => Partial | null) ): Promise { const completionMovedIds: string[] = [] for (const id of taskIds) { const task = findTaskById(project, id) if (!task) continue const raw = typeof patch === 'function' ? patch(task) : patch if (!raw) continue // Copy before stamping so one task's completion date doesn't bleed onto the next. const p = { ...raw } this.stampCompletion(project, task, p) if (this.completionMoved(task, p)) completionMovedIds.push(id) const oldTitle = task.title updateTaskInTree(project.tasks, id, p) const titleChanged = p.title !== undefined && p.title !== oldTitle const kind: DirtyKind = patchNeedsBodyRewrite(p) || titleChanged ? 'full' : 'fm' this.markDirty(project, [id], kind) if (p.description !== undefined) this.hydratedBodies.add(task) if (titleChanged) { for (const sub of task.subtasks) this.markDirty(project, [sub.id], 'full') } } await this.saveProject(project) await this.scheduleAfterEarlyFinish(project, completionMovedIds) } /** Sibling order lives in the parent's subtaskIds, so only the parent is rewritten. */ async reorderTask(project: Project, taskId: string, targetId: string, position: 'before' | 'after'): Promise { if (!moveTaskInTree(project.tasks, taskId, targetId, position)) return const parentId = findParentId(project, targetId) if (parentId) this.markDirty(project, [parentId], 'full') await this.saveProject(project) } async deleteTasks(project: Project, taskIds: string[]): Promise { const folder = this.projectTaskFolder(project) const dirtyParents = new Set() for (const id of taskIds) { const parentId = findParentId(project, id) if (parentId) dirtyParents.add(parentId) const task = findTaskById(project, id) if (task) { await this.deleteTaskFiles(task, folder) indexRemoveSubtree(project, task) } deleteTaskFromTree(project.tasks, id) } // The parents' Subtasks lists shrink. if (dirtyParents.size) this.markDirty(project, dirtyParents, 'full') await this.saveProject(project) } async archiveTask(project: Project, taskId: string): Promise { await doArchiveTask(this.app, project, taskId, (path) => this.markSelfWrite(path)) await this.saveProject(project) } async unarchiveTask(project: Project, taskId: string): Promise { await doUnarchiveTask(this.app, project, taskId, (path) => this.markSelfWrite(path)) await this.saveProject(project) } async deleteTask(project: Project, taskId: string): Promise { const parentId = findParentId(project, taskId) const task = findTaskById(project, taskId) if (task) { await this.deleteTaskFiles(task, this.projectTaskFolder(project)) indexRemoveSubtree(project, task) } deleteTaskFromTree(project.tasks, taskId) // The parent's Subtasks list shrinks. if (parentId) this.markDirty(project, [parentId], 'full') await this.saveProject(project) } private async deleteTaskFiles(task: Task, folder: string): Promise { for (const sub of task.subtasks) { await this.deleteTaskFiles(sub, folder) } if (task.filePath) { const file = this.app.vault.getAbstractFileByPath(task.filePath) if (file instanceof TFile) { this.markSelfWrite(task.filePath) await this.app.fileManager.trashFile(file) } const taskDir = this.app.vault.getAbstractFileByPath(this.taskFolder(task.filePath)) if (taskDir instanceof TFolder) { await this.deleteFolderRecursive(taskDir) } } } /** A task's own folder, holding its attachments. */ private taskFolder(taskFilePath: string): string { return taskFilePath.replace(/\.md$/, '') } /** Keeps a pasted or dropped file with the task, not in the vault-wide default folder. */ async saveTaskAttachment(project: Project, task: Task, fileName: string, data: ArrayBuffer): Promise { const taskPath = task.filePath ?? taskFilePath(task.title, this.projectTaskFolder(project)) const dir = normalizePath(`${this.taskFolder(taskPath)}/attachments`) this.markSelfWrite(this.taskFolder(taskPath)) this.markSelfWrite(dir) await this.ensureFolder(dir) const path = this.uniqueChildPath(dir, fileName) this.markSelfWrite(path) return this.app.vault.createBinary(path, data) } private uniqueChildPath(folder: string, fileName: string): string { const dot = fileName.lastIndexOf('.') const base = dot > 0 ? fileName.slice(0, dot) : fileName const ext = dot > 0 ? fileName.slice(dot) : '' let candidate = normalizePath(`${folder}/${base}${ext}`) for (let n = 1; this.app.vault.getAbstractFileByPath(candidate); n++) { candidate = normalizePath(`${folder}/${base} ${n}${ext}`) } return candidate } async deleteProject(project: Project): Promise { const taskFolder = this.projectTaskFolder(project) const folder = this.app.vault.getAbstractFileByPath(taskFolder) if (folder instanceof TFolder) { await this.deleteFolderRecursive(folder) } const file = this.app.vault.getAbstractFileByPath(project.filePath) if (file instanceof TFile) { this.markSelfWrite(project.filePath) await this.app.fileManager.trashFile(file) } this.clearDirty(project) this.saveQueues.delete(project.filePath) this.projectCache.delete(project.filePath) this.emitChange(project.filePath) } private async deleteFolderRecursive(folder: TFolder): Promise { for (const child of folder.children.slice()) { if (child instanceof TFile) { this.markSelfWrite(child.path) await this.app.fileManager.trashFile(child) } else if (child instanceof TFolder) { await this.deleteFolderRecursive(child) } } await this.app.fileManager.trashFile(folder) } /** * Apply dependency-based scheduling and save, returning the number of tasks * adjusted. A no-op when auto-scheduling is off, so callers needn't check. */ async scheduleAfterChange(project: Project, changedTaskId?: string): Promise { const config = this.configFor(project) if (!config.autoSchedule) return 0 const { patches } = computeSchedule(project.tasks, changedTaskId, config.statuses, config.pullForwardOnEarlyFinish) if (patches.length === 0) return 0 for (const p of patches) { updateTaskInTree(project.tasks, p.taskId, { start: p.start, due: p.due }) this.markDirty(project, [p.taskId], 'fm') } await this.saveProject(project) return patches.length } }