feat: support auto complete parent tasks

This commit is contained in:
quorafind 2025-03-02 22:37:46 +08:00
parent 1c3df1fc12
commit 44c8de4915
9 changed files with 2584 additions and 802 deletions

View file

@ -1,6 +1,6 @@
# Obsidian Task Progress Bar
A plugin for showing task progress bar near the tasks bullet or headings. Only works in Live Preview mode in Obsidian.
A plugin for showing task progress bar near the tasks bullet or headings.
![example](./media/example.gif)
@ -22,9 +22,37 @@ When you finished the task2,
## Settings
1. Add progress bar to Heading: Make the Heading showing the task progress bars.
2. Add number to progress bar: You can see the total/completed number of tasks.
3. Set alternative marks: You can set which marks means the completed tasks.
### Basic Settings
1. **Add progress bar to Heading**: Make the Heading showing the task progress bars.
2. **Enable heading progress bars**: Add progress bars to headings to show progress of all tasks under that heading.
3. **Add number to progress bar**: You can see the total/completed number of tasks.
4. **Show percentage**: Display the completion percentage in the progress bar.
5. **Count sub children level of current Task**: Allow the plugin to count sub-tasks in the progress calculation.
### Task Status Settings
You can customize which characters represent different task statuses, or choose from predefined collections.
1. **Completed task markers**: Characters that represent completed tasks (default: `x|X`).
2. **Planned task markers**: Characters that represent planned tasks (default: `?`).
3. **In progress task markers**: Characters that represent tasks in progress (default: `>|/`).
4. **Abandoned task markers**: Characters that represent abandoned tasks (default: `-`).
5. **Not started task markers**: Characters that represent not started tasks (default: space ` `).
6. **Count other statuses as**: Choose which category to count other statuses as.
### Task Counting Settings
1. **Exclude specific task markers**: Specify task markers to exclude from counting (e.g., `?|/`).
2. **Only count specific task markers**: Toggle to only count specific task markers.
3. **Specific task markers to count**: If the above option is enabled, specify which task markers to count.
### Conditional Progress Bar Display
1. **Hide progress bars based on conditions**: Enable hiding progress bars based on tags, folders, or metadata.
2. **Hide by tags**: Specify tags that will hide progress bars (comma-separated, without #).
3. **Hide by folders**: Specify folder paths that will hide progress bars.
4. **Hide by metadata**: Specify frontmatter metadata that will hide progress bars.
## How to Install
@ -32,10 +60,6 @@ When you finished the task2,
💜: Directly install from Obsidian Market.
### From BRAT
🚗: Add `Quorafind/Obsidian-Task-Progress-Bar` to BRAT.
### Download Manually
🚚: Download the latest release. Extract and put the three files (main.js, manifest.json, styles.css) to

View file

@ -14,25 +14,23 @@
"devDependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "https://github.com/lishid/cm-language",
"@codemirror/rangeset": "^0.19.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/stream-parser": "https://github.com/lishid/stream-parser",
"codemirror": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0",
"builtin-modules": "^3.2.0",
"codemirror": "^6.0.0",
"esbuild": "0.13.12",
"obsidian": "latest",
"obsidian": "^1.8.7",
"regexp-match-indices": "^1.0.2",
"tslib": "2.4.0",
"typescript": "4.7.3",
"regexp-match-indices": "^1.0.2"
"typescript": "4.7.3"
}
}

File diff suppressed because it is too large Load diff

284
src/autoCompleteParent.ts Normal file
View file

@ -0,0 +1,284 @@
import { App, Editor } from "obsidian";
import {
EditorState,
Text,
Transaction,
TransactionSpec,
} from "@codemirror/state";
import { getTabSize } from "./utils";
/**
* Creates an editor extension that automatically completes parent tasks when all child tasks are completed
* @param app The Obsidian app instance
* @param plugin The plugin instance
* @returns An editor extension that can be registered with the plugin
*/
export function autoCompleteParentExtension(app: App) {
return EditorState.transactionFilter.of((tr) => {
return handleAutoCompleteParentTransaction(tr, app);
});
}
/**
* Handles transactions to detect task completion and process parent task updates
* @param tr The transaction to handle
* @param app The Obsidian app instance
* @param plugin The plugin instance
* @returns The original transaction or a modified transaction
*/
export function handleAutoCompleteParentTransaction(
tr: Transaction,
app: App
): TransactionSpec {
// Only process transactions that change the document and are user input events
if (!tr.docChanged) {
return tr;
}
// Check if a task was completed in this transaction
const taskCompletionInfo = findTaskCompletion(tr);
if (!taskCompletionInfo) {
return tr;
}
// Check if the completed task has a parent task
const { doc, lineNumber } = taskCompletionInfo;
const parentInfo = findParentTask(doc, lineNumber);
if (!parentInfo) {
return tr;
}
// Check if all siblings are completed
const allSiblingsCompleted = areAllSiblingsCompleted(
doc,
parentInfo.lineNumber,
parentInfo.indentationLevel,
app
);
// If all siblings are completed, update the parent task
if (allSiblingsCompleted) {
return completeParentTask(tr, parentInfo.lineNumber, doc);
}
return tr;
}
/**
* Finds a task completion event in the transaction
* @param tr The transaction to check
* @returns Information about the completed task or null if no task was completed
*/
function findTaskCompletion(tr: Transaction): {
doc: Text;
lineNumber: number;
} | null {
let taskCompletedLine: number | null = null;
// Check each change in the transaction
tr.changes.iterChanges(
(
fromA: number,
toA: number,
fromB: number,
toB: number,
inserted: Text
) => {
// If a change involves inserting an 'x' character
const insertedText = inserted.toString();
if (insertedText === "x" || insertedText === "X") {
// Get the position context
const pos = fromB;
const line = tr.newDoc.lineAt(pos);
const lineText = line.text;
console.log(lineText);
// Check if this is a task being completed ([ ] to [x])
// Matches the pattern where the cursor is between [ and ]
const taskRegex = /^[\s|\t]*([-*+]|\d+\.)\s\[[x|X]\]/i;
if (taskRegex.test(lineText)) {
taskCompletedLine = line.number;
}
}
}
);
if (taskCompletedLine === null) {
return null;
}
return {
doc: tr.newDoc,
lineNumber: taskCompletedLine,
};
}
/**
* Finds the parent task of a given task line
* @param doc The document to search in
* @param lineNumber The line number of the task
* @returns Information about the parent task or null if no parent was found
*/
function findParentTask(
doc: Text,
lineNumber: number
): {
lineNumber: number;
indentationLevel: number;
} | null {
// Get the current line and its indentation level
const currentLine = doc.line(lineNumber);
const currentLineText = currentLine.text;
const currentIndentMatch = currentLineText.match(/^[\s|\t]*/);
const currentIndentLevel = currentIndentMatch
? currentIndentMatch[0].length
: 0;
// If we're at the top level, there's no parent
if (currentIndentLevel === 0) {
return null;
}
// Look backwards for a line with less indentation that contains a task
for (let i = lineNumber - 1; i >= 1; i--) {
const line = doc.line(i);
const lineText = line.text;
// Skip empty lines
if (lineText.trim() === "") {
continue;
}
// Get the indentation level of this line
const indentMatch = lineText.match(/^[\s|\t]*/);
const indentLevel = indentMatch ? indentMatch[0].length : 0;
// If this line has less indentation than the current line
if (indentLevel < currentIndentLevel) {
// Check if it's a task
const taskRegex = /^[\s|\t]*([-*+]|\d+\.)\s\[([ |x|X])\]/i;
if (taskRegex.test(lineText)) {
return {
lineNumber: i,
indentationLevel: indentLevel,
};
}
// If it's not a task, it can't be a parent task
// If it's a heading or other structural element, we keep looking
if (!lineText.startsWith("#") && !lineText.startsWith(">")) {
break;
}
}
}
return null;
}
/**
* Checks if all sibling tasks at the same indentation level as the parent's children are completed
* @param doc The document to check
* @param parentLineNumber The line number of the parent task
* @param parentIndentLevel The indentation level of the parent task
* @returns True if all siblings are completed, false otherwise
*/
function areAllSiblingsCompleted(
doc: Text,
parentLineNumber: number,
parentIndentLevel: number,
app: App
): boolean {
const tabSize = getTabSize(app);
// The expected indentation level for child tasks
const childIndentLevel = parentIndentLevel + tabSize;
// Track if we found at least one child
let foundChild = false;
// Search forward from the parent line
for (let i = parentLineNumber + 1; i <= doc.lines; i++) {
const line = doc.line(i);
const lineText = line.text;
// Skip empty lines
if (lineText.trim() === "") {
continue;
}
// Get the indentation of this line
const indentMatch = lineText.match(/^[\s|\t]*/);
const indentLevel = indentMatch ? indentMatch[0].length : 0;
// If we encounter a line with less or equal indentation to the parent,
// we've moved out of the parent's children scope
if (indentLevel <= parentIndentLevel) {
break;
}
// If this is a direct child of the parent (exactly one level deeper)
if (indentLevel === childIndentLevel) {
// Create a regex to match tasks based on the indentation level
const taskRegex = new RegExp(
`^[\\s|\\t]{${childIndentLevel}}([-*+]|\\d+\\.)\\s\\[(.)\\]`
);
const taskMatch = lineText.match(taskRegex);
if (taskMatch) {
foundChild = true;
// If we find an incomplete task, return false
const taskStatus = taskMatch[2];
if (taskStatus !== "x" && taskStatus !== "X") {
return false;
}
}
}
}
// If we found at least one child and all were completed, return true
return foundChild;
}
/**
* Completes a parent task by modifying the transaction
* @param tr The transaction to modify
* @param parentLineNumber The line number of the parent task
* @param doc The document
* @returns The modified transaction
*/
function completeParentTask(
tr: Transaction,
parentLineNumber: number,
doc: Text
): TransactionSpec {
const parentLine = doc.line(parentLineNumber);
const parentLineText = parentLine.text;
// Find the task marker position
const taskMarkerMatch = parentLineText.match(
/^[\s|\t]*([-*+]|\d+\.)\s\[( )\]/
);
if (!taskMarkerMatch) {
return tr;
}
// Calculate the position where we need to insert 'x'
const markerStart =
parentLine.from +
taskMarkerMatch.index! +
taskMarkerMatch[0].indexOf("[ ]") +
1;
// Create a new transaction that adds the completion marker 'x' to the parent task
return {
changes: [
tr.changes,
{
from: markerStart,
to: markerStart + 1,
insert: "x",
},
],
selection: tr.selection,
};
}

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ import {
TaskProgressBarSettingTab,
} from "./taskProgressBarSetting";
import { EditorView } from "@codemirror/view";
import { autoCompleteParentExtension } from "./autoCompleteParent";
class TaskProgressBarPopover extends HoverPopover {
plugin: TaskProgressBarPlugin;
@ -103,7 +104,10 @@ export default class TaskProgressBarPlugin extends Plugin {
await this.loadSettings();
this.addSettingTab(new TaskProgressBarSettingTab(this.app, this));
this.registerEditorExtension(taskProgressBarExtension(this.app, this));
this.registerEditorExtension([
taskProgressBarExtension(this.app, this),
]);
this.registerMarkdownPostProcessor((el, ctx) => {
updateProgressBarInElement({
plugin: this,
@ -111,6 +115,14 @@ export default class TaskProgressBarPlugin extends Plugin {
ctx: ctx,
});
});
this.app.workspace.onLayoutReady(() => {
if (this.settings.autoCompleteParent) {
this.registerEditorExtension([
autoCompleteParentExtension(this.app),
]);
}
});
}
onunload() {}

View file

@ -4,8 +4,10 @@ import { allStatusCollections } from "./task-status";
export interface TaskProgressBarSettings {
addTaskProgressBarToHeading: boolean;
enableHeadingProgressBar: boolean;
addNumberToProgressBar: boolean;
showPercentage: boolean;
autoCompleteParent: boolean;
countSubLevel: boolean;
hideProgressBarBasedOnConditions: boolean;
hideProgressBarTags: string;
@ -31,7 +33,9 @@ export interface TaskProgressBarSettings {
export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
addTaskProgressBarToHeading: false,
enableHeadingProgressBar: false,
addNumberToProgressBar: false,
autoCompleteParent: false,
showPercentage: false,
countSubLevel: true,
hideProgressBarBasedOnConditions: false,
@ -95,6 +99,34 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName("Enable heading progress bars")
.setDesc(
"Add progress bars to headings to show progress of all tasks under that heading."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableHeadingProgressBar)
.onChange(async (value) => {
this.plugin.settings.enableHeadingProgressBar = value;
this.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName("Auto complete parent task")
.setDesc(
"Toggle this to allow this plugin to auto complete parent task when all child tasks are completed."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoCompleteParent)
.onChange(async (value) => {
this.plugin.settings.autoCompleteParent = value;
this.applySettingsUpdate();
})
);
this.showNumberToProgressbar();
new Setting(containerEl)
@ -240,6 +272,22 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName("Planned task markers")
.setDesc(
'Characters in square brackets that represent planned tasks. Example: "?"'
)
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.taskStatuses.planned)
.setValue(this.plugin.settings.taskStatuses.planned)
.onChange(async (value) => {
this.plugin.settings.taskStatuses.planned =
value || DEFAULT_SETTINGS.taskStatuses.planned;
this.applySettingsUpdate();
})
);
new Setting(containerEl)
.setName("In progress task markers")
.setDesc(

View file

@ -1,8 +1,12 @@
import { EditorView } from "@codemirror/view";
import TaskProgressBarPlugin from "./taskProgressBarIndex";
import { editorInfoField, MarkdownPostProcessorContext, TFile } from "obsidian";
import {
App,
editorInfoField,
MarkdownPostProcessorContext,
TFile,
} from "obsidian";
// Helper function to check if progress bars should be hidden
export function shouldHideProgressBarInPreview(
@ -14,7 +18,7 @@ export function shouldHideProgressBarInPreview(
}
const abstractFile = ctx.sourcePath
? plugin.app.vault.getAbstractFileByPath(ctx.sourcePath)
? plugin.app.vault.getFileByPath(ctx.sourcePath)
: null;
if (!abstractFile) {
return false;
@ -139,3 +143,21 @@ export function shouldHideProgressBarInLivePriview(
return false;
}
/**
* Get tab size from vault configuration
*/
export function getTabSize(app: App): number {
try {
const vaultConfig = app.vault as any;
const useTab =
vaultConfig.getConfig?.("useTab") === undefined ||
vaultConfig.getConfig?.("useTab") === true;
return useTab
? (vaultConfig.getConfig?.("tabSize") || 4) / 4
: vaultConfig.getConfig?.("tabSize") || 4;
} catch (e) {
console.error("Error getting tab size:", e);
return 4; // Default tab size
}
}

View file

@ -773,7 +773,6 @@ export function taskProgressBarExtension(
| "notStarted"
| "planned" {
const markMatch = text.match(/\[(.)]/);
console.log(markMatch, text);
if (!markMatch || !markMatch[1]) {
return "notStarted";
}
@ -997,17 +996,7 @@ export function taskProgressBarExtension(
tabSize
);
const bulletCompleteRegex = this.createCompletedTaskRegex(
plugin,
false,
level,
tabSize
);
const headingTotalRegex = this.createTotalTaskRegex(true);
const headingCompleteRegex = this.createCompletedTaskRegex(
plugin,
true
);
// Count tasks
for (let i = 0; i < textArray.length; i++) {
@ -1116,29 +1105,6 @@ export function taskProgressBarExtension(
}
}
// Log debug information
if (taskDebug.length > 0) {
console.log("Task counting debug:", {
taskDebug,
completedCount: completed,
inProgressCount: inProgress,
abandonedCount: abandoned,
notStartedCount: notStarted,
plannedCount: planned,
totalCount: total,
settings: {
useOnlyCountMarks:
plugin?.settings.useOnlyCountMarks,
onlyCountTaskMarks:
plugin?.settings.onlyCountTaskMarks,
excludeTaskMarks: plugin?.settings.excludeTaskMarks,
taskStatuses: plugin?.settings.taskStatuses,
countOtherStatusesAs:
plugin?.settings.countOtherStatusesAs,
},
});
}
// Ensure counts don't exceed total
completed = Math.min(completed, total);
inProgress = Math.min(inProgress, total - completed);