Tweaks & fixes
This commit is contained in:
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal 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.
|
||||||
@ -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)* |
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/main.ts
15
src/main.ts
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
36
styles.css
36
styles.css
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user