diff --git a/manifest.json b/manifest.json index 51e7713..597c941 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "taskmap", "name": "Taskmap", - "version": "0.1.1", + "version": "0.1.2", "minAppVersion": "1.12.4", "description": "Plan projects via interactive GUI task trees with automatic layout.", "author": "poanse", diff --git a/package-lock.json b/package-lock.json index 888f0d2..a66371e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "obsidian-taskmap", - "version": "0.0.8", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 959412d..fd6d48b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-taskmap", - "version": "0.0.8", + "version": "0.1.2", "description": "Taskmap plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index e59bfef..a8ea28a 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -494,15 +494,13 @@ export class Context { * Opens the note on the right side. */ public async createLinkedNote(taskId: TaskId, plugin: TaskmapPlugin) { + const projectNoteFolder = this.versionedData.getFolderPath(); + const pluginNoteFolder = plugin.settings.newNoteFolder; const taskmapPath = - plugin.app.workspace.getActiveViewOfType(TaskmapView)?.file?.parent - ?.path; - let folderPath = ""; - if (plugin.settings.newNoteFolder) { - folderPath = plugin.settings.newNoteFolder; - } else if (taskmapPath) { - folderPath = taskmapPath; - } + plugin.app.workspace.getActiveViewOfType(TaskmapView)?.file?.parent?.path; + const folderPath = + projectNoteFolder || pluginNoteFolder || taskmapPath || ""; + const filepath = this.filePathFromTask(taskId, folderPath); try { diff --git a/src/FileWatcherWithCache.ts b/src/FileWatcherWithCache.ts index 7e6be21..f2b92d0 100644 --- a/src/FileWatcherWithCache.ts +++ b/src/FileWatcherWithCache.ts @@ -1,27 +1,8 @@ -import { - Notice, - type Plugin, - TAbstractFile, - type App, - TFile, - TFolder, -} from "obsidian"; -import type { ProjectData } from "./data/ProjectData.svelte"; -import type { TaskData } from "./types"; +import { type App, type Plugin, TAbstractFile, TFile, TFolder } from "obsidian"; import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView"; -import { deserializeProjectData, updateFile } from "./SaveManager"; +import { loadProjectData, 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); -} +import type { ProjectData } from "./data/ProjectData.svelte"; /** In-memory index of `.taskmap` files; `TFile.path` stays in sync with vault renames. */ export class FileWatcherWithCache { @@ -61,7 +42,7 @@ export class FileWatcherWithCache { ); plugin.registerEvent( app.vault.on("rename", (file, oldPath) => { - void this.onRenameLinkUpdate(file, oldPath, app); + void this.onRenameLinkUpdate(app, file, oldPath); }), ); @@ -93,144 +74,158 @@ export class FileWatcherWithCache { /** * Path-only renames update `TFile.path` on the same instance — no Set mutation. - * Still handle extension changes (e.g. `.taskmap` → `.md`). + * Still handle extension changes (e.g. `.md` -> `.taskmap`). */ onRenameCacheUpdate(file: TAbstractFile): void { - if (!(file instanceof TFile)) { - return; - } - this.cachedTaskmapFiles.delete(file); - if (file.extension === "taskmap") { - this.cachedTaskmapFiles.add(file); + if (file instanceof TFile) { + 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]; + await this.handleDeleteFile(app, file); } else if (file instanceof TFolder) { - files = [...this.cachedTaskmapFiles].filter((f) => - pathIsUnderFolder(f.path, file.path), - ); - } else { - return; + // files in folder emit separate events per file + await this.handleNoteFolderDelete(app, file); } - await this.handleDeleteFiles(app, files); } - async onRenameLinkUpdate( - file: TAbstractFile, - oldPath: string, - app: App, - ) { - console.debug(`${file.path} renamed`); - let files: TFile[]; + async onRenameLinkUpdate(app: App, file: TAbstractFile, oldPath: string) { if (file instanceof TFile) { - files = [file]; + await this.handleRenameFiles(app, file, oldPath); } else if (file instanceof TFolder) { - files = [...this.cachedTaskmapFiles].filter((f) => - pathIsUnderFolder(f.path, file.path), - ); - } else { - return; + // files in folder emit separate events per file + await this.handleNoteFolderRename(app, file, oldPath); } - await this.handleRenameFiles(app, files, oldPath, file.path); } - async handleDeleteFiles(app: App, files: TFile[]) { - const deletedPaths = new Set(files.map((f) => f.path)); + async handleDeleteFile(app: App, deletedFile: TFile) { for (const taskmapFile of [...this.cachedTaskmapFiles]) { - if (deletedPaths.has(taskmapFile.path)) { + if (deletedFile.path === 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)}`); + const projectData = await this.getProjectData(app, taskmapFile); + if (!projectData) { 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`); + const affectedTasks = projectData.tasks.filter( + (t) => t.path && deletedFile.path === t.path, + ); + for (const t of affectedTasks) { + t.name = delink(t.name); + t.path = undefined; + } + if (affectedTasks) { + console.debug(`${taskmapFile.path} changed`); + await this.updateFileAndRefreshActiveView( + app, + taskmapFile, + projectData, + ); } - } - // 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 + async handleNoteFolderDelete(app: App, folder: TFolder) { 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`); + const projectData = await this.getProjectData(app, taskmapFile); + if (projectData && projectData.folderPath == folder.path) { + console.debug(`Removed folderPath for ${taskmapFile.name}`); + projectData.setFolderPath(""); + await this.updateFileAndRefreshActiveView( + app, + taskmapFile, + projectData, + ); } } + } - // refresh all active views - for (const view of app.workspace + async handleRenameFiles(app: App, changedMdFile: TFile, oldPath: string) { + // update paths in taskmap files + for (const taskmapFile of [...this.cachedTaskmapFiles]) { + const projectData = await this.getProjectData(app, taskmapFile); + if (!projectData) { + continue; + } + const affectedTasks = projectData.tasks.filter( + (t) => t.path && t.path === oldPath, + ); + for (const t of affectedTasks) { + t.path = changedMdFile.path; + t.name = generateMarkdownLink(app, changedMdFile); + } + if (affectedTasks) { + console.debug(`${taskmapFile.path} changed`); + // resave file on the file system + await this.updateFileAndRefreshActiveView( + app, + taskmapFile, + projectData, + ); + } + } + } + + async handleNoteFolderRename(app: App, folder: TFolder, oldPath: string) { + for (const taskmapFile of [...this.cachedTaskmapFiles]) { + const projectData = await this.getProjectData(app, taskmapFile); + if (projectData && projectData.folderPath == oldPath) { + projectData.setFolderPath(folder.path); + console.debug( + `Changed folderPath for ${taskmapFile.name} from ${oldPath} to ${folder.path}`, + ); + await this.updateFileAndRefreshActiveView( + app, + taskmapFile, + projectData, + ); + } + } + } + + async getProjectData(app: App, taskmapFile: TFile) { + const viewByName = new Map(); + app.workspace .getLeavesOfType(TASKMAP_VIEW_TYPE) .map((l) => l.view) - .filter((view) => view instanceof TaskmapView)) { - await view.refreshUi(); + .forEach((v) => { + if (v instanceof TaskmapView && v.file) { + viewByName.set(v.file.name, v); + } + }); + const view = viewByName.get(taskmapFile.name); + if (view) { + // ensure that view data is consistent with disk data + await view.debouncedSave.run(); + return view.projectData; + } + const loadedProjectData = await loadProjectData(app, taskmapFile); + if (loadedProjectData) { + return loadedProjectData; + } + } + + async updateFileAndRefreshActiveView( + app: App, + taskmapFile: TFile, + projectData: ProjectData, + ) { + await updateFile(app, taskmapFile, projectData); + for (const view of app.workspace + .getLeavesOfType(TASKMAP_VIEW_TYPE) + .map((l) => l.view)) { + if ( + view instanceof TaskmapView && + view.file?.name === taskmapFile.name + ) { + await view.refreshUi(); + } } } } diff --git a/src/ProjectSettingsModal.ts b/src/ProjectSettingsModal.ts new file mode 100644 index 0000000..b680304 --- /dev/null +++ b/src/ProjectSettingsModal.ts @@ -0,0 +1,42 @@ +import { Modal, Setting, TFolder } from "obsidian"; +import type { Context } from "./Context.svelte.js"; +import { FolderSuggest } from "./helpers/FolderSuggest"; + +export class ProjectSettingsModal extends Modal { + constructor(private readonly context: Context) { + super(context.app); + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl("h2", { text: "Project settings" }); + + new Setting(contentEl) + .setName("Note folder") + .setDesc( + "Notes created from tasks will be placed in this folder. If blank, they will be placed in the default location for this vault.", + ) + .addText((text) => { + new FolderSuggest(this.app, text.inputEl); + text.setPlaceholder("") + .setValue(this.context.versionedData.getFolderPath() ?? "") + .onChange(async (value) => { + if ( + value === "" || + this.app.vault.getAbstractFileByPath( + value, + ) instanceof TFolder + ) { + this.context.versionedData.setFolderPath(value); + this.context.save(); + } + }); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/SaveManager.ts b/src/SaveManager.ts index 594ea1d..db91547 100644 --- a/src/SaveManager.ts +++ b/src/SaveManager.ts @@ -14,6 +14,7 @@ export function serializeProjectData(projectData: ProjectData) { schemaVersion: TASKMAP_FILE_SCHEMA_VERSION, tasks: projectData.tasks, blockerPairs: projectData.blockerPairs, + folderPath: projectData.folderPath, curTaskId: projectData.curTaskId, }, null, @@ -21,6 +22,27 @@ export function serializeProjectData(projectData: ProjectData) { ); } +export async function updateFile( + app: App, + file: TFile, + projectData: ProjectData, +) { + await app.vault.modify(file, serializeProjectData(projectData)); +} + +export async function loadProjectData(app: App, taskmapFile: TFile) { + let projectData: ProjectData | undefined = undefined; + try { + const projectDataRaw = await app.vault.read(taskmapFile); + projectData = deserializeProjectData(projectDataRaw); + } catch (e) { + console.error( + `Taskmap rename hook: skipped invalid file ${taskmapFile.path} ${e}`, + ); + } + return projectData; +} + export function deserializeProjectData(data: string) { let parsed: unknown; try { @@ -34,11 +56,3 @@ export function deserializeProjectData(data: string) { const input = parseProjectFileJson(parsed); return new ProjectData(input); } - -export async function updateFile( - app: App, - file: TFile, - projectData: ProjectData, -) { - await app.vault.modify(file, serializeProjectData(projectData)); -} diff --git a/src/Tooltip.ts b/src/Tooltip.ts index 39fdcf7..1157c9a 100644 --- a/src/Tooltip.ts +++ b/src/Tooltip.ts @@ -58,7 +58,7 @@ export function getTooltipText(code: IconCode) { export function getTooltipTextSettings(code: SettingsIconCode) { switch (code) { case SettingsIconCode.SETTINGS_MENU: - return "WIP (Settings)"; + return "Settings"; case SettingsIconCode.SETTINGS_REDO: return "Redo"; case SettingsIconCode.SETTINGS_UNDO: diff --git a/src/components/SettingsButton.svelte b/src/components/SettingsButton.svelte index 036a6c7..7e5ad65 100644 --- a/src/components/SettingsButton.svelte +++ b/src/components/SettingsButton.svelte @@ -3,10 +3,12 @@ import {SettingsIconCode} from "../types"; import {getTooltipTextSettings, tooltip} from "../Tooltip"; import {Redo2, Settings, Undo2} from 'lucide-svelte'; + import { ProjectSettingsModal } from "src/ProjectSettingsModal.js"; let { iconCode, context }: { iconCode: SettingsIconCode, context: Context } = $props(); let isPressedDown = $state(false); + let modal: ProjectSettingsModal | null = null; function onpointerdown(event: MouseEvent) { isPressedDown = true; @@ -20,7 +22,6 @@ let isButtonDisabled = $derived( (iconCode == SettingsIconCode.NONE) - || (iconCode == SettingsIconCode.SETTINGS_MENU) || (iconCode == SettingsIconCode.SETTINGS_UNDO && !context.versionedData.canUndo()) || (iconCode == SettingsIconCode.SETTINGS_REDO && !context.versionedData.canRedo()) ); @@ -39,6 +40,8 @@ context.versionedData.undo(); } else if (iconCode == SettingsIconCode.SETTINGS_REDO) { context.versionedData.redo(); + } else if (iconCode == SettingsIconCode.SETTINGS_MENU) { + new ProjectSettingsModal(context).open(); } } diff --git a/src/components/TaskmapContainer.svelte b/src/components/TaskmapContainer.svelte index 810a7d2..e90f3a0 100644 --- a/src/components/TaskmapContainer.svelte +++ b/src/components/TaskmapContainer.svelte @@ -93,7 +93,6 @@ } function onpointermove(e: PointerEvent) { - console.debug('handlePointerMove', e.pointerId, e.button); if (context.draggedTaskId != NoTaskId) { context.taskDraggingManager.onPointerMove(e); if (context.taskDraggingManager.isDragging) { diff --git a/src/data/ProjectData.svelte.ts b/src/data/ProjectData.svelte.ts index bbc2401..f5c19b4 100644 --- a/src/data/ProjectData.svelte.ts +++ b/src/data/ProjectData.svelte.ts @@ -6,27 +6,27 @@ } from "../types"; import { NoTaskId, RootTaskId } from "../NodePositionsCalculator"; import { serializeProjectData } from "../SaveManager"; +import type { ProjectFileParsed } from "./ProjectDataSchema"; export class ProjectData { tasks = $state(new Array()); blockerPairs = $state(new Array()); + folderPath: string | undefined; curTaskId = RootTaskId; public static getDefault(): ProjectData { return new ProjectData({ tasks: new Array(), blockerPairs: new Array(), + folderPath: undefined, curTaskId: 0, }); } - constructor(obj: { - tasks: TaskData[]; - blockerPairs?: BlockerPair[]; - curTaskId: number; - }) { + constructor(obj: ProjectFileParsed) { this.tasks = obj.tasks; this.blockerPairs = obj.blockerPairs ?? []; + this.folderPath = obj.folderPath; this.curTaskId = obj.curTaskId; if (this.tasks.length == 0) { this.addRootTask(); @@ -203,6 +203,14 @@ export class ProjectData { public addBlockerPair = (blockerPair: BlockerPair) => { this.blockerPairs.push(blockerPair); }; + + public getFolderPath = (): string | undefined => { + return this.folderPath; + }; + + public setFolderPath = (path: string | undefined) => { + this.folderPath = path === "" ? undefined : path; + }; } export const DEFAULT_DATA = serializeProjectData(ProjectData.getDefault()); diff --git a/src/data/ProjectDataSchema.ts b/src/data/ProjectDataSchema.ts index c13d3b9..e62f8cd 100644 --- a/src/data/ProjectDataSchema.ts +++ b/src/data/ProjectDataSchema.ts @@ -40,12 +40,14 @@ export const projectFileSchema = v.object({ ), tasks: v.array(taskDataSchema), blockerPairs: v.optional(v.array(blockerPairSchema)), + folderPath: v.optional(v.string()), curTaskId: v.pipe(v.number(), v.integer()), }); export type ProjectFileParsed = { tasks: TaskData[]; blockerPairs: BlockerPair[]; + folderPath: string | undefined; curTaskId: number; }; @@ -89,6 +91,7 @@ export function parseProjectFileJson(parsed: unknown): ProjectFileParsed { return { tasks: o.tasks, blockerPairs: o.blockerPairs ?? [], + folderPath: o.folderPath, curTaskId: o.curTaskId, }; } diff --git a/src/data/VersionedData.ts b/src/data/VersionedData.ts index d29d523..c38cbdb 100644 --- a/src/data/VersionedData.ts +++ b/src/data/VersionedData.ts @@ -124,6 +124,14 @@ export class VersionedData { ); }; + public getFolderPath = (): string | undefined => { + return this.data.getFolderPath(); + }; + + public setFolderPath = (path: string | undefined) => { + this.data.setFolderPath(path); + }; + public getTasks = (includeDeleted = false) => { return this.data.tasks.filter((t) => includeDeleted || !t.deleted); };