Add note folder project setting (#1)

Added note folder project settings
Refactored events handling
This commit is contained in:
Poluboiarinov Anton 2026-04-25 03:51:02 +03:00 committed by GitHub
parent 13c7f3b131
commit 2aeed7bd70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 226 additions and 156 deletions

View file

@ -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",

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "obsidian-taskmap",
"version": "0.0.8",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {

View file

@ -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": {

View file

@ -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 {

View file

@ -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<string>(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<string, TaskData>();
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<string, TaskmapView>();
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();
}
}
}
}

View file

@ -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();
}
}

View file

@ -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));
}

View file

@ -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:

View file

@ -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();
}
}

View file

@ -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) {

View file

@ -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<TaskData>());
blockerPairs = $state(new Array<BlockerPair>());
folderPath: string | undefined;
curTaskId = RootTaskId;
public static getDefault(): ProjectData {
return new ProjectData({
tasks: new Array<TaskData>(),
blockerPairs: new Array<BlockerPair>(),
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());

View file

@ -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,
};
}

View file

@ -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);
};