Release Context Nine plugin

This commit is contained in:
Matt Derman 2026-05-29 08:38:59 +02:00
parent 07c0664195
commit 467a667a47
23 changed files with 1199 additions and 320 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Derman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
README.md
View file

@ -1,95 +1,36 @@
# Obsidian Master Plugin
# Context Nine
Workspace-specific Obsidian helpers for Matthew's master vault.
TaskNotes capture, context routing, vault command cockpit, and attachment cleanup tools for Obsidian.
This is a local Obsidian community plugin, not a published marketplace plugin. It is developed here:
```text
/Users/matthewderman/Code/Personal/obsidian_master_plugin
```
The built plugin is installed into the vault here:
```text
/Users/matthewderman/My Drive/Workspace/.obsidian/plugins/obsidian-master-plugin
```
Context Nine is desktop-only. Vault Cockpit and background sync commands use local Node.js process APIs.
## Features
- Capture the current Markdown selection into a new TaskNotes task.
- Append the current Markdown selection to an existing TaskNotes task under `## Captures`.
- Route attachment links in the captured selection into the destination context folder immediately.
- Route new pasted/dropped files from `master/_obsidian/attachments/_inbox` into the active note's root attachment folder.
- Delete hovered or selected files with Obsidian's normal confirmation/trash behavior.
- Create a new note in the hovered file-explorer folder.
- Add delete actions to file explorer items, Bases kanban cards, and TaskNotes edit modals where possible.
- Open a right-sidebar Vault Cockpit for running common `vault` terminal commands from Obsidian.
- Capture selected Markdown into a new TaskNotes task.
- Append selected Markdown to an existing TaskNotes task under `## Captures`.
- Route attachment links from captured selections into destination context folders.
- Route pasted or dropped inbox attachments from `master/_obsidian/attachments/_inbox`.
- Move TaskNotes files when context fields change.
- Add file delete actions for file explorer items, Bases kanban cards, and TaskNotes edit modals where possible.
- Open a right-sidebar Vault Cockpit for common `vault` terminal commands.
## Default Shortcuts
## Commands
These are also documented in the vault note `master/system/obsidian_notes/obsidian-keyboard-shortcuts.md`.
| Shortcut | Command | Behavior |
| --- | --- | --- |
| `Alt+Cmd+T` | Capture selection to new TaskNotes task | Opens the native TaskNotes create dialog. If Markdown is selected, the plugin injects that selection into the task details on save, routes selected attachments immediately, then deletes the source selection after success. With no selection, it behaves like the normal TaskNotes new task dialog. |
| `Alt+Cmd+Y` | Append selection to existing TaskNotes task | Opens a task picker, appends the selected block under `## Captures`, routes selected attachments immediately, then deletes the source selection after success. |
| `Cmd+Backspace` | Delete hovered or selected file | Uses Obsidian's delete confirmation and trash behavior. Works for file explorer selections, hovered file explorer items, and Bases kanban cards that expose a file path. It does not delete the active note as a fallback. |
| `Alt+Cmd+Backspace` | Obsidian: delete current file | Uses Obsidian's native current-file delete command for deliberate current-note deletion. |
| `Cmd+N` | New note in hovered folder | Creates `Untitled.md` in the hovered file-explorer folder. If no folder is hovered, falls back to Obsidian's normal new note command. |
| Command | Behavior |
| --- | --- |
| Capture selection to new TaskNotes task | Opens TaskNotes create dialog. If Markdown is selected, injects selection into task details on save, routes selected attachments, then deletes source selection after success. |
| Append selection to existing TaskNotes task | Opens a task picker, appends selected block under `## Captures`, routes selected attachments, then deletes source selection after success. |
| Route inbox attachments for active note | Moves queued inbox attachments into active note context. |
| Route TaskNotes files by context | Moves TaskNotes files to folders matching their context metadata. |
| Delete hovered or selected file | Uses Obsidian delete confirmation and trash behavior. |
| New note in hovered folder | Creates `Untitled.md` in hovered file-explorer folder, falling back to Obsidian new note behavior. |
| Open vault cockpit | Opens command runner panel in right sidebar. |
## Vault Cockpit
The Vault Cockpit is a right-sidebar view for running the local `vault` command dispatcher without leaving Obsidian. Open it from the ribbon icon or the command palette command `Open vault cockpit`.
Vault Cockpit runs the local `vault` command dispatcher without leaving Obsidian. Default buttons include refresh, sync, context, content schedules, attachment dry run/apply, and profile sync.
Default buttons:
- Refresh: `vault refresh`
- Sync Apple Notes: `vault sync`
- Context: `vault context`
- Content Schedules: `vault content`
- Attachments Dry Run: `vault attachments`
- Attachments Apply: `vault attachments --apply`
- Obsidian Profile Sync: `vault profile`
Normal click runs the command directly. `Cmd+Click` opens an arguments modal and appends extra parsed arguments to the base command. Output streams live into the panel, with stdout, stderr, status, exit code, and timestamps kept visible after completion.
The plugin is desktop-only because the cockpit uses Node's `child_process.spawn`. Settings expose the command name/path and vault root; defaults are `vault` and `/Users/matthewderman/My Drive/Workspace`.
## Attachment Routing
There are two attachment routing paths:
- Capture commands route selected attachment links immediately. This does not wait for any background timer.
- The inbox router watches `master/_obsidian/attachments/_inbox` and flushes queued new files every 60 seconds. That path is for ordinary paste/drop attachments outside the capture commands.
- When enabled, the plugin runs `vault gcal sync-tasks --apply` every 5 minutes while Obsidian is open.
Destination attachments are flat under the destination root's attachment folder, for example:
```text
03-impression/_obsidian/attachments/
```
If the same file already exists at the destination, the plugin reuses it. If a different file has the same name, it adds a suffix like ` (2)`.
## TaskNotes Integration
For new tasks, the plugin reuses the installed TaskNotes create modal when available. It temporarily wraps TaskNotes' `taskService.createTask` so it can route the selected attachments and inject the selected content at save time.
For existing tasks, the plugin uses TaskNotes' cached task list, appends the capture to the Markdown file, updates `dateModified`, and asks TaskNotes to refresh its cache when available.
## Vault Config
The vault should have:
- `obsidian-master-plugin` enabled in `.obsidian/community-plugins.json`.
- `Alt+Cmd+T`, `Alt+Cmd+Y`, `Cmd+Backspace`, `Alt+Cmd+Backspace`, and `Cmd+N` mapped in `.obsidian/hotkeys.json`.
- Obsidian's attachment folder set to `master/_obsidian/attachments/_inbox`.
The same plugin enablement and hotkey defaults are mirrored into the reusable bootstrap profile under:
```text
master/system/bootstrap/setup/obsidian-profiles/source/.obsidian/
```
Normal click runs a command directly. `Cmd+Click` opens an arguments modal and appends parsed arguments to the base command. Output streams live into the panel with stdout, stderr, status, exit code, and timestamps visible after completion.
## Development
@ -97,7 +38,21 @@ master/system/bootstrap/setup/obsidian-profiles/source/.obsidian/
npm install
npm run build
npm run test
npm run install-vault
VAULT_ROOT="/path/to/vault" npm run install-vault
```
`npm run install-vault` builds the plugin and copies `main.js`, `manifest.json`, and `styles.css` into the active vault plugin folder.
`npm run install-vault` builds the plugin and copies `main.js`, `manifest.json`, and `styles.css` into:
```text
.obsidian/plugins/context-nine-hastitian/
```
## Release
GitHub releases must include these assets:
- `main.js`
- `manifest.json`
- `styles.css`
Release tag must match `manifest.json` version.

View file

@ -1,9 +1,9 @@
{
"id": "obsidian-master-plugin",
"name": "Obsidian Master Plugin",
"version": "0.1.0",
"id": "context-nine-hastitian",
"name": "Context Nine",
"version": "1.0.0",
"minAppVersion": "1.7.0",
"description": "Workspace-specific TaskNotes capture, vault command cockpit, and attachment routing commands.",
"description": "TaskNotes capture, context routing, vault command cockpit, and attachment cleanup tools.",
"author": "Matthew Derman",
"isDesktopOnly": true
}

8
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-master-plugin",
"version": "0.1.0",
"name": "context-nine-hastitian",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-master-plugin",
"version": "0.1.0",
"name": "context-nine-hastitian",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.17.24",

View file

@ -1,13 +1,13 @@
{
"name": "obsidian-master-plugin",
"version": "0.1.0",
"description": "Workspace-specific Obsidian helpers for TaskNotes capture and attachment routing.",
"name": "context-nine-hastitian",
"version": "1.0.0",
"description": "TaskNotes capture, context routing, vault command cockpit, and attachment cleanup tools for Obsidian.",
"main": "main.js",
"scripts": {
"build": "tsc --noEmit && node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs",
"test": "vitest run",
"install-vault": "npm run build && mkdir -p '/Users/matthewderman/My Drive/Workspace/.obsidian/plugins/obsidian-master-plugin' && cp main.js manifest.json styles.css '/Users/matthewderman/My Drive/Workspace/.obsidian/plugins/obsidian-master-plugin/'"
"install-vault": "npm run build && VAULT_ROOT=\"${VAULT_ROOT:-$(vault root)}\" node -e \"const fs=require('fs'); const path=require('path'); const root=process.env.VAULT_ROOT; const dest=path.join(root,'.obsidian/plugins/context-nine-hastitian'); fs.mkdirSync(dest,{recursive:true}); for (const file of ['main.js','manifest.json','styles.css']) fs.copyFileSync(file,path.join(dest,file));\""
},
"keywords": [
"obsidian",

View file

@ -1,6 +1,5 @@
import { App, Menu, Modal, Notice, Plugin, TAbstractFile, TFile, TFolder, normalizePath } from "obsidian";
import { App, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder, normalizePath } from "obsidian";
import { dirname, splitExtension } from "./path-utils";
import type { TaskNotesPluginLike } from "./types";
interface HoverTarget {
path: string;
@ -19,9 +18,6 @@ const PATH_SELECTORS = [
export class FileActionService {
private hoveredTarget: HoverTarget | null = null;
private lastMousePosition: { x: number; y: number } | null = null;
private taskModalPatchRestore: (() => void) | null = null;
private taskModalObserver: MutationObserver | null = null;
private taskModalClosers = new WeakMap<HTMLElement, () => void>();
constructor(private readonly app: App) {}
@ -59,77 +55,6 @@ export class FileActionService {
});
}
patchTaskNotesModal(taskNotes: TaskNotesPluginLike | null): void {
const pluginLike = taskNotes as
| (TaskNotesPluginLike & {
openTaskEditModal?: (task: { path: string; title?: string }, onTaskUpdated?: unknown) => void;
})
| null;
const original = pluginLike?.openTaskEditModal;
if (!pluginLike || !original || this.taskModalPatchRestore) {
return;
}
pluginLike.openTaskEditModal = (task, onTaskUpdated) => {
const openedModals: Modal[] = [];
const originalOpen = Modal.prototype.open;
Modal.prototype.open = function (this: Modal): void {
originalOpen.call(this);
openedModals.push(this);
};
try {
original.call(pluginLike, task, onTaskUpdated);
} finally {
Modal.prototype.open = originalOpen;
}
const inject = (): void => {
const taskModal =
openedModals.find((modal) => this.taskModalElementMatchesPath(modal.modalEl, task.path)) ??
openedModals[openedModals.length - 1];
if (taskModal) {
this.injectDeleteIntoTaskModalElement(task.path, taskModal.modalEl, () => taskModal.close());
return;
}
this.injectDeleteIntoTaskModal(task.path);
};
window.setTimeout(inject, 100);
window.setTimeout(inject, 500);
};
this.taskModalPatchRestore = () => {
pluginLike.openTaskEditModal = original;
this.taskModalPatchRestore = null;
};
}
unpatchTaskNotesModal(): void {
this.taskModalPatchRestore?.();
}
registerTaskModalDeleteObserver(plugin: Plugin): void {
if (this.taskModalObserver) {
return;
}
const scan = (): void => this.injectDeleteIntoVisibleTaskModals();
const observer = new MutationObserver(() => {
window.setTimeout(scan, 50);
});
observer.observe(document.body, { childList: true, subtree: true });
this.taskModalObserver = observer;
plugin.register(() => {
observer.disconnect();
if (this.taskModalObserver === observer) {
this.taskModalObserver = null;
}
});
window.setTimeout(scan, 0);
}
async deleteHoveredOrActiveFile(): Promise<void> {
const target = this.resolveTarget();
if (!target) {
@ -195,7 +120,7 @@ export class FileActionService {
await this.app.workspace.getLeaf(false).openFile(file);
}
private async deleteFile(file: TAbstractFile): Promise<boolean> {
async deleteFile(file: TAbstractFile): Promise<boolean> {
const confirmed = await this.app.fileManager.promptForDeletion(file);
if (!confirmed) {
return false;
@ -338,115 +263,4 @@ export class FileActionService {
return this.app.vault.getAbstractFileByPath(target.path);
}
private injectDeleteIntoVisibleTaskModals(): void {
const modals = Array.from(document.querySelectorAll(".modal"));
for (const modal of modals) {
if (!(modal instanceof HTMLElement)) {
continue;
}
const taskPath = this.taskPathFromModal(modal);
if (taskPath) {
this.injectDeleteIntoTaskModalElement(taskPath, modal);
}
}
}
private taskPathFromModal(modal: HTMLElement): string | null {
if (modal.querySelector(".omp-delete-task-button")) {
return null;
}
const text = modal.textContent ?? "";
if (!/Edit task/i.test(text) || !/Task Information/i.test(text)) {
return null;
}
const fileMatch = text.match(/File:\s*([^\n\r]+?\.md)/i);
const path = fileMatch?.[1]?.trim();
if (!path) {
return null;
}
return this.app.vault.getAbstractFileByPath(path) instanceof TFile ? path : null;
}
private injectDeleteIntoTaskModal(taskPath: string): void {
const modals = Array.from(document.querySelectorAll(".modal"));
const modal = modals[modals.length - 1];
this.injectDeleteIntoTaskModalElement(taskPath, modal);
}
private injectDeleteIntoTaskModalElement(
taskPath: string,
modal: Element | undefined,
closeModal?: () => void
): void {
const file = this.app.vault.getAbstractFileByPath(taskPath);
if (!(file instanceof TFile)) {
return;
}
if (!(modal instanceof HTMLElement)) {
return;
}
if (closeModal) {
this.taskModalClosers.set(modal, closeModal);
}
if (modal.querySelector(".omp-delete-task-button")) {
return;
}
const archiveButton = this.archiveButtonInModal(modal);
archiveButton?.addClass("omp-archive-task-button");
const buttonRow =
archiveButton?.parentElement ??
modal.querySelector(".modal-button-container") ??
modal.querySelector(".setting-item:last-child .setting-item-control") ??
modal;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Delete note";
button.addClass("mod-warning", "omp-delete-task-button");
button.addEventListener("click", async () => {
const deleted = await this.deleteFile(file);
if (deleted) {
this.closeTaskModal(modal);
}
});
if (archiveButton?.parentElement === buttonRow) {
archiveButton.insertAdjacentElement("afterend", button);
} else {
buttonRow.appendChild(button);
}
}
private archiveButtonInModal(modal: HTMLElement): HTMLButtonElement | null {
const buttons = Array.from(modal.querySelectorAll("button"));
return buttons.find((button) => button.textContent?.trim() === "Archive") ?? null;
}
private taskModalElementMatchesPath(modal: Element | undefined, taskPath: string): boolean {
if (!(modal instanceof HTMLElement)) {
return false;
}
const text = modal.textContent ?? "";
return /Edit task/i.test(text) && text.includes(taskPath);
}
private closeTaskModal(modal: HTMLElement): void {
const closeModal = this.taskModalClosers.get(modal);
if (closeModal) {
closeModal();
return;
}
const closeButton =
modal.querySelector(".modal-close-button") ??
modal.closest(".modal-container")?.querySelector(".modal-close-button");
if (closeButton instanceof HTMLElement) {
closeButton.click();
return;
}
modal.closest(".modal-container")?.remove();
}
}

View file

@ -1,19 +1,23 @@
import { Notice, Plugin, PluginSettingTab, Setting, TFile, type WorkspaceLeaf } from "obsidian";
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
import { join } from "path";
import { AttachmentRouter, noticeRouteResult } from "./attachment-router";
import { FileActionService } from "./file-actions";
import { TaskCaptureService } from "./task-capture";
import { DEFAULT_SETTINGS, MasterPluginSettings } from "./types";
import { getTaskNotesPlugin } from "./tasknotes";
import { VAULT_COCKPIT_VIEW_TYPE, VaultCockpitView } from "./vault-cockpit";
import { TaskContextRouterService } from "./task-context-router";
import { TaskNotesUxService } from "./tasknotes-ux";
import { TaskNotesModalUiService } from "./tasknotes-modal-ui";
export default class ObsidianMasterPlugin extends Plugin {
export default class ContextNinePlugin extends Plugin {
settings: MasterPluginSettings;
private router: AttachmentRouter;
private taskCapture: TaskCaptureService;
private fileActions: FileActionService;
private taskContextRouter: TaskContextRouterService;
private taskNotesUx: TaskNotesUxService;
private taskNotesModalUi: TaskNotesModalUiService;
private queuedInboxPaths = new Set<string>();
private gcalSyncProcess: ChildProcessWithoutNullStreams | null = null;
@ -21,7 +25,9 @@ export default class ObsidianMasterPlugin extends Plugin {
await this.loadSettings();
this.router = new AttachmentRouter(this.app, () => this.settings);
this.fileActions = new FileActionService(this.app);
this.taskContextRouter = new TaskContextRouterService(this.app, () => this.settings);
this.taskNotesUx = new TaskNotesUxService(this.app);
this.taskNotesModalUi = new TaskNotesModalUiService(this.app, () => this.settings);
this.taskCapture = new TaskCaptureService(
this.app,
this.router,
@ -142,20 +148,28 @@ export default class ObsidianMasterPlugin extends Plugin {
},
});
this.addCommand({
id: "route-task-files-by-context",
name: "Route TaskNotes files by context",
callback: () => {
void this.routeTaskFilesByContext();
},
});
if (this.settings.enableAutoAttachmentRouter) {
this.registerAttachmentWatcher();
}
if (this.settings.enableGcalSync) {
this.registerGcalSyncTimer();
}
if (this.settings.enableTaskContextRouter) {
this.taskContextRouter.register(this);
}
if (this.settings.hoveredDeleteEnabled) {
this.fileActions.register(this);
}
if (this.settings.taskModalDeleteButtonEnabled) {
this.fileActions.registerTaskModalDeleteObserver(this);
this.app.workspace.onLayoutReady(() => {
this.fileActions.patchTaskNotesModal(getTaskNotesPlugin(this.app));
});
this.taskNotesModalUi.register(this);
}
this.taskNotesUx.register(this);
@ -163,21 +177,36 @@ export default class ObsidianMasterPlugin extends Plugin {
}
onunload(): void {
this.fileActions?.unpatchTaskNotesModal();
this.taskNotesModalUi?.unpatch();
this.taskNotesUx?.unpatch();
}
async loadSettings(): Promise<void> {
const loadedSettings = (await this.loadData()) as Partial<MasterPluginSettings> | null;
const currentVaultRoot = this.getCurrentVaultRoot();
this.settings = {
...DEFAULT_SETTINGS,
...((await this.loadData()) as Partial<MasterPluginSettings> | null),
...loadedSettings,
vaultRoot: currentVaultRoot || loadedSettings?.vaultRoot || DEFAULT_SETTINGS.vaultRoot,
};
if (currentVaultRoot && loadedSettings?.vaultRoot !== currentVaultRoot) {
await this.saveSettings();
}
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
getCurrentVaultRoot(): string {
const adapter = this.app.vault.adapter as { getBasePath?: () => string };
return adapter.getBasePath?.() ?? "";
}
getVaultCommand(command: string, cwd: string): string {
return command === "vault" ? join(cwd, "master/system/scripts/vault.py") : command;
}
async openVaultCockpit(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VAULT_COCKPIT_VIEW_TYPE)[0];
if (existing) {
@ -280,6 +309,11 @@ export default class ObsidianMasterPlugin extends Plugin {
noticeRouteResult(count);
}
private async routeTaskFilesByContext(): Promise<void> {
const moved = await this.taskContextRouter.routeAllTasks();
new Notice(`Moved ${moved} task file${moved === 1 ? "" : "s"}.`);
}
private registerGcalSyncTimer(): void {
const interval = Math.max(60, this.settings.gcalSyncIntervalSeconds) * 1000;
this.registerInterval(
@ -294,8 +328,11 @@ export default class ObsidianMasterPlugin extends Plugin {
return;
}
const command = this.settings.vaultCommand || DEFAULT_SETTINGS.vaultCommand;
const cwd = this.settings.vaultRoot || DEFAULT_SETTINGS.vaultRoot;
const cwd = this.settings.vaultRoot || this.getCurrentVaultRoot() || DEFAULT_SETTINGS.vaultRoot;
const command = this.getVaultCommand(
this.settings.vaultCommand || DEFAULT_SETTINGS.vaultCommand,
cwd
);
const child = spawn(command, ["gcal", "sync-tasks", "--apply"], {
cwd,
env: {
@ -310,37 +347,37 @@ export default class ObsidianMasterPlugin extends Plugin {
child.stdout.on("data", (data: Buffer) => {
const text = data.toString().trim();
if (text) {
console.log("[Obsidian Master Plugin] gcal sync:", text);
console.log("[Context Nine] gcal sync:", text);
}
});
child.stderr.on("data", (data: Buffer) => {
const text = data.toString().trim();
if (text) {
console.warn("[Obsidian Master Plugin] gcal sync:", text);
console.warn("[Context Nine] gcal sync:", text);
}
});
child.on("error", (error) => {
this.gcalSyncProcess = null;
console.error("[Obsidian Master Plugin] gcal sync failed", error);
console.error("[Context Nine] gcal sync failed", error);
});
child.on("close", (exitCode) => {
this.gcalSyncProcess = null;
if (exitCode !== 0) {
console.warn(`[Obsidian Master Plugin] gcal sync exited with ${exitCode}`);
console.warn(`[Context Nine] gcal sync exited with ${exitCode}`);
}
});
}
}
class ObsidianMasterSettingTab extends PluginSettingTab {
constructor(private readonly plugin: ObsidianMasterPlugin) {
constructor(private readonly plugin: ContextNinePlugin) {
super(plugin.app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Obsidian Master Plugin" });
containerEl.createEl("h2", { text: "Context Nine" });
new Setting(containerEl).setName("Default capture context").addText((text) => {
text.setValue(this.plugin.settings.defaultContext).onChange(async (value) => {
@ -394,6 +431,13 @@ class ObsidianMasterSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl).setName("Move TaskNotes files when context changes").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.enableTaskContextRouter).onChange(async (value) => {
this.plugin.settings.enableTaskContextRouter = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl).setName("Hovered file actions").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.hoveredDeleteEnabled).onChange(async (value) => {
this.plugin.settings.hoveredDeleteEnabled = value;
@ -401,7 +445,7 @@ class ObsidianMasterSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl).setName("Delete button in TaskNotes edit modal").addToggle((toggle) => {
new Setting(containerEl).setName("TaskNotes edit modal enhancements").addToggle((toggle) => {
toggle.setValue(this.plugin.settings.taskModalDeleteButtonEnabled).onChange(async (value) => {
this.plugin.settings.taskModalDeleteButtonEnabled = value;
await this.plugin.saveSettings();
@ -417,7 +461,8 @@ class ObsidianMasterSettingTab extends PluginSettingTab {
new Setting(containerEl).setName("Vault root").addText((text) => {
text.setValue(this.plugin.settings.vaultRoot).onChange(async (value) => {
this.plugin.settings.vaultRoot = value.trim() || DEFAULT_SETTINGS.vaultRoot;
this.plugin.settings.vaultRoot =
value.trim() || this.plugin.getCurrentVaultRoot() || DEFAULT_SETTINGS.vaultRoot;
await this.plugin.saveSettings();
});
});

View file

@ -29,6 +29,19 @@ export function taskFolderForContext(context: string): string {
return `${context}/_obsidian/tasks`;
}
export function isTaskFilePath(path: string, knownRoots: string[]): boolean {
const normalized = normalizeVaultPath(path);
if (!normalized.endsWith(".md")) {
return false;
}
const root = topRoot(normalized, knownRoots);
return root !== null && normalized.startsWith(`${root}/_obsidian/tasks/`);
}
export function taskDestinationPathForContext(path: string, context: string): string {
return normalizeVaultPath(`${taskFolderForContext(context)}/${basename(path)}`);
}
export function normalizeVaultPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+/, "");
}

132
src/task-context-router.ts Normal file
View file

@ -0,0 +1,132 @@
import { App, Component, Notice, TFile, normalizePath, type CachedMetadata } from "obsidian";
import { basename, isTaskFilePath, taskDestinationPathForContext } from "./path-utils";
import { taskContextFromFrontmatter } from "./task-context";
import { getTaskNotesPlugin } from "./tasknotes";
import type { MasterPluginSettings, TaskNotesPluginLike } from "./types";
export class TaskContextRouterService {
private readonly queuedPaths = new Set<string>();
private flushTimer: number | null = null;
private moving = false;
constructor(
private readonly app: App,
private readonly getSettings: () => MasterPluginSettings
) {}
register(plugin: Component): void {
plugin.registerEvent(
this.app.metadataCache.on("changed", (file, _data, cache) => {
this.queueChangedFile(file, cache);
})
);
}
async routeAllTasks(): Promise<number> {
const taskNotes = getTaskNotesPlugin(this.app);
let moved = 0;
for (const file of this.app.vault.getMarkdownFiles()) {
if (await this.routeTaskFile(file, taskNotes)) {
moved += 1;
}
}
return moved;
}
private queueChangedFile(file: TFile, cache: CachedMetadata): void {
if (this.moving) {
return;
}
const settings = this.getSettings();
if (!isTaskFilePath(file.path, settings.knownRoots)) {
return;
}
const taskNotes = getTaskNotesPlugin(this.app);
const context = taskContextFromFrontmatter(cache.frontmatter, taskNotes);
if (!this.shouldRoute(file.path, context)) {
return;
}
this.queuedPaths.add(file.path);
if (this.flushTimer !== null) {
window.clearTimeout(this.flushTimer);
}
this.flushTimer = window.setTimeout(() => {
this.flushTimer = null;
void this.flushQueue();
}, 750);
}
private async flushQueue(): Promise<void> {
const paths = [...this.queuedPaths];
this.queuedPaths.clear();
const taskNotes = getTaskNotesPlugin(this.app);
for (const path of paths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
await this.routeTaskFile(file, taskNotes);
}
}
}
private async routeTaskFile(file: TFile, taskNotes: TaskNotesPluginLike | null): Promise<boolean> {
const settings = this.getSettings();
if (!isTaskFilePath(file.path, settings.knownRoots)) {
return false;
}
const cache = this.app.metadataCache.getFileCache(file);
const context = taskContextFromFrontmatter(cache?.frontmatter, taskNotes);
if (!this.shouldRoute(file.path, context)) {
return false;
}
const targetPath = await this.uniqueTargetPath(taskDestinationPathForContext(file.path, context!));
const oldPath = file.path;
await this.ensureFolder(`${context}/_obsidian/tasks`);
this.moving = true;
try {
await this.app.vault.rename(file, targetPath);
taskNotes?.cacheManager?.clearCacheEntry?.(oldPath);
taskNotes?.cacheManager?.clearCacheEntry?.(targetPath);
taskNotes?.notifyDataChanged?.(targetPath, false, true);
new Notice(`Moved task to ${targetPath}`);
return true;
} finally {
this.moving = false;
}
}
private shouldRoute(path: string, context: string | null): boolean {
if (!context || !this.getSettings().knownRoots.includes(context)) {
return false;
}
const targetPath = taskDestinationPathForContext(path, context);
return normalizePath(path) !== normalizePath(targetPath);
}
private async uniqueTargetPath(path: string): Promise<string> {
if (!(await this.app.vault.adapter.exists(path))) {
return path;
}
const ext = ".md";
const stem = path.endsWith(ext) ? path.slice(0, -ext.length) : path;
for (let index = 2; index < 500; index += 1) {
const candidate = `${stem} (${index})${ext}`;
if (!(await this.app.vault.adapter.exists(candidate))) {
return candidate;
}
}
throw new Error(`Could not find a free task filename for ${basename(path)}`);
}
private async ensureFolder(folderPath: string): Promise<void> {
const parts = normalizePath(folderPath).split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
if (!(await this.app.vault.adapter.exists(current))) {
await this.app.vault.createFolder(current);
}
}
}
}

29
src/task-context.ts Normal file
View file

@ -0,0 +1,29 @@
import { getTaskNotesField } from "./tasknotes";
import type { TaskNotesPluginLike } from "./types";
export function firstContextValue(value: unknown): string | null {
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string" && item.trim()) {
return item.trim();
}
}
return null;
}
if (typeof value === "string") {
const context = value.split(",")[0]?.trim();
return context || null;
}
return null;
}
export function taskContextFromFrontmatter(
frontmatter: Record<string, unknown> | undefined,
taskNotes: TaskNotesPluginLike | null
): string | null {
if (!frontmatter) {
return null;
}
const contextsField = getTaskNotesField(taskNotes, "contexts");
return firstContextValue(frontmatter[contextsField] ?? frontmatter.contexts);
}

54
src/task-modal-fields.ts Normal file
View file

@ -0,0 +1,54 @@
export interface VaultEntityChoice {
title: string;
path: string;
link: string;
}
export function stripMarkdownExtension(path: string): string {
return path.endsWith(".md") ? path.slice(0, -3) : path;
}
export function entityLink(path: string, title: string): string {
return `[[${stripMarkdownExtension(path)}|${title}]]`;
}
export function linkLabel(value: unknown): string | null {
const text = firstStringValue(value);
if (!text) {
return null;
}
const aliased = text.match(/^\[\[[^|\]]+\|([^\]]+)\]\]$/);
if (aliased) {
return aliased[1].trim() || null;
}
const plain = text.match(/^\[\[([^\]]+)\]\]$/);
if (plain) {
const target = plain[1].split("|")[0];
return target.split("/").pop()?.replace(/\.md$/, "").trim() || null;
}
return text.trim() || null;
}
export function firstStringValue(value: unknown): string | null {
if (Array.isArray(value)) {
const item = value.find((part) => typeof part === "string" && part.trim());
return typeof item === "string" ? item.trim() : null;
}
if (typeof value === "string" && value.trim()) {
return value.trim();
}
return null;
}
export function vaultCreateArgs(
kind: "epic" | "project",
context: string,
title: string,
epicTitle?: string | null
): string[] {
const args = [kind, "create", context, title];
if (kind === "project" && epicTitle) {
args.push("--epic", epicTitle);
}
return args;
}

View file

@ -29,6 +29,24 @@ export function parseEpicPathFromBaseText(text: string): string | null {
return match?.[1]?.trim() || null;
}
export function parseContextFromBaseText(text: string): string | null {
const folderMatches = [
...text.matchAll(/\bfile\.inFolder\("(\d\d-[^"/]+)\/_obsidian\/(?:tasks|projects|epics)(?:\/[^"]*)?"\)/g),
];
if (folderMatches.length === 1) {
return folderMatches[0][1];
}
const contextMatches = [
...text.matchAll(/\bcontexts?\s*(?:==|\.contains\()\s*"?(\d\d-[^")\]]+)"?/g),
];
if (contextMatches.length === 1) {
return contextMatches[0][1].trim();
}
return null;
}
export function contextFromPathRoot(path: string | null | undefined): string | null {
const root = path?.split("/")[0]?.trim();
return root && /^\d\d-/.test(root) ? root : null;

587
src/tasknotes-modal-ui.ts Normal file
View file

@ -0,0 +1,587 @@
import { App, Menu, Modal, Notice, Plugin, Setting, TFile, TFolder, normalizePath } from "obsidian";
import { spawn } from "child_process";
import { firstContextValue } from "./task-context";
import {
entityLink,
firstStringValue,
linkLabel,
vaultCreateArgs,
type VaultEntityChoice,
} from "./task-modal-fields";
import {
getTaskNotesField,
getTaskNotesPlugin,
notifyTaskNotesChanged,
} from "./tasknotes";
import type { MasterPluginSettings, TaskInfoLike, TaskNotesPluginLike } from "./types";
export class TaskNotesModalUiService {
private patchRestore: (() => void) | null = null;
private observer: MutationObserver | null = null;
private closers = new WeakMap<HTMLElement, () => void>();
constructor(
private readonly app: App,
private readonly getSettings: () => MasterPluginSettings
) {}
register(plugin: Plugin): void {
this.registerObserver(plugin);
const patch = (): void => this.patchTaskNotesModal(getTaskNotesPlugin(this.app));
this.app.workspace.onLayoutReady(() => {
patch();
window.setTimeout(patch, 500);
window.setTimeout(patch, 1500);
window.setTimeout(() => this.enhanceVisibleTaskModals(), 0);
});
}
unpatch(): void {
this.patchRestore?.();
}
patchTaskNotesModal(taskNotes: TaskNotesPluginLike | null): void {
const original = taskNotes?.openTaskEditModal;
if (!taskNotes || !original || this.patchRestore) {
return;
}
taskNotes.openTaskEditModal = (task, onTaskUpdated) => {
const openedModals: Modal[] = [];
const originalOpen = Modal.prototype.open;
Modal.prototype.open = function (this: Modal): void {
originalOpen.call(this);
openedModals.push(this);
};
try {
original.call(taskNotes, task, onTaskUpdated);
} finally {
Modal.prototype.open = originalOpen;
}
const enhance = (): void => {
const modal =
openedModals.find((candidate) => this.modalMatchesPath(candidate.modalEl, task.path)) ??
openedModals[openedModals.length - 1];
if (modal?.modalEl instanceof HTMLElement) {
modal.modalEl.dataset.ompTaskPath = task.path;
}
this.enhanceTaskModal(task, modal?.modalEl, () => modal?.close());
};
window.setTimeout(enhance, 100);
window.setTimeout(enhance, 500);
};
this.patchRestore = () => {
taskNotes.openTaskEditModal = original;
this.patchRestore = null;
};
}
private registerObserver(plugin: Plugin): void {
if (this.observer) {
return;
}
const observer = new MutationObserver(() => {
window.setTimeout(() => this.enhanceVisibleTaskModals(), 50);
});
observer.observe(document.body, { childList: true, subtree: true });
this.observer = observer;
plugin.register(() => {
observer.disconnect();
if (this.observer === observer) {
this.observer = null;
}
});
window.setTimeout(() => this.enhanceVisibleTaskModals(), 0);
}
private enhanceVisibleTaskModals(): void {
for (const modal of Array.from(document.querySelectorAll(".modal"))) {
if (!(modal instanceof HTMLElement) || modal.hasClass("omp-task-modal-enhanced")) {
continue;
}
const path = this.taskPathFromModal(modal);
if (path) {
this.enhanceTaskModal({ path }, modal);
}
}
}
private enhanceTaskModal(task: Pick<TaskInfoLike, "path" | "title">, modal: Element | undefined, closeModal?: () => void): void {
if (!(modal instanceof HTMLElement) || modal.hasClass("omp-task-modal-enhanced")) {
return;
}
const file = this.fileForTask(task.path);
if (!file) {
return;
}
modal.dataset.ompTaskPath = task.path;
const left = this.leftPanel(modal);
const title = this.titleBlock(modal);
if (!(left instanceof HTMLElement) || !(title instanceof HTMLElement)) {
this.injectDeleteButton(file, modal, closeModal);
return;
}
modal.addClass("omp-task-modal-enhanced");
if (closeModal) {
this.closers.set(modal, closeModal);
}
this.normalizeTitleLabel(left);
this.hideNativePrimaryRows(left);
const primary = left.createDiv({ cls: "omp-task-primary-fields" });
const divider = left.createDiv({ cls: "omp-task-secondary-divider" });
title.insertAdjacentElement("afterend", primary);
primary.insertAdjacentElement("afterend", divider);
const actionBar = left.querySelector(".action-bar");
const details = left.querySelector(".details-container");
if (actionBar instanceof HTMLElement && details instanceof HTMLElement) {
details.insertAdjacentElement("beforebegin", actionBar);
}
const contextButton = this.addDropdownRow(primary, "Context");
const epicButton = this.addDropdownRow(primary, "Epic");
const projectButton = this.addDropdownRow(primary, "Project");
const refresh = (): void => void this.refreshButtons(file, contextButton, epicButton, projectButton);
contextButton.addEventListener("click", () => void this.openContextMenu(file, contextButton, refresh));
epicButton.addEventListener("click", () => void this.openEntityMenu("epic", file, epicButton, refresh));
projectButton.addEventListener("click", () => void this.openEntityMenu("project", file, projectButton, refresh));
refresh();
this.injectDeleteButton(file, modal, closeModal);
}
private addDropdownRow(parent: HTMLElement, label: string): HTMLButtonElement {
const row = parent.createDiv({ cls: "omp-task-primary-row" });
row.createDiv({ cls: "omp-task-primary-label", text: label });
const button = row.createEl("button", {
cls: "omp-task-primary-dropdown",
type: "button",
text: "None",
});
button.setAttr("aria-label", label);
return button;
}
private async refreshButtons(
file: TFile,
contextButton: HTMLButtonElement,
epicButton: HTMLButtonElement,
projectButton: HTMLButtonElement
): Promise<void> {
const metadata = this.app.metadataCache.getFileCache(file)?.frontmatter;
const taskNotes = getTaskNotesPlugin(this.app);
const context =
firstContextValue(metadata?.[getTaskNotesField(taskNotes, "contexts")] ?? metadata?.contexts) ??
this.contextFromPath(file.path) ??
this.getSettings().defaultContext;
contextButton.setText(context);
epicButton.setText(linkLabel(metadata?.epic) ?? "No epic");
projectButton.setText(linkLabel(metadata?.[getTaskNotesField(taskNotes, "projects")] ?? metadata?.projects) ?? "No project");
this.syncNativeInputs(file, context, metadata?.epic, metadata?.projects);
}
private async openContextMenu(file: TFile, button: HTMLElement, refresh: () => void): Promise<void> {
const menu = new Menu();
for (const context of await this.activeContexts()) {
menu.addItem((item) => {
item.setTitle(context).onClick(() => {
void this.writeTaskFields(file, { contexts: [context] }).then(refresh);
});
});
}
menu.showAtPosition(this.menuPosition(button));
}
private async openEntityMenu(
kind: "epic" | "project",
file: TFile,
button: HTMLElement,
refresh: () => void
): Promise<void> {
const context = this.currentContext(file);
const menu = new Menu();
const choices = this.entityChoices(context, kind === "epic" ? "epics" : "projects");
menu.addItem((item) => {
item.setTitle(`New ${kind}...`).setIcon("plus").onClick(() => {
new EntityNameModal(this.app, `New ${kind}`, async (title) => {
await this.createEntity(kind, file, context, title);
refresh();
}).open();
});
});
menu.addSeparator();
for (const choice of choices) {
menu.addItem((item) => {
item.setTitle(choice.title).onClick(() => {
const fields = kind === "epic" ? { epic: choice.link } : { projects: [choice.link] };
void this.writeTaskFields(file, fields).then(refresh);
});
});
}
menu.showAtPosition(this.menuPosition(button));
}
private async createEntity(
kind: "epic" | "project",
file: TFile,
context: string,
title: string
): Promise<void> {
const epicTitle = kind === "project" ? linkLabel(this.currentFrontmatter(file)?.epic) : null;
await this.runVault(vaultCreateArgs(kind, context, title, epicTitle));
const folder = kind === "epic" ? "epics" : "projects";
const path = normalizePath(`${context}/_obsidian/${folder}/${this.safeFilename(title)}.md`);
const link = entityLink(path, title);
await this.writeTaskFields(file, kind === "epic" ? { epic: link } : { projects: [link] });
new Notice(`Created ${kind}: ${title}`);
}
private async writeTaskFields(
file: TFile,
fields: { contexts?: string[]; projects?: string[]; epic?: string }
): Promise<void> {
this.syncWrittenFields(file, fields);
const taskNotes = getTaskNotesPlugin(this.app);
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (fields.contexts) {
frontmatter[getTaskNotesField(taskNotes, "contexts")] = fields.contexts.slice(0, 1);
}
if (fields.projects) {
frontmatter[getTaskNotesField(taskNotes, "projects")] = fields.projects.slice(0, 1);
}
if (fields.epic) {
frontmatter.epic = fields.epic;
}
frontmatter[getTaskNotesField(taskNotes, "dateModified")] = new Date().toISOString();
});
notifyTaskNotesChanged(taskNotes, file);
}
private syncWrittenFields(file: TFile, fields: { contexts?: string[]; projects?: string[]; epic?: string }): void {
const modal = this.enhancedModalForFile(file);
if (!modal) {
return;
}
if (fields.contexts?.[0]) {
this.setInputNearLabel(modal, /^contexts?$/i, fields.contexts[0]);
}
if (fields.epic) {
this.setInputNearText(modal, /choose epic/i, fields.epic);
}
if (fields.projects?.[0]) {
this.setInputNearLabel(modal, /^projects?$/i, fields.projects[0]);
}
}
private runVault(args: string[]): Promise<void> {
const settings = this.getSettings();
const command = settings.vaultCommand || "vault";
const cwd = settings.vaultRoot;
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
PATH: process.env.PATH
? `${process.env.HOME}/.local/bin:${process.env.PATH}`
: `${process.env.HOME}/.local/bin`,
},
});
let output = "";
child.stdout.on("data", (data: Buffer) => {
output += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
output += data.toString();
});
child.on("error", (error) => {
new Notice(`vault ${args.join(" ")} failed: ${error.message}`);
reject(error);
});
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
const message = output.trim() || `exit ${code}`;
new Notice(`vault ${args.join(" ")} failed: ${message}`);
reject(new Error(message));
});
});
}
private activeContexts(): string[] {
return this.getSettings().knownRoots.filter((root) => {
if (!/^\d\d-/.test(root)) {
return false;
}
const home = this.app.vault.getAbstractFileByPath(`${root}/HOME.md`);
if (!(home instanceof TFile)) {
return false;
}
const cache = this.app.metadataCache.getFileCache(home);
return cache?.frontmatter?.status === "active";
});
}
private entityChoices(context: string, folderName: "epics" | "projects"): VaultEntityChoice[] {
const folder = this.app.vault.getAbstractFileByPath(`${context}/_obsidian/${folderName}`);
if (!(folder instanceof TFolder)) {
return [];
}
return folder.children
.filter((child): child is TFile => child instanceof TFile && child.extension === "md")
.map((child) => {
const title = String(this.app.metadataCache.getFileCache(child)?.frontmatter?.title ?? child.basename);
return { title, path: child.path, link: entityLink(child.path, title) };
})
.sort((a, b) => a.title.localeCompare(b.title));
}
private currentContext(file: TFile): string {
const frontmatter = this.currentFrontmatter(file);
const taskNotes = getTaskNotesPlugin(this.app);
return (
firstContextValue(frontmatter?.[getTaskNotesField(taskNotes, "contexts")] ?? frontmatter?.contexts) ??
this.contextFromPath(file.path) ??
this.getSettings().defaultContext
);
}
private currentFrontmatter(file: TFile): Record<string, unknown> | undefined {
return this.app.metadataCache.getFileCache(file)?.frontmatter as Record<string, unknown> | undefined;
}
private contextFromPath(path: string): string | null {
const root = path.split("/")[0];
return this.getSettings().knownRoots.includes(root) ? root : null;
}
private menuPosition(element: HTMLElement): { x: number; y: number } {
const rect = element.getBoundingClientRect();
return { x: rect.left, y: rect.bottom + 4 };
}
private normalizeTitleLabel(left: HTMLElement): void {
const titleLabel = this.labelElement(left, /^title$/i);
if (titleLabel instanceof HTMLElement) {
titleLabel.setText("Title");
}
}
private hideNativePrimaryRows(left: HTMLElement): void {
for (const label of [/^contexts?$/i, /^projects?$/i, /^epic$/i]) {
const row = this.rowForLabel(left, label);
row?.addClass("omp-task-modal-native-hidden");
}
}
private syncNativeInputs(file: TFile, context: string, epic: unknown, projects: unknown): void {
const modal = this.enhancedModalForFile(file);
if (!modal) {
return;
}
this.setInputNearLabel(modal, /^contexts?$/i, context);
this.setInputNearText(modal, /choose epic/i, firstStringValue(epic) ?? "");
this.setInputNearLabel(modal, /^projects?$/i, firstStringValue(projects) ?? "");
}
private enhancedModalForFile(file: TFile): HTMLElement | null {
for (const modal of Array.from(document.querySelectorAll(".modal.omp-task-modal-enhanced"))) {
if (modal instanceof HTMLElement && this.modalMatchesPath(modal, file.path)) {
return modal;
}
}
return null;
}
private setInputNearLabel(modal: HTMLElement, label: RegExp, value: string): void {
const row = this.rowForLabel(modal, label);
if (row) {
this.setFirstInputValue(row, value);
}
}
private setInputNearText(modal: HTMLElement, text: RegExp, value: string): void {
const row = Array.from(modal.querySelectorAll(".setting-item")).find((candidate) =>
text.test(candidate.textContent ?? "")
);
if (row instanceof HTMLElement) {
this.setFirstInputValue(row, value);
}
}
private leftPanel(modal: HTMLElement): HTMLElement | null {
const splitLeft = modal.querySelector(".modal-split-left");
if (splitLeft instanceof HTMLElement) {
return splitLeft;
}
const contexts = this.labelElement(modal, /^contexts?$/i);
const title = this.labelElement(modal, /^title$/i);
return contexts?.parentElement?.parentElement ?? title?.parentElement?.parentElement ?? null;
}
private titleBlock(modal: HTMLElement): HTMLElement | null {
const known = modal.querySelector(".title-input-container, .nl-markdown-editor");
if (known instanceof HTMLElement) {
return known;
}
return this.rowForLabel(modal, /^title$/i);
}
private rowForLabel(root: HTMLElement, label: RegExp): HTMLElement | null {
const known = Array.from(root.querySelectorAll(".setting-item")).find((candidate) => {
const name = candidate.querySelector(".setting-item-name")?.textContent?.trim() ?? "";
return label.test(name);
});
if (known instanceof HTMLElement) {
return known;
}
const labelEl = this.labelElement(root, label);
return labelEl?.closest(".setting-item, .task-modal__field, .tasknotes-field") as HTMLElement | null
?? labelEl?.parentElement ?? null;
}
private labelElement(root: HTMLElement, label: RegExp): HTMLElement | null {
return Array.from(root.querySelectorAll(".setting-item-name, .detail-label, label, div, span")).find(
(candidate): candidate is HTMLElement =>
candidate instanceof HTMLElement && label.test(candidate.textContent?.trim() ?? "")
) ?? null;
}
private setFirstInputValue(parent: HTMLElement, value: string): void {
const input = parent.querySelector("input");
if (input instanceof HTMLInputElement) {
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
private safeFilename(title: string): string {
return title.replace(/[\\/:*?"<>|\r\n\t]/g, "-").trim() || "Untitled";
}
private fileForTask(path: string): TFile | null {
const file = this.app.vault.getAbstractFileByPath(path);
return file instanceof TFile ? file : null;
}
private taskPathFromModal(modal: HTMLElement): string | null {
const dataPath = modal.dataset.ompTaskPath;
if (dataPath && this.fileForTask(dataPath)) {
return dataPath;
}
const text = modal.textContent ?? "";
if (!/Edit task/i.test(text) || !/Task Information/i.test(text)) {
return null;
}
const fileMatch = text.match(/File:\s*([\s\S]+?\.md)/i);
const path = fileMatch?.[1]?.replace(/\s+/g, " ").replace(/ \/ /g, "/").trim();
return path && this.fileForTask(path) ? path : null;
}
private modalMatchesPath(modal: Element | undefined, taskPath: string): boolean {
if (!(modal instanceof HTMLElement)) {
return false;
}
if (modal.dataset.ompTaskPath === taskPath) {
return true;
}
const text = modal.textContent ?? "";
return /Edit task/i.test(text) && text.includes(taskPath);
}
private injectDeleteButton(file: TFile, modal: HTMLElement, closeModal?: () => void): void {
if (closeModal) {
this.closers.set(modal, closeModal);
}
if (modal.querySelector(".omp-delete-task-button")) {
return;
}
const archiveButton = Array.from(modal.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Archive"
);
archiveButton?.addClass("omp-archive-task-button");
const buttonRow =
archiveButton?.parentElement ??
modal.querySelector(".modal-button-container") ??
modal;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Delete note";
button.addClass("mod-warning", "omp-delete-task-button");
button.addEventListener("click", async () => {
const confirmed = await this.app.fileManager.promptForDeletion(file);
if (!confirmed) {
return;
}
await this.app.fileManager.trashFile(file);
new Notice(`File deleted: ${file.path}`);
this.closeTaskModal(modal);
});
if (archiveButton?.parentElement === buttonRow) {
archiveButton.insertAdjacentElement("afterend", button);
} else {
buttonRow.appendChild(button);
}
}
private closeTaskModal(modal: HTMLElement): void {
const closeModal = this.closers.get(modal);
if (closeModal) {
closeModal();
return;
}
const closeButton =
modal.querySelector(".modal-close-button") ??
modal.closest(".modal-container")?.querySelector(".modal-close-button");
if (closeButton instanceof HTMLElement) {
closeButton.click();
return;
}
modal.closest(".modal-container")?.remove();
}
}
class EntityNameModal extends Modal {
private name = "";
constructor(
app: App,
private readonly titleText: string,
private readonly onSubmit: (name: string) => Promise<void>
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("omp-modal");
new Setting(contentEl).setName(this.titleText).addText((text) => {
text.onChange((value) => {
this.name = value.trim();
});
text.inputEl.focus();
});
const buttons = contentEl.createDiv({ cls: "omp-button-row" });
buttons.createEl("button", { text: "Cancel", type: "button" }).addEventListener("click", () => {
this.close();
});
const create = buttons.createEl("button", { text: "Create", type: "button", cls: "mod-cta" });
create.addEventListener("click", async () => {
if (!this.name) {
new Notice("Name required.");
return;
}
await this.onSubmit(this.name);
this.close();
});
}
}

View file

@ -6,6 +6,7 @@ import {
contextFromWikiLinkValue,
mergeKanbanTaskDefaults,
normalizeSwimlaneProjectValue,
parseContextFromBaseText,
parseEpicPathFromBaseText,
} from "./tasknotes-kanban-defaults";
import { getTaskNotesPlugin } from "./tasknotes";
@ -191,12 +192,13 @@ export class TaskNotesUxService {
}
const baseFile = this.baseFileForElement(column);
this.openTaskModalWithKanbanDefaults(taskNotes, status, project, baseFile).catch((error) => {
this.openTaskModalWithKanbanDefaults(taskNotes, status, project, baseFile).catch(async (error) => {
console.error("Failed to open kanban task modal with defaults:", error);
new Notice("Failed to read kanban defaults.");
const baseContext = baseFile ? await this.contextFromBaseFile(baseFile) : null;
const fallbackContext = project
? this.contextFromWikiLink(project)
: contextFromPathRoot(baseFile?.path);
: baseContext ?? contextFromPathRoot(baseFile?.path);
const defaults = buildKanbanTaskDefaults({
status,
priority: taskNotes.settings?.defaultTaskPriority ?? "normal",
@ -217,9 +219,11 @@ export class TaskNotesUxService {
baseFile: TFile | null
): Promise<void> {
const epic = await this.epicChoiceFromBaseFile(baseFile);
const baseContext = baseFile ? await this.contextFromBaseFile(baseFile) : null;
const context =
(project ? this.contextFromWikiLink(project) : null) ??
contextFromPathRoot(epic?.path) ??
baseContext ??
contextFromPathRoot(baseFile?.path);
const defaults = buildKanbanTaskDefaults({
status,
@ -271,25 +275,60 @@ export class TaskNotesUxService {
}
private baseFileForElement(element: HTMLElement): TFile | null {
const domPath = this.basePathFromElement(element);
if (domPath) {
const domFile = this.app.vault.getAbstractFileByPath(domPath);
if (domFile instanceof TFile) {
return domFile;
}
}
const workspace = this.app.workspace as unknown as {
iterateAllLeaves?: (callback: (leaf: unknown) => void) => void;
};
const leaves: Array<{ view?: { containerEl?: HTMLElement; file?: TFile } }> = [];
const leaves: Array<{ view?: { containerEl?: HTMLElement; file?: TFile; state?: { file?: string } } }> = [];
workspace.iterateAllLeaves?.((leaf) => {
leaves.push(leaf as { view?: { containerEl?: HTMLElement; file?: TFile } });
leaves.push(leaf as { view?: { containerEl?: HTMLElement; file?: TFile; state?: { file?: string } } });
});
const matchingFile = leaves.find((leaf) => {
const matchingLeaf = leaves.find((leaf) => {
return leaf.view?.containerEl?.contains(element) && leaf.view.file instanceof TFile;
})?.view?.file;
});
const matchingFile = matchingLeaf?.view?.file;
if (matchingFile?.path.endsWith(".base")) {
return matchingFile;
}
const statePath = leaves.find((leaf) => leaf.view?.containerEl?.contains(element))?.view?.state?.file;
const stateFile = statePath ? this.app.vault.getAbstractFileByPath(statePath) : null;
if (stateFile instanceof TFile && stateFile.path.endsWith(".base")) {
return stateFile;
}
const activeFile = this.app.workspace.getActiveFile();
return activeFile?.path.endsWith(".base") ? activeFile : null;
}
private basePathFromElement(element: HTMLElement): string | null {
for (let current: Element | null = element; current; current = current.parentElement) {
if (!(current instanceof HTMLElement)) {
continue;
}
for (const attr of ["data-path", "data-file-path", "data-base-path"]) {
const value = current.getAttribute(attr);
if (value?.endsWith(".base")) {
return value;
}
}
}
return null;
}
private async contextFromBaseFile(file: TFile): Promise<string | null> {
const text = await this.app.vault.cachedRead(file);
return parseContextFromBaseText(text);
}
private async epicChoiceFromBaseFile(file: TFile | null): Promise<EpicChoice | null> {
if (!file) {
return null;

View file

@ -22,6 +22,7 @@ export interface MasterPluginSettings {
routeIntervalSeconds: number;
enableGcalSync: boolean;
gcalSyncIntervalSeconds: number;
enableTaskContextRouter: boolean;
deleteSourceAfterCapture: boolean;
hoveredDeleteEnabled: boolean;
taskModalDeleteButtonEnabled: boolean;
@ -39,11 +40,12 @@ export const DEFAULT_SETTINGS: MasterPluginSettings = {
routeIntervalSeconds: 60,
enableGcalSync: true,
gcalSyncIntervalSeconds: 300,
enableTaskContextRouter: true,
deleteSourceAfterCapture: true,
hoveredDeleteEnabled: true,
taskModalDeleteButtonEnabled: true,
vaultCommand: "vault",
vaultRoot: "/Users/matthewderman/My Drive/Workspace",
vaultRoot: "",
};
export interface CapturedSelection {

View file

@ -1,5 +1,5 @@
import { ItemView, Modal, Notice, Setting, type App, type IconName, type WorkspaceLeaf } from "obsidian";
import type ObsidianMasterPlugin from "./main";
import type ContextNinePlugin from "./main";
import { parseAdditionalArgs } from "./vault-args";
import {
FALLBACK_VAULT_COMMANDS,
@ -32,7 +32,7 @@ export class VaultCockpitView extends ItemView {
constructor(
leaf: WorkspaceLeaf,
private readonly plugin: ObsidianMasterPlugin
private readonly plugin: ContextNinePlugin
) {
super(leaf);
}

View file

@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
import { join } from "path";
export type VaultStream = "stdout" | "stderr";
export type VaultRunStatus = "idle" | "running" | "succeeded" | "failed";
@ -43,9 +44,10 @@ export class VaultCommandRunner {
}
const startedAt = new Date();
events.onStart({ spec, command, cwd, startedAt });
const resolvedCommand = command === "vault" ? join(cwd, "master/system/scripts/vault.py") : command;
events.onStart({ spec, command: resolvedCommand, cwd, startedAt });
const child = spawn(command, spec.args, {
const child = spawn(resolvedCommand, spec.args, {
cwd,
env: {
...process.env,
@ -66,7 +68,7 @@ export class VaultCommandRunner {
this.child = null;
events.onFinish({
spec,
command,
command: resolvedCommand,
cwd,
startedAt,
status: exitCode === 0 ? "succeeded" : "failed",

View file

@ -249,21 +249,109 @@
@media (min-width: 900px) {
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded .modal,
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded.modal {
width: min(90vw, 1065px) !important;
width: min(90vw, 1145px) !important;
}
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded .modal-split-left {
flex-basis: 266px !important;
flex-basis: 346px !important;
}
}
@media (min-width: 1200px) {
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded .modal,
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded.modal {
width: min(90vw, 1390px) !important;
width: min(90vw, 1491px) !important;
}
.tasknotes-plugin.minimalist-task-modal.split-layout-enabled.expanded .modal-split-left {
flex-basis: 336px !important;
flex-basis: 437px !important;
}
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .modal-split-left {
gap: 0;
}
.omp-task-modal-enhanced .modal-split-left {
gap: 0;
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .detail-label,
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .details-container .setting-item-name,
.omp-task-modal-enhanced .detail-label,
.omp-task-modal-enhanced .setting-item-name,
.omp-task-primary-label {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-weight: 650;
letter-spacing: 0;
text-transform: none;
}
.omp-task-primary-fields {
display: grid;
gap: 8px;
margin-top: 10px;
}
.omp-task-primary-row {
display: grid;
grid-template-columns: minmax(86px, 0.42fr) minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.omp-task-primary-dropdown {
width: 100%;
min-width: 0;
justify-content: flex-start;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.omp-task-primary-dropdown::after {
content: "▾";
float: right;
color: var(--text-muted);
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .task-modal__section-separator {
display: none;
}
.omp-task-modal-enhanced .task-modal__section-separator {
display: none;
}
.omp-task-secondary-divider {
height: 1px;
margin: 14px 0 10px;
background: var(--background-modifier-border);
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .details-container .setting-item {
border: 0;
padding: 6px 0;
}
.omp-task-modal-enhanced .setting-item {
border: 0;
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .details-container .setting-item-control {
min-width: 0;
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .details-container input {
width: 100%;
}
.tasknotes-plugin.minimalist-task-modal.omp-task-modal-enhanced .action-bar {
margin: 0 0 8px;
}
.omp-task-modal-native-hidden {
display: none !important;
}

View file

@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { attachmentFolderForRoot, firstNonEmptyLine, slugifyTaskTitle, topRoot } from "../src/path-utils";
import {
attachmentFolderForRoot,
firstNonEmptyLine,
isTaskFilePath,
slugifyTaskTitle,
taskDestinationPathForContext,
topRoot,
} from "../src/path-utils";
describe("path utilities", () => {
it("detects known top roots", () => {
@ -17,5 +24,13 @@ describe("path utilities", () => {
expect(firstNonEmptyLine("\n# Ship this\nmore")).toBe("Ship this");
expect(slugifyTaskTitle("bad/name: ok?")).toBe("bad-name- ok-");
});
});
it("detects and routes TaskNotes task files", () => {
const roots = ["01-personal", "03-impression"];
expect(isTaskFilePath("01-personal/_obsidian/tasks/Foo.md", roots)).toBe(true);
expect(isTaskFilePath("01-personal/Matt/Foo.md", roots)).toBe(false);
expect(taskDestinationPathForContext("01-personal/_obsidian/tasks/Foo.md", "03-impression")).toBe(
"03-impression/_obsidian/tasks/Foo.md"
);
});
});

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { firstContextValue, taskContextFromFrontmatter } from "../src/task-context";
describe("task context router", () => {
it("reads first context from TaskNotes frontmatter arrays", () => {
expect(firstContextValue(["03-impression", "02-matt-derman"])).toBe("03-impression");
});
it("reads first context from comma-delimited strings", () => {
expect(firstContextValue("03-impression, 02-matt-derman")).toBe("03-impression");
});
it("uses mapped TaskNotes context field", () => {
expect(
taskContextFromFrontmatter(
{ taskContexts: ["02-matt-derman"] },
{ fieldMapper: { toUserField: (field) => (field === "contexts" ? "taskContexts" : field) } }
)
).toBe("02-matt-derman");
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { entityLink, firstStringValue, linkLabel, vaultCreateArgs } from "../src/task-modal-fields";
describe("task modal field helpers", () => {
it("formats entity links without markdown suffix", () => {
expect(entityLink("03-impression/_obsidian/epics/Backlog.md", "Backlog")).toBe(
"[[03-impression/_obsidian/epics/Backlog|Backlog]]"
);
});
it("extracts display labels from links and arrays", () => {
expect(linkLabel("[[03-impression/_obsidian/epics/Backlog|Backlog]]")).toBe("Backlog");
expect(linkLabel("[[03-impression/_obsidian/projects/Main]]")).toBe("Main");
expect(firstStringValue(["[[x|One]]", "[[x|Two]]"])).toBe("[[x|One]]");
});
it("builds vault create commands", () => {
expect(vaultCreateArgs("epic", "03-impression", "Launch")).toEqual([
"epic",
"create",
"03-impression",
"Launch",
]);
expect(vaultCreateArgs("project", "03-impression", "Launch copy", "Growth")).toEqual([
"project",
"create",
"03-impression",
"Launch copy",
"--epic",
"Growth",
]);
});
});

View file

@ -4,6 +4,7 @@ import {
buildKanbanTaskDefaults,
mergeKanbanTaskDefaults,
normalizeSwimlaneProjectValue,
parseContextFromBaseText,
parseEpicPathFromBaseText,
} from "../src/tasknotes-kanban-defaults";
@ -59,6 +60,16 @@ filters:
).toBe("03-impression/_obsidian/epics/Focus");
});
it("parses context filters from context-scoped base files", () => {
expect(
parseContextFromBaseText(`
filters:
and:
- file.inFolder("01-personal/_obsidian/tasks")
`)
).toBe("01-personal");
});
it("merges kanban defaults into blank task data", () => {
const defaults = buildKanbanTaskDefaults({
status: "backlog",

View file

@ -1,3 +1,3 @@
{
"0.1.0": "1.7.0"
"1.0.0": "1.7.0"
}