From b523367c7a75f42cd03f760eed83854705f4e6cc Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:49:37 +0300 Subject: [PATCH] fixed warnings and added taskmap files cache --- src/Context.svelte.ts | 12 +- src/FileWatcher.ts | 154 ---------------- src/FileWatcherWithCache.ts | 236 +++++++++++++++++++++++++ src/LinkManager.ts | 10 +- src/NodePositionsCalculator.ts | 50 ++---- src/TaskmapSettingTab.ts | 4 +- src/components/AddTaskButton.svelte | 2 +- src/components/Button.svelte | 4 +- src/components/HideBranchButton.svelte | 1 + src/components/SettingsButton.svelte | 11 -- src/components/SettingsPanel.svelte | 3 + src/components/Task.svelte | 10 +- src/components/TaskText.svelte | 58 +++--- src/components/TaskmapContainer.svelte | 2 + src/components/Toolbar.svelte | 9 +- src/data/HistoryManager.svelte.ts | 2 +- src/main.ts | 18 +- 17 files changed, 334 insertions(+), 252 deletions(-) delete mode 100644 src/FileWatcher.ts create mode 100644 src/FileWatcherWithCache.ts diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index 38a35ed..22001d0 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -1,4 +1,4 @@ -import TaskmapPlugin from "./main"; +import TaskmapPlugin from "./main"; import { type BlockerPair, MouseDown, @@ -14,7 +14,7 @@ import { TFile, type WorkspaceLeaf, } from "obsidian"; -import { TaskmapView } from "./TaskmapView"; +import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView"; import { type NodePositionsCalculator, NoTaskId, @@ -371,7 +371,7 @@ export class Context { } private updateDraggedTaskPriority() { - // Если порядок тасок по оси Y отличается от приоритетов, то меняем приоритеты и пересчитываем порядок + // If task positions are different from priorities, change priorities and recalculate order const draggedParentId = this.versionedData.getTask( this.draggedTaskId, ).parentId; @@ -408,7 +408,11 @@ export class Context { } public save() { - this.app.workspace.getActiveViewOfType(TaskmapView)?.debouncedSave(); + // Cannot have reference to the view, so save all opened views + this.app.workspace.getLeavesOfType(TASKMAP_VIEW_TYPE) + .map((leaf) => leaf.view) + .filter((view) => view instanceof TaskmapView) + .forEach((view) => view.debouncedSave()); } public addTask(parentId: TaskId): void { diff --git a/src/FileWatcher.ts b/src/FileWatcher.ts deleted file mode 100644 index 32fe7db..0000000 --- a/src/FileWatcher.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Notice, TAbstractFile, type App, TFile, TFolder } from "obsidian"; -import type { ProjectData } from "./data/ProjectData.svelte"; -import type { TaskData } from "./types"; -import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView"; -import { deserializeProjectData, updateFile } from "./SaveManager"; -import { delink, generateMarkdownLink } from "./LinkManager"; - -export function getOnDelete(app: App) { - return async (file: TAbstractFile) => { - console.debug(`${file.path} deleted`); - let files: TFile[]; - if (file instanceof TFile) { - files = [file]; - } else if (file instanceof TFolder) { - files = app.vault - .getMarkdownFiles() - .filter((mdFile) => - mdFile.path.startsWith(file.path + "/"), - ); - } else { - return; - } - await handleDeleteFiles( - app, - files, - ); - }; -} - -export function getOnRename(app: App) { - return async (file: TAbstractFile, oldPath: string) => { - console.debug(`${file.path} renamed`); - let files: TFile[]; - if (file instanceof TFile) { - files = [file]; - } else if (file instanceof TFolder) { - files = app.vault - .getMarkdownFiles() - .filter((mdFile) => - mdFile.path.startsWith(file.path + "/"), - ); - } else { - return; - } - await handleRenameFiles( - app, - files, - oldPath, - file.path, - ); - }; -} - -export async function handleRenameFiles( - app: App, - changedMdFiles: TFile[], - oldPath: string, - newPath: string, -) { - const taskmapFiles = app.vault - .getFiles() - .filter((file) => file.path.endsWith(".taskmap")); - - // update paths in taskmap files - for (const taskmapFile of taskmapFiles) { - console.debug(`handling ${taskmapFile.path}`); - let projectData: ProjectData; - try { - const projectDataRaw = await app.vault.read(taskmapFile); - projectData = deserializeProjectData(projectDataRaw); - } catch (e) { - console.error(`Taskmap rename hook: skipped invalid file ${taskmapFile.path}`); - new Notice(`Taskmap rename hook: skipped invalid file: ${String(e)}`); - continue; - } - const mapping = new Map(); - projectData.tasks.forEach((t) => { - if (t.path) { - if (t.path.startsWith(oldPath)) { - t.path = newPath + t.path.slice(oldPath.length); - } - mapping.set(t.path, t); - } - }); - console.debug("mapping " + JSON.stringify(mapping.keys())); - let changed = false; - changedMdFiles - .filter(mdFile => mapping.has(mdFile.path)) - .forEach((mdFile) => { - const t = mapping.get(mdFile.path)!; - t.name = generateMarkdownLink(app, mdFile); - changed = true; - } - ); - if (changed) { - console.debug(`${taskmapFile.path} changed`); - // resave file on the file system - await updateFile(app, taskmapFile, projectData); - } else { - console.debug(`${taskmapFile.path} not changed`); - } - } - - // refresh all active views - for (const view of app.workspace - .getLeavesOfType(TASKMAP_VIEW_TYPE) - .map((l) => l.view) - .filter((view) => view instanceof TaskmapView) - ) { - await view.refreshUi(); - } -} - -export async function handleDeleteFiles( - app: App, - files: TFile[], -) { - const deletedPaths = new Set(files.map((f) => f.path)); - const taskmapFiles = app.vault - .getFiles() - .filter((file) => file.path.endsWith(".taskmap")); - for (const taskmapFile of taskmapFiles) { - let projectData: ProjectData; - try { - const projectDataRaw = await app.vault.read(taskmapFile); - projectData = deserializeProjectData(projectDataRaw); - } catch (e) { - console.error(`Taskmap rename hook: skipped invalid file ${taskmapFile.path}`); - new Notice(`Taskmap rename hook: skipped invalid file: ${String(e)}`); - continue; - } - let taskmapFileChanged = false; - projectData.tasks.filter(t => t.path && deletedPaths.has(t.path)).forEach((t) => { - t.name = delink(t.name); - t.path = undefined; - taskmapFileChanged = true; - }); - if (taskmapFileChanged) { - console.debug(`${taskmapFile.path} changed`); - // resave file on the file system - await updateFile(app, taskmapFile, projectData); - } else { - console.debug(`${taskmapFile.path} not changed`); - } - } - // refresh active views - for (const view of app.workspace - .getLeavesOfType(TASKMAP_VIEW_TYPE) - .map((l) => l.view) - .filter((view) => view instanceof TaskmapView) - ) { - await view.refreshUi(); - } -} \ No newline at end of file diff --git a/src/FileWatcherWithCache.ts b/src/FileWatcherWithCache.ts new file mode 100644 index 0000000..7e6be21 --- /dev/null +++ b/src/FileWatcherWithCache.ts @@ -0,0 +1,236 @@ +import { + Notice, + type Plugin, + TAbstractFile, + type App, + TFile, + TFolder, +} from "obsidian"; +import type { ProjectData } from "./data/ProjectData.svelte"; +import type { TaskData } from "./types"; +import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView"; +import { deserializeProjectData, updateFile } from "./SaveManager"; +import { delink, generateMarkdownLink, pathIsUnderFolder } from "./LinkManager"; + +function taskPathAffectedByRename(taskPath: string, oldPath: string): boolean { + return taskPath === oldPath || pathIsUnderFolder(taskPath, oldPath); +} + +function remapTaskPathAfterRename(taskPath: string, oldPath: string, newPath: string): string { + if (taskPath === oldPath) { + return newPath; + } + return newPath + taskPath.slice(oldPath.length); +} + +/** In-memory index of `.taskmap` files; `TFile.path` stays in sync with vault renames. */ +export class FileWatcherWithCache { + readonly cachedTaskmapFiles = new Set(); + + initFromVault(app: App): void { + this.cachedTaskmapFiles.clear(); + for (const f of app.vault.getFiles()) { + if (f.extension === "taskmap") { + this.cachedTaskmapFiles.add(f); + } + } + } + + /** + * Registers vault hooks: taskmap index maintenance and path/link updates. + * Order: link handlers before cache on delete (cache still lists children under a deleted folder); + * cache before link on rename so `TFile.path` matches vault before we scan. + */ + registerTaskmapVaultHooks(plugin: Plugin): void { + const { app } = plugin; + plugin.registerEvent( + app.vault.on("delete", (file) => { + void this.onDeleteLinkUpdate(file, app); + }), + ); + plugin.registerEvent( + app.vault.on("delete", (file) => { + this.onDeleteCacheUpdate(file); + }), + ); + + plugin.registerEvent( + app.vault.on("rename", (file) => { + this.onRenameCacheUpdate(file); + }), + ); + plugin.registerEvent( + app.vault.on("rename", (file, oldPath) => { + void this.onRenameLinkUpdate(file, oldPath, app); + }), + ); + + plugin.registerEvent( + app.vault.on("create", (file) => { + this.onCreateCacheUpdate(file); + }), + ); + } + + onCreateCacheUpdate(file: TAbstractFile): void { + if (file instanceof TFile && file.extension === "taskmap") { + this.cachedTaskmapFiles.add(file); + } + // TFolder: each nested .taskmap emits its own "create" + } + + onDeleteCacheUpdate(file: TAbstractFile): void { + if (file instanceof TFile && file.extension === "taskmap") { + this.cachedTaskmapFiles.delete(file); + } else if (file instanceof TFolder) { + for (const f of [...this.cachedTaskmapFiles]) { + if (pathIsUnderFolder(f.path, file.path)) { + this.cachedTaskmapFiles.delete(f); + } + } + } + } + + /** + * Path-only renames update `TFile.path` on the same instance — no Set mutation. + * Still handle extension changes (e.g. `.taskmap` → `.md`). + */ + onRenameCacheUpdate(file: TAbstractFile): void { + if (!(file instanceof TFile)) { + return; + } + this.cachedTaskmapFiles.delete(file); + if (file.extension === "taskmap") { + this.cachedTaskmapFiles.add(file); + } + } + + async onDeleteLinkUpdate(file: TAbstractFile, app: App) { + console.debug(`${file.path} deleted`); + let files: TFile[]; + if (file instanceof TFile) { + files = [file]; + } else if (file instanceof TFolder) { + files = [...this.cachedTaskmapFiles].filter((f) => + pathIsUnderFolder(f.path, file.path), + ); + } else { + return; + } + await this.handleDeleteFiles(app, files); + } + + async onRenameLinkUpdate( + file: TAbstractFile, + oldPath: string, + app: App, + ) { + console.debug(`${file.path} renamed`); + let files: TFile[]; + if (file instanceof TFile) { + files = [file]; + } else if (file instanceof TFolder) { + files = [...this.cachedTaskmapFiles].filter((f) => + pathIsUnderFolder(f.path, file.path), + ); + } else { + return; + } + await this.handleRenameFiles(app, files, oldPath, file.path); + } + + async handleDeleteFiles(app: App, files: TFile[]) { + const deletedPaths = new Set(files.map((f) => f.path)); + for (const taskmapFile of [...this.cachedTaskmapFiles]) { + if (deletedPaths.has(taskmapFile.path)) { + // Entire .taskmap was removed; link handler runs before cache — skip read/write. + continue; + } + let projectData: ProjectData; + try { + const projectDataRaw = await app.vault.read(taskmapFile); + projectData = deserializeProjectData(projectDataRaw); + } catch (e) { + console.error(`Taskmap delete hook: skipped invalid file ${taskmapFile.path}`); + new Notice(`Taskmap delete hook: skipped invalid file: ${String(e)}`); + continue; + } + let taskmapFileChanged = false; + projectData.tasks + .filter((t) => t.path && deletedPaths.has(t.path)) + .forEach((t) => { + t.name = delink(t.name); + t.path = undefined; + taskmapFileChanged = true; + }); + if (taskmapFileChanged) { + console.debug(`${taskmapFile.path} changed`); + // resave file on the file system + await updateFile(app, taskmapFile, projectData); + } else { + console.debug(`${taskmapFile.path} not changed`); + } + } + // refresh all active views + for (const view of app.workspace + .getLeavesOfType(TASKMAP_VIEW_TYPE) + .map((l) => l.view) + .filter((view) => view instanceof TaskmapView)) { + await view.refreshUi(); + } + } + + async handleRenameFiles( + app: App, + changedMdFiles: TFile[], + oldPath: string, + newPath: string, + ) { + // update paths in taskmap files + for (const taskmapFile of [...this.cachedTaskmapFiles]) { + console.debug(`handling ${taskmapFile.path}`); + let projectData: ProjectData; + try { + const projectDataRaw = await app.vault.read(taskmapFile); + projectData = deserializeProjectData(projectDataRaw); + } catch (e) { + console.error(`Taskmap rename hook: skipped invalid file ${taskmapFile.path}`); + new Notice(`Taskmap rename hook: skipped invalid file: ${String(e)}`); + continue; + } + const mapping = new Map(); + projectData.tasks.forEach((t) => { + if (t.path) { + if (taskPathAffectedByRename(t.path, oldPath)) { + t.path = remapTaskPathAfterRename(t.path, oldPath, newPath); + } + mapping.set(t.path, t); + } + }); + console.debug("mapping " + JSON.stringify(mapping.keys())); + let changed = false; + changedMdFiles + .filter((mdFile) => mapping.has(mdFile.path)) + .forEach((mdFile) => { + const t = mapping.get(mdFile.path)!; + t.name = generateMarkdownLink(app, mdFile); + changed = true; + }); + if (changed) { + console.debug(`${taskmapFile.path} changed`); + // resave file on the file system + await updateFile(app, taskmapFile, projectData); + } else { + console.debug(`${taskmapFile.path} not changed`); + } + } + + // refresh all active views + for (const view of app.workspace + .getLeavesOfType(TASKMAP_VIEW_TYPE) + .map((l) => l.view) + .filter((view) => view instanceof TaskmapView)) { + await view.refreshUi(); + } + } +} diff --git a/src/LinkManager.ts b/src/LinkManager.ts index 4b2c182..9354638 100644 --- a/src/LinkManager.ts +++ b/src/LinkManager.ts @@ -15,15 +15,21 @@ export function delink(s: string) { return isLink(s) ? s.slice(2, s.length - 2) : s; } -// relativePath to TFile +/** relativePath to TFile */ export function getFromRelativePath(app: App, path: string) { return app.vault.getFileByPath(path); } -// TFile to wikilink + +/** TFile to wikilink */ export function generateMarkdownLink(app: App, file: TFile) { return app.fileManager.generateMarkdownLink(file, ""); } +/** True if `path` is a file inside `folderPath` (not a false `startsWith` on the folder segment). */ +export function pathIsUnderFolder(path: string, folderPath: string): boolean { + return path.startsWith(folderPath + "/"); +} + export class LinkManager { private app: App; diff --git a/src/NodePositionsCalculator.ts b/src/NodePositionsCalculator.ts index e45ce56..0f43c2f 100644 --- a/src/NodePositionsCalculator.ts +++ b/src/NodePositionsCalculator.ts @@ -58,7 +58,7 @@ export class NodePositionsCalculator { } /** - * Позиции относительно рута + * Positions relative to the root */ private CalculatePositionsInRootFrame( tasks: TaskData[], @@ -98,19 +98,19 @@ export class NodePositionsCalculator { } /** - * Считаем позиции на основе веса поддеревьев и константы alignmentRatio + * Calculate positions based on the weight of subtrees and the alignmentRatio constant */ private CalculatePositionsInParentFrame( tasks: TaskData[], ): Map { - // Расположение ноды родителя относительно высоты поддерева. 0 - top, 0.5 - center, 1 - bottom + // Position of the parent node relative to the height of the subtree. 0 - top, 0.5 - center, 1 - bottom const alignmentRatio: Vector2 = { x: 0, y: 0.5 }; - // Дополнительный сдвиг в виде доли от siblingDelta, чтобы дети сиблингов не сливались в один список + // Additional shift in the form of a fraction of siblingDelta, so that siblings' children do not merge into a single list const parentDelta: Vector2 = { x: 0, y: 0.15 }; - //// Вспомогательные структуры данных - // Сортируем таски, чтобы упросить реализацию DP: depth DESC, parentId ASC/DESC, priority ASC - // TODO: мб поддерживать порядок сортировки в DataModel? + //// Helper data structures + // Sort tasks to simplify the implementation of DP: depth DESC, parentId ASC/DESC, priority ASC + // TODO: maybe support order sorting in DataModel? const sortedTasks = [...tasks].sort((a, b) => { if (b.depth !== a.depth) { return b.depth - a.depth; @@ -138,8 +138,8 @@ export class NodePositionsCalculator { const parentIds = [...childrenIdsByParentId.keys()]; - //// Расчеты - // ключ - айди рута поддерева + //// Calculations + // key - root id of the subtree const subtreeSizeByNodeId: Map = new Map< TaskId, Vector2 @@ -168,7 +168,7 @@ export class NodePositionsCalculator { } }); - // Шифт дочерних нод относительно левой-верхней точки прямоугольника + // Shift of children nodes relative to the left-top point of the rectangle const siblingShiftsRel: Map = new Map< TaskId, Vector2 @@ -189,7 +189,7 @@ export class NodePositionsCalculator { s = V2.add(s, prevSize); s = V2.add(s, siblingShiftsRel.get(previousSibling)!); } - // Дополнительный сдвиг для отрисовки блокеров + // Additional shift for drawing blockers // var blockerCount = BlockerDataManager.GetConnections() // .Count(x => x.Item1 == childId); // s += blockerCount; @@ -197,14 +197,8 @@ export class NodePositionsCalculator { }); }); - // TODO: походу в проекте что-то не так с данными. - // Нужно перейти на мета уровень и сделать удобный способ смотреть данные. Какой? - // - Логировать десятки тасок в виде json может быть полезно, но не удобно. - // - Можно добавить этап валидации данных, где будут проверяться предположения. - // - Можно добавить дебажную инфу с данными выбранной таски. Не поможет, если проблема в удаленных. - - // Шифт родителя относительно левой-верхней точки прямоугольника. - // Вычитаем parentDelta, чтобы родитель был выровнен по детям + // Shift of the parent relative to the left-top point of the rectangle. + // Subtract parentDelta, so that the parent is aligned with the children const parentAlignmentShift: Map = new Map< TaskId, Vector2 @@ -219,7 +213,7 @@ export class NodePositionsCalculator { ); }); - // Шифт дочерних нод относительно родителя и уже в нормальных координатах + // Shift of children nodes relative to the parent and already in normal coordinates const finalChildShifts: Map = new Map< TaskId, Vector2 @@ -273,11 +267,8 @@ export class NodePositionsCalculator { depthOneTasksByRow.get(idx)!.push(t); }); - const yShiftByRowId: Map = {} as Map< - number, - number - >; - Object.keys(depthOneTasksByRow).forEach((key) => { + const yShiftByRowId: Map = new Map(); + [...depthOneTasksByRow.keys()].forEach((key) => { const rowIdx = Number(key); const elements = depthOneTasksByRow.get(rowIdx)!; @@ -308,13 +299,10 @@ export class NodePositionsCalculator { subtreeSizeByNodeId.get(x.taskId)!.x + this.xshift; }); } else if (this.Algorithm === AlgorithmEnum.DoubleRow) { - // Размеры поддеревьев по иксу. Если 2 таски друг над другом, то используется большее. - this.subtreeWidthByHalfPriority = {} as Map; + // Sizes of subtrees by x. If 2 tasks are above each other, the larger one is used. + this.subtreeWidthByHalfPriority = new Map(); - const rootChildrenByPriority: Map = {} as Map< - number, - TaskId - >; + const rootChildrenByPriority: Map = new Map(); const rootChildren = childrenIdsByParentId.get(RootTaskId) ?? []; rootChildren.forEach((id, idx) => { diff --git a/src/TaskmapSettingTab.ts b/src/TaskmapSettingTab.ts index a86b274..027f83a 100644 --- a/src/TaskmapSettingTab.ts +++ b/src/TaskmapSettingTab.ts @@ -16,7 +16,7 @@ export class TaskmapSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Zoom sensitivity (touchpad)") - .setDesc("In percents") + .setDesc("As a percentage") .addText((text) => text .setPlaceholder("100") @@ -28,7 +28,7 @@ export class TaskmapSettingTab extends PluginSettingTab { ); new Setting(containerEl) .setName("Zoom sensitivity (mouse)") - .setDesc("In percents") + .setDesc("As a percentage") .addText((text) => text .setPlaceholder("100") diff --git a/src/components/AddTaskButton.svelte b/src/components/AddTaskButton.svelte index a4525f6..176972e 100644 --- a/src/components/AddTaskButton.svelte +++ b/src/components/AddTaskButton.svelte @@ -18,6 +18,7 @@
entered = true} onmouseleave={() => entered = false} style=" @@ -28,7 +29,6 @@ {#if entered} entered = true} onmouseleave={() => entered = false} style=" diff --git a/src/components/SettingsButton.svelte b/src/components/SettingsButton.svelte index f04915f..036a6c7 100644 --- a/src/components/SettingsButton.svelte +++ b/src/components/SettingsButton.svelte @@ -100,10 +100,6 @@ stroke-linejoin: round; will-change: transform,scale,translate; } - .custom-svg { - stroke-width: 0.083; - fill: #bbb; - } } .button.disabled { @@ -131,11 +127,4 @@ stroke: white; } } - .button.is-pressed-up:not(.disabled){ - background-color: #343434; - color: white; - :global(svg) { - stroke: white; - } - } diff --git a/src/components/SettingsPanel.svelte b/src/components/SettingsPanel.svelte index c069c5d..fdb8d6a 100644 --- a/src/components/SettingsPanel.svelte +++ b/src/components/SettingsPanel.svelte @@ -9,7 +9,10 @@ {#if !context.taskDraggingManager.isDragging}