Tweaks & fixes

This commit is contained in:
2026-03-29 09:56:40 -04:00
parent 6a2d0cffd6
commit c21d8b1097
11 changed files with 310 additions and 81 deletions

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 1986 bagaag.com
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.

View File

@ -16,14 +16,16 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
### 1.2 Structure ### 1.2 Structure
- Task files consist entirely of markdown checklist items (`- [ ] …` / `- [x] …`). - Task files consist of markdown checklist items (`- [ ] …` / `- [x] …`) and optional header content.
- Any non-checklist lines **before the first checklist item** are treated as **header text**: they are not parsed as tasks, not shown in the UI, and are preserved verbatim when the file is written back.
- Any non-checklist lines immediately following a checklist item are treated as **notes** for that item. - Any non-checklist lines immediately following a checklist item are treated as **notes** for that item.
- Notes are separated from their parent task in the file by a blank line.
- The ordering of checklist items in the file reflects the display order in the UI. - The ordering of checklist items in the file reflects the display order in the UI.
- Completed items remain in the file until the user marks them as done, at which point they are moved to the current Daily Note (see §5). - Completed items remain in the file until the user marks them as done, at which point they are moved to the current Daily Note (see §5).
### 1.3 Inbox File ### 1.3 Inbox File
- One task file is designated as the **Inbox** — the default destination for new tasks. - One task file is designated as the **Inbox** — the default destination when opened via the command palette.
- The Inbox file path is configured in plugin settings. - The Inbox file path is configured in plugin settings.
- Default: `Tasks/Inbox.md`. - Default: `Tasks/Inbox.md`.
@ -31,7 +33,13 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
## 2. Task View UI ## 2. Task View UI
### 2.1 Layout ### 2.1 Source Mode
- The task view header contains an **"Edit source"** action button (code icon).
- Clicking it switches the current leaf to Obsidian's standard markdown editor in source mode, bypassing the normal intercept that redirects task files to the task view.
- To return to task view, use the tab's **"Open as… > YAOTP Task View"** context menu option, or close and reopen the file.
### 2.2 Layout
- The custom view replaces the standard markdown editor for task files. - The custom view replaces the standard markdown editor for task files.
- Each checklist item is displayed as a task row containing: - Each checklist item is displayed as a task row containing:
@ -40,14 +48,14 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
- Notes are **not** shown inline; they are accessible via the task editor (§3). - Notes are **not** shown inline; they are accessible via the task editor (§3).
- The view does not render raw markdown syntax. - The view does not render raw markdown syntax.
### 2.2 Reordering ### 2.3 Reordering
- Task rows can be reordered by dragging and dropping within the view. - Task rows can be reordered by dragging and dropping within the view.
- On mobile, reordering is triggered by a long-press on the drag handle, followed by dragging. - On mobile, reordering is triggered by a long-press on the drag handle, followed by dragging.
- Reordering the UI immediately reorders the corresponding checklist items in the markdown file. - Reordering the UI immediately reorders the corresponding checklist items in the markdown file.
- Notes travel with their parent task during reordering. - Notes travel with their parent task during reordering.
### 2.3 File Switcher ### 2.4 File Switcher
- A header bar or sidebar control lets the user quickly switch between: - A header bar or sidebar control lets the user quickly switch between:
- The Inbox file. - The Inbox file.
@ -57,7 +65,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
- Creates the file in the configured tasks folder (or a location derived from the regex). - Creates the file in the configured tasks folder (or a location derived from the regex).
- Opens the new file in the task view. - Opens the new file in the task view.
### 2.3 Adding a Task ### 2.5 Adding a Task
- The first row of every task list is a persistent **add-task row** that looks identical to a regular task row. - The first row of every task list is a persistent **add-task row** that looks identical to a regular task row.
- The add-task row contains: - The add-task row contains:
@ -68,7 +76,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
- The new task is immediately persisted to the markdown file. - The new task is immediately persisted to the markdown file.
- The add-task row is always visible, even when the list is empty. - The add-task row is always visible, even when the list is empty.
### 2.4 Completing a Task ### 2.6 Completing a Task
- Clicking a task's checkbox marks it as complete. - Clicking a task's checkbox marks it as complete.
- The task (and its notes) is removed from the current task file. - The task (and its notes) is removed from the current task file.
@ -79,11 +87,13 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
## 3. Task Editor ## 3. Task Editor
- Clicking the **text** of a task opens an editor modal or inline panel. - Clicking the **text** of a task opens an editor modal.
- The editor provides: - The editor provides:
- A single **multiline text area** containing the task text on the first line, followed by the notes. The first line is the task title; any subsequent lines are notes. The user edits both in one continuous field. - A single **multiline text area** with the task title on the first line, a blank line, then the notes. The blank line separator is always present when notes exist; if the user removes it or writes notes without it, a blank line is inserted automatically on save.
- A **file selector** (dropdown or fuzzy-search picker) to move the task to a different task file. Selecting a different file removes the task from the current file and appends it to the bottom of the selected file. - A **file selector** (dropdown or fuzzy-search picker) to move the task to a different task file. Selecting a different file removes the task from the current file and appends it to the bottom of the selected file.
- A **Delete** button that, after a confirmation prompt, permanently removes the task from the file.
- Changes are saved back to the markdown file on confirm/close. - Changes are saved back to the markdown file on confirm/close.
- The **"+ New list"** button opens a modal prompting for a file name and creates the file in the configured tasks folder.
--- ---
@ -123,6 +133,7 @@ An Obsidian plugin that provides a more traditional task management GUI for desi
|---|---|---| |---|---|---|
| **Inbox file path** | Vault-relative path to the Inbox task file | `Tasks/Inbox.md` | | **Inbox file path** | Vault-relative path to the Inbox task file | `Tasks/Inbox.md` |
| **Task file regex** | Regex matched against vault-relative file paths to identify task files | `^Tasks\/.*\.md$` | | **Task file regex** | Regex matched against vault-relative file paths to identify task files | `^Tasks\/.*\.md$` |
| **Task file folder** | Vault-relative folder where new task lists are created via the "New list" button; leave empty for the vault root | `Tasks` |
| **Daily note date format** | Date format string used to locate the daily note (should match Daily Notes plugin setting) | `YYYY-MM-DD` | | **Daily note date format** | Date format string used to locate the daily note (should match Daily Notes plugin setting) | `YYYY-MM-DD` |
| **Daily note folder** | Folder where daily notes are stored (should match Daily Notes plugin setting) | *(empty — vault root)* | | **Daily note folder** | Folder where daily notes are stored (should match Daily Notes plugin setting) | *(empty — vault root)* |

View File

@ -5,5 +5,6 @@ export const DEFAULT_INBOX_PATH = 'Tasks/Inbox.md';
export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$'; export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$';
export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD'; export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD';
export const DEFAULT_DAILY_NOTE_FOLDER = ''; export const DEFAULT_DAILY_NOTE_FOLDER = '';
export const DEFAULT_TASK_FILE_FOLDER = 'Tasks';
export const DAILY_NOTE_TASKS_HEADING = '#### Tasks'; export const DAILY_NOTE_TASKS_HEADING = '#### Tasks';

View File

@ -5,6 +5,21 @@ type SetViewState = WorkspaceLeaf['setViewState'];
let originalSetViewState: SetViewState | null = null; let originalSetViewState: SetViewState | null = null;
/**
* Leaves that have been granted a one-shot bypass of the intercept.
* Used to allow the task view's "Source mode" button to switch to the
* markdown editor without being redirected back.
*/
const bypassLeaves = new WeakSet<WorkspaceLeaf>();
/**
* Allow the next setViewState call on this leaf to pass through the
* intercept unmodified. Call this immediately before setViewState.
*/
export function allowMarkdownOnce(leaf: WorkspaceLeaf): void {
bypassLeaves.add(leaf);
}
/** /**
* Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a * Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a
* task file (identified by the given predicate) redirects to our custom * task file (identified by the given predicate) redirects to our custom
@ -25,8 +40,21 @@ export function installFileIntercept(isTaskFile: (path: string) => boolean): voi
if ( if (
state?.type === 'markdown' && state?.type === 'markdown' &&
state?.state?.file && state?.state?.file &&
isTaskFile(state.state.file as string) isTaskFile(state.state.file as string) &&
// Don't intercept mode-only changes within an already-open markdown view
// for the same file (e.g. switching to Source mode via the context menu).
// We DO still intercept if the leaf is markdown but showing a different
// file, or if the view is not yet markdown at all.
!(
this.view?.getViewType() === 'markdown' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.view as any)?.file?.path === (state.state?.file as string)
)
) { ) {
if (bypassLeaves.has(this)) {
bypassLeaves.delete(this);
return originalSetViewState!.call(this, state, eState);
}
const newState = { ...state, type: VIEW_TYPE }; const newState = { ...state, type: VIEW_TYPE };
return originalSetViewState!.call(this, newState, eState); return originalSetViewState!.call(this, newState, eState);
} }

View File

@ -31,12 +31,6 @@ export default class YaotpPlugin extends Plugin {
// Settings tab // Settings tab
this.addSettingTab(new YaotpSettingTab(this.app, this)); this.addSettingTab(new YaotpSettingTab(this.app, this));
// Command: open inbox
this.addCommand({
id: 'open-inbox',
name: 'Open Inbox',
callback: () => this.openInbox(),
});
} }
onunload(): void { onunload(): void {
@ -84,13 +78,4 @@ export default class YaotpPlugin extends Plugin {
}); });
} }
private async openInbox(): Promise<void> {
const { inboxPath } = this.settings;
let file = this.app.vault.getAbstractFileByPath(inboxPath) as TFile | null;
if (!file) {
file = await this.app.vault.create(inboxPath, '');
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
} }

View File

@ -2,8 +2,14 @@ import type { Task } from './types';
const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/; const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
export interface TaskFileData {
/** Raw text before the first checklist item, preserved verbatim on serialize. */
header: string;
tasks: Task[];
}
/** /**
* Parse a task file's markdown content into an array of Tasks. * Parse a task file's markdown content into header text and an array of Tasks.
* *
* Format expected: * Format expected:
* - [ ] Task title * - [ ] Task title
@ -13,13 +19,14 @@ const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
* *
* - [ ] Next task * - [ ] Next task
* *
* Notes are lines between two blank-line boundaries following a checklist item. * Any non-checklist lines before the first task are captured as `header` and
* Blank lines that are not between a checklist item and notes, or between notes * round-tripped unchanged. Notes are lines between two blank-line boundaries
* lines, are ignored. * following a checklist item.
*/ */
export function parseTaskFile(markdown: string): Task[] { export function parseTaskFile(markdown: string): TaskFileData {
const lines = markdown.split('\n'); const lines = markdown.split('\n');
const tasks: Task[] = []; const tasks: Task[] = [];
const headerLines: string[] = [];
let current: Task | null = null; let current: Task | null = null;
// State: 'idle' | 'after-task' | 'in-notes' // State: 'idle' | 'after-task' | 'in-notes'
// after-task: saw checklist line, waiting for blank line or next checklist // after-task: saw checklist line, waiting for blank line or next checklist
@ -56,7 +63,8 @@ export function parseTaskFile(markdown: string): Task[] {
} }
if (current === null) { if (current === null) {
// No active task; ignore non-checklist lines before the first task // Before the first task — capture as header
headerLines.push(line);
continue; continue;
} }
@ -82,13 +90,22 @@ export function parseTaskFile(markdown: string): Task[] {
} }
flush(); flush();
return tasks;
// Trim trailing blank lines from header
while (headerLines.length > 0 && headerLines[headerLines.length - 1].trim() === '') {
headerLines.pop();
}
const header = headerLines.length > 0 ? headerLines.join('\n') + '\n' : '';
return { header, tasks };
} }
/** /**
* Serialize an array of Tasks back to canonical markdown. * Serialize a TaskFileData back to canonical markdown.
*
* The header (if any) is written verbatim first, separated from tasks by a
* blank line. Task format:
* *
* Format produced:
* - [ ] Task title * - [ ] Task title
* *
* notes line 1 * notes line 1
@ -96,11 +113,13 @@ export function parseTaskFile(markdown: string): Task[] {
* *
* - [ ] Next task * - [ ] Next task
* *
* Tasks with no notes have no trailing blank line before the next task, * Tasks with notes always have a blank line between the checklist line and the
* except the final newline at EOF. * first note line. Completed items use `- [x] …`.
*/ */
export function serializeTaskFile(tasks: Task[]): string { export function serializeTaskFile(data: TaskFileData): string {
if (tasks.length === 0) return ''; const { header, tasks } = data;
if (tasks.length === 0) return header || '';
const parts: string[] = []; const parts: string[] = [];
@ -119,5 +138,8 @@ export function serializeTaskFile(tasks: Task[]): string {
} }
// Join and ensure single trailing newline // Join and ensure single trailing newline
return parts.join('\n').replace(/\n+$/, '') + '\n'; const taskContent = parts.join('\n').replace(/\n+$/, '') + '\n';
// header already ends with '\n'; add one more for the blank separator line
return header ? header + '\n' + taskContent : taskContent;
} }

View File

@ -3,6 +3,7 @@ import {
DEFAULT_DAILY_NOTE_FOLDER, DEFAULT_DAILY_NOTE_FOLDER,
DEFAULT_DAILY_NOTE_FORMAT, DEFAULT_DAILY_NOTE_FORMAT,
DEFAULT_INBOX_PATH, DEFAULT_INBOX_PATH,
DEFAULT_TASK_FILE_FOLDER,
DEFAULT_TASK_REGEX, DEFAULT_TASK_REGEX,
} from './constants'; } from './constants';
import type YaotpPlugin from './main'; import type YaotpPlugin from './main';
@ -10,6 +11,7 @@ import type YaotpPlugin from './main';
export interface YaotpSettings { export interface YaotpSettings {
inboxPath: string; inboxPath: string;
taskFileRegex: string; taskFileRegex: string;
taskFileFolder: string;
dailyNoteFormat: string; dailyNoteFormat: string;
dailyNoteFolder: string; dailyNoteFolder: string;
} }
@ -17,6 +19,7 @@ export interface YaotpSettings {
export const DEFAULT_SETTINGS: YaotpSettings = { export const DEFAULT_SETTINGS: YaotpSettings = {
inboxPath: DEFAULT_INBOX_PATH, inboxPath: DEFAULT_INBOX_PATH,
taskFileRegex: DEFAULT_TASK_REGEX, taskFileRegex: DEFAULT_TASK_REGEX,
taskFileFolder: DEFAULT_TASK_FILE_FOLDER,
dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT, dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT,
dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER, dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER,
}; };
@ -69,6 +72,19 @@ export class YaotpSettingTab extends PluginSettingTab {
text.inputEl.style.width = '100%'; text.inputEl.style.width = '100%';
}); });
new Setting(containerEl)
.setName('Task file folder')
.setDesc('Vault-relative folder where new task lists are created. Leave empty for the vault root.')
.addText((text) =>
text
.setPlaceholder('Tasks')
.setValue(this.plugin.settings.taskFileFolder)
.onChange(async (value) => {
this.plugin.settings.taskFileFolder = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl) new Setting(containerEl)
.setName('Daily note date format') .setName('Daily note date format')
.setDesc( .setDesc(

View File

@ -1,7 +1,7 @@
import { App, TFile } from 'obsidian'; import { TFile } from 'obsidian';
export interface FileSwitcherBarOptions { export interface FileSwitcherBarOptions {
app: App; app: unknown; // kept for interface compatibility, unused after modal removal
currentFile: TFile; currentFile: TFile;
taskFiles: TFile[]; taskFiles: TFile[];
inboxPath: string; inboxPath: string;
@ -10,8 +10,8 @@ export interface FileSwitcherBarOptions {
} }
/** /**
* Renders a header bar with a file switcher dropdown and a "New task file" button. * Renders a header bar with a file switcher dropdown and an inline
* Returns the container element to be prepended into the view's root. * "New task file" affordance. Returns the container element.
*/ */
export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement { export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement {
const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts; const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts;
@ -20,39 +20,70 @@ export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement
// Left side: file selector // Left side: file selector
const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' }); const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' });
const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' }); const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' });
// Always show Inbox first if it exists in the list // Always show Inbox first if it exists in the list
const inbox = taskFiles.find((f) => f.path === inboxPath); const inbox = taskFiles.find((f) => f.path === inboxPath);
const others = taskFiles.filter((f) => f.path !== inboxPath); const others = taskFiles.filter((f) => f.path !== inboxPath);
const ordered = inbox ? [inbox, ...others] : others; for (const file of (inbox ? [inbox, ...others] : others)) {
const opt = select.createEl('option', { text: file.basename, value: file.path });
for (const file of ordered) { if (file.path === currentFile.path) opt.selected = true;
const opt = select.createEl('option', {
text: file.basename,
value: file.path,
});
if (file.path === currentFile.path) {
opt.selected = true;
} }
}
select.addEventListener('change', () => { select.addEventListener('change', () => {
const chosen = taskFiles.find((f) => f.path === select.value); const chosen = taskFiles.find((f) => f.path === select.value);
if (chosen) onSwitchFile(chosen); if (chosen) onSwitchFile(chosen);
}); });
// Right side: new file button // Right side: "+ New list" button / inline create input (mutually exclusive)
const newBtn = bar.createEl('button', { const newBtn = bar.createEl('button', {
text: '+ New list', text: '+ New list',
cls: 'yaotp-switcher-new-btn', cls: 'yaotp-switcher-new-btn',
}); });
newBtn.addEventListener('click', () => {
const name = prompt('New task file name (without .md):'); const createGroup = bar.createDiv({ cls: 'yaotp-switcher-create-group' });
if (name && name.trim()) { createGroup.style.display = 'none';
onCreateFile(name.trim());
const nameInput = createGroup.createEl('input', {
type: 'text',
cls: 'yaotp-switcher-name-input',
attr: { placeholder: 'File name (without .md)' },
}) as HTMLInputElement;
const confirmBtn = createGroup.createEl('button', {
text: 'Create',
cls: 'mod-cta yaotp-switcher-confirm-btn',
});
const cancelBtn = createGroup.createEl('button', {
text: '✕',
cls: 'yaotp-switcher-cancel-btn',
});
const showInput = () => {
newBtn.style.display = 'none';
createGroup.style.display = 'flex';
nameInput.value = '';
setTimeout(() => nameInput.focus(), 0);
};
const hideInput = () => {
createGroup.style.display = 'none';
newBtn.style.display = '';
};
const submit = () => {
const name = nameInput.value.trim();
if (name) {
hideInput();
onCreateFile(name);
} }
};
newBtn.addEventListener('click', showInput);
confirmBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', hideInput);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') hideInput();
}); });
return bar; return bar;

View File

@ -9,6 +9,7 @@ export interface TaskEditorResult {
} }
type SaveCallback = (result: TaskEditorResult) => void; type SaveCallback = (result: TaskEditorResult) => void;
type DeleteCallback = () => void;
/** /**
* Modal for editing a task's text and notes together, and optionally * Modal for editing a task's text and notes together, and optionally
@ -19,6 +20,7 @@ export class TaskEditorModal extends Modal {
private taskFiles: TFile[]; private taskFiles: TFile[];
private currentFile: TFile; private currentFile: TFile;
private onSave: SaveCallback; private onSave: SaveCallback;
private onDelete: DeleteCallback;
private textarea: HTMLTextAreaElement | null = null; private textarea: HTMLTextAreaElement | null = null;
private selectedFile: TFile | null = null; private selectedFile: TFile | null = null;
private fileLabel: HTMLSpanElement | null = null; private fileLabel: HTMLSpanElement | null = null;
@ -28,13 +30,15 @@ export class TaskEditorModal extends Modal {
task: Task, task: Task,
taskFiles: TFile[], taskFiles: TFile[],
currentFile: TFile, currentFile: TFile,
onSave: SaveCallback onSave: SaveCallback,
onDelete: DeleteCallback
) { ) {
super(app); super(app);
this.task = task; this.task = task;
this.taskFiles = taskFiles; this.taskFiles = taskFiles;
this.currentFile = currentFile; this.currentFile = currentFile;
this.onSave = onSave; this.onSave = onSave;
this.onDelete = onDelete;
} }
onOpen(): void { onOpen(): void {
@ -44,8 +48,11 @@ export class TaskEditorModal extends Modal {
// Title // Title
contentEl.createEl('h2', { text: 'Edit task' }); contentEl.createEl('h2', { text: 'Edit task' });
// Textarea: first line = task text, rest = notes // Textarea: first line = task text, blank line, then notes
const initialValue = [this.task.text, ...this.task.notes].join('\n'); const initialValue =
this.task.notes.length > 0
? [this.task.text, '', ...this.task.notes].join('\n')
: this.task.text;
this.textarea = contentEl.createEl('textarea', { this.textarea = contentEl.createEl('textarea', {
cls: 'yaotp-editor-textarea', cls: 'yaotp-editor-textarea',
attr: { rows: '8', placeholder: 'Task title\n\nNotes…' }, attr: { rows: '8', placeholder: 'Task title\n\nNotes…' },
@ -81,6 +88,18 @@ export class TaskEditorModal extends Modal {
// Buttons // Buttons
const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' }); const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const deleteBtn = btnRow.createEl('button', {
text: 'Delete',
cls: 'yaotp-editor-delete-btn',
});
deleteBtn.addEventListener('click', () => {
new ConfirmModal(this.app, 'Delete this task? This cannot be undone.', () => {
this.close();
this.onDelete();
}).open();
});
const saveBtn = btnRow.createEl('button', { const saveBtn = btnRow.createEl('button', {
text: 'Save', text: 'Save',
cls: 'mod-cta yaotp-editor-save-btn', cls: 'mod-cta yaotp-editor-save-btn',
@ -100,7 +119,7 @@ export class TaskEditorModal extends Modal {
const lines = this.textarea.value.split('\n'); const lines = this.textarea.value.split('\n');
const text = lines[0] ?? ''; const text = lines[0] ?? '';
const notes = lines.slice(1); const notes = lines.slice(1);
// Trim leading blank lines from notes // Trim leading blank lines from notes (user may have removed or added the blank separator)
while (notes.length > 0 && notes[0].trim() === '') notes.shift(); while (notes.length > 0 && notes[0].trim() === '') notes.shift();
this.onSave({ this.onSave({
text, text,
@ -111,6 +130,37 @@ export class TaskEditorModal extends Modal {
} }
} }
class ConfirmModal extends Modal {
private message: string;
private onConfirm: () => void;
constructor(app: App, message: string, onConfirm: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('p', { text: this.message });
const row = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const confirmBtn = row.createEl('button', {
text: 'Delete',
cls: 'mod-warning yaotp-editor-delete-btn',
});
confirmBtn.addEventListener('click', () => {
this.onConfirm();
this.close();
});
const cancelBtn = row.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}
class TaskFilePicker extends FuzzySuggestModal<TFile> { class TaskFilePicker extends FuzzySuggestModal<TFile> {
private files: TFile[]; private files: TFile[];
private onSelect: (file: TFile) => void; private onSelect: (file: TFile) => void;

View File

@ -5,11 +5,13 @@ import { TaskListComponent } from './TaskListComponent';
import { buildFileSwitcherBar } from './FileSwitcherBar'; import { buildFileSwitcherBar } from './FileSwitcherBar';
import { TaskEditorModal } from './TaskEditorModal'; import { TaskEditorModal } from './TaskEditorModal';
import { VIEW_ICON, VIEW_TYPE } from '../constants'; import { VIEW_ICON, VIEW_TYPE } from '../constants';
import { allowMarkdownOnce } from '../file-intercept';
import type { Task } from '../types'; import type { Task } from '../types';
import type { YaotpSettings } from '../settings'; import type { YaotpSettings } from '../settings';
export class TaskFileView extends TextFileView { export class TaskFileView extends TextFileView {
private tasks: Task[] = []; private tasks: Task[] = [];
private fileHeader: string = '';
private taskList: TaskListComponent | null = null; private taskList: TaskListComponent | null = null;
private rootEl: HTMLElement | null = null; private rootEl: HTMLElement | null = null;
private listEl: HTMLElement | null = null; private listEl: HTMLElement | null = null;
@ -40,16 +42,19 @@ export class TaskFileView extends TextFileView {
// Called by Obsidian when the file content is loaded or changes externally. // Called by Obsidian when the file content is loaded or changes externally.
setViewData(data: string, _clear: boolean): void { setViewData(data: string, _clear: boolean): void {
this.tasks = parseTaskFile(data); const { header, tasks } = parseTaskFile(data);
this.fileHeader = header;
this.tasks = tasks;
this.renderView(); this.renderView();
} }
// Called by Obsidian when it needs to read back the file content. // Called by Obsidian when it needs to read back the file content.
getViewData(): string { getViewData(): string {
return serializeTaskFile(this.tasks); return serializeTaskFile({ header: this.fileHeader, tasks: this.tasks });
} }
clear(): void { clear(): void {
this.fileHeader = '';
this.tasks = []; this.tasks = [];
this.renderView(); this.renderView();
} }
@ -58,6 +63,24 @@ export class TaskFileView extends TextFileView {
async onOpen(): Promise<void> { async onOpen(): Promise<void> {
this.contentEl.addClass('yaotp-view'); this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl; this.rootEl = this.contentEl;
this.addAction('pencil', 'Edit source', async () => {
if (!this.file) return;
const filePath = this.file.path;
// First call: bypass the intercept to land in markdown (Obsidian may
// open in Live Preview regardless of the mode we pass here).
allowMarkdownOnce(this.leaf);
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath },
});
// Second call: now the leaf is markdown + same file, so the intercept
// treats this as a mode change and lets it through.
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath, mode: 'source' },
});
});
} }
async onClose(): Promise<void> { async onClose(): Promise<void> {
@ -100,7 +123,16 @@ export class TaskFileView extends TextFileView {
private async persistTasks(): Promise<void> { private async persistTasks(): Promise<void> {
if (!this.file) return; if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks)); await this.app.vault.modify(
this.file,
serializeTaskFile({ header: this.fileHeader, tasks: this.tasks })
);
}
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
} }
private async completeTask(index: number): Promise<void> { private async completeTask(index: number): Promise<void> {
@ -142,18 +174,17 @@ export class TaskFileView extends TextFileView {
await this.persistTasks(); await this.persistTasks();
} }
this.renderView();
},
async () => {
this.tasks.splice(index, 1);
await this.persistTasks();
this.renderView(); this.renderView();
} }
); );
modal.open(); modal.open();
} }
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
}
private reorderTask(oldIndex: number, newIndex: number): void { private reorderTask(oldIndex: number, newIndex: number): void {
const moved = this.tasks.splice(oldIndex, 1)[0]; const moved = this.tasks.splice(oldIndex, 1)[0];
this.tasks.splice(newIndex, 0, moved); this.tasks.splice(newIndex, 0, moved);
@ -163,9 +194,9 @@ export class TaskFileView extends TextFileView {
private async appendTaskToFile(task: Task, file: TFile): Promise<void> { private async appendTaskToFile(task: Task, file: TFile): Promise<void> {
const content = await this.app.vault.read(file); const content = await this.app.vault.read(file);
const existing = parseTaskFile(content); const data = parseTaskFile(content);
existing.push(task); data.tasks.push(task);
await this.app.vault.modify(file, serializeTaskFile(existing)); await this.app.vault.modify(file, serializeTaskFile(data));
} }
private openFile(file: TFile): void { private openFile(file: TFile): void {
@ -173,11 +204,8 @@ export class TaskFileView extends TextFileView {
} }
private async createAndOpenFile(name: string): Promise<void> { private async createAndOpenFile(name: string): Promise<void> {
// Derive folder from inboxPath const folder = this.settings.taskFileFolder;
const inboxFolder = this.settings.inboxPath.includes('/') const path = folder ? `${folder}/${name}.md` : `${name}.md`;
? this.settings.inboxPath.slice(0, this.settings.inboxPath.lastIndexOf('/'))
: '';
const path = inboxFolder ? `${inboxFolder}/${name}.md` : `${name}.md`;
let file = this.app.vault.getAbstractFileByPath(path) as TFile | null; let file = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!file) { if (!file) {

View File

@ -44,6 +44,36 @@
cursor: pointer; cursor: pointer;
} }
.yaotp-switcher-create-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.yaotp-switcher-name-input {
font-size: var(--font-ui-small);
padding: 3px 6px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-normal);
width: 160px;
}
.yaotp-switcher-name-input:focus {
outline: none;
border-color: var(--interactive-accent);
}
.yaotp-switcher-confirm-btn,
.yaotp-switcher-cancel-btn {
flex-shrink: 0;
font-size: var(--font-ui-small);
padding: 3px 8px;
cursor: pointer;
}
/* ── Task List ──────────────────────────────────────────────────────────── */ /* ── Task List ──────────────────────────────────────────────────────────── */
.yaotp-list-container { .yaotp-list-container {
@ -225,6 +255,12 @@
gap: 8px; gap: 8px;
} }
.yaotp-editor-delete-btn {
margin-right: auto;
color: var(--text-error);
}
/* Settings error state */ /* Settings error state */
.yaotp-setting-error { .yaotp-setting-error {
border-color: var(--color-red) !important; border-color: var(--color-red) !important;