feat: support sort command for current file

This commit is contained in:
quorafind 2025-05-05 16:16:51 +08:00
parent 0f4667a13f
commit 7b35480be0
52 changed files with 1674 additions and 1130 deletions

View file

@ -29,8 +29,8 @@
"devDependencies": {
"@codemirror/language": "https://github.com/lishid/cm-language",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@datastructures-js/queue": "^4.2.3",
"@types/jest": "^29.5.0",
"@types/node": "^16.11.6",

View file

@ -16,7 +16,7 @@ importers:
version: 1.10.0
obsidian-daily-notes-interface:
specifier: ^0.9.4
version: 0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)
version: 0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.7)
devDependencies:
'@codemirror/language':
specifier: https://github.com/lishid/cm-language
@ -25,11 +25,11 @@ importers:
specifier: ^6.0.0
version: 6.5.10
'@codemirror/state':
specifier: ^6.0.0
specifier: ^6.5.2
version: 6.5.2
'@codemirror/view':
specifier: ^6.0.0
version: 6.36.5
specifier: ^6.36.7
version: 6.36.7
'@datastructures-js/queue':
specifier: ^4.2.3
version: 4.2.3
@ -71,7 +71,7 @@ importers:
version: 3.0.0
obsidian:
specifier: ^1.8.7
version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)
version: 1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.7)
regexp-match-indices:
specifier: ^1.0.2
version: 1.0.2
@ -277,8 +277,8 @@ packages:
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/view@6.36.5':
resolution: {integrity: sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==}
'@codemirror/view@6.36.7':
resolution: {integrity: sha512-kCWGW/chWGPgZqfZ36Um9Iz0X2IVpmCjg1P/qY6B6a2ecXtWRRAigmpJ6YgUQ5lTWXMyyVdfmpzhLZmsZQMbtg==}
'@datastructures-js/queue@4.2.3':
resolution: {integrity: sha512-GWVMorC/xi2V2ta+Z/CPgPGHL2ZJozcj48g7y2nIX5GIGZGRrbShSHgvMViJwHJurUzJYOdIdRZnWDRrROFwJA==}
@ -289,6 +289,12 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.7.0':
resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@ -2263,20 +2269,20 @@ snapshots:
dependencies:
'@codemirror/language': 6.10.8
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@lezer/common': 1.2.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.10.8
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@lezer/common': 1.2.3
'@codemirror/language@6.10.8':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
@ -2285,7 +2291,7 @@ snapshots:
'@codemirror/language@https://codeload.github.com/lishid/cm-language/tar.gz/6c1c5f5b677f6f6503d1ca2ec47f62f6406cda67':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
@ -2294,20 +2300,20 @@ snapshots:
'@codemirror/lint@6.8.5':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
crelt: 1.0.6
'@codemirror/search@6.5.10':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.36.5':
'@codemirror/view@6.36.7':
dependencies:
'@codemirror/state': 6.5.2
style-mod: 4.1.2
@ -2320,6 +2326,11 @@ snapshots:
eslint: 8.57.1
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)':
dependencies:
eslint: 8.57.1
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
'@eslint/eslintrc@2.1.4':
@ -2921,7 +2932,7 @@ snapshots:
'@codemirror/lint': 6.8.5
'@codemirror/search': 6.5.10
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
collect-v8-coverage@1.0.2: {}
@ -3155,7 +3166,7 @@ snapshots:
eslint@8.57.1:
dependencies:
'@eslint-community/eslint-utils': 4.6.1(eslint@8.57.1)
'@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1)
'@eslint-community/regexpp': 4.12.1
'@eslint/eslintrc': 2.1.4
'@eslint/js': 8.57.1
@ -3993,25 +4004,25 @@ snapshots:
nwsapi@2.2.20: {}
obsidian-daily-notes-interface@0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
obsidian-daily-notes-interface@0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.7):
dependencies:
obsidian: https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)
obsidian: https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.7)
tslib: 2.1.0
transitivePeerDependencies:
- '@codemirror/state'
- '@codemirror/view'
obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.7):
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@types/codemirror': 5.60.8
moment: 2.29.4
obsidian@https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
obsidian@https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.7):
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@codemirror/view': 6.36.7
'@types/codemirror': 5.60.8
moment: 2.29.4

View file

@ -1,180 +1,251 @@
// Mock for @codemirror/state
export class Text {
content: string;
constructor(content: string = '') {
this.content = content;
}
toString() {
return this.content;
}
get length() {
return this.content.length;
}
sliceString(from: number, to: number) {
return this.content.slice(from, to);
}
line(lineNum: number) {
const lines = this.content.split('\n');
if (lineNum < 1 || lineNum > lines.length) {
throw new Error(`Line ${lineNum} out of range`);
}
const line = lines[lineNum - 1];
let from = 0;
for (let i = 0; i < lineNum - 1; i++) {
from += lines[i].length + 1; // +1 for newline
}
return {
text: line,
from,
to: from + line.length,
number: lineNum
};
}
get lines() {
return this.content.split('\n').length;
}
lineAt(pos: number) {
let lineStart = 0;
let lineEnd = 0;
let lineNumber = 1;
const lines = this.content.split('\n');
for (const line of lines) {
lineEnd = lineStart + line.length;
if (pos >= lineStart && pos <= lineEnd) {
return {
text: line,
from: lineStart,
to: lineEnd,
number: lineNumber
};
}
lineStart = lineEnd + 1; // +1 for newline
lineNumber++;
}
// Default to last line if position is beyond content
return {
text: lines[lines.length - 1] || '',
from: lineStart - (lines[lines.length - 1]?.length || 0) - 1,
to: lineStart,
number: lines.length
};
}
content: string;
constructor(content: string = "") {
this.content = content;
}
toString() {
return this.content;
}
get length() {
return this.content.length;
}
sliceString(from: number, to: number) {
return this.content.slice(from, to);
}
line(lineNum: number) {
const lines = this.content.split("\n");
if (lineNum < 1 || lineNum > lines.length) {
throw new Error(`Line ${lineNum} out of range`);
}
const line = lines[lineNum - 1];
let from = 0;
for (let i = 0; i < lineNum - 1; i++) {
from += lines[i].length + 1; // +1 for newline
}
return {
text: line,
from,
to: from + line.length,
number: lineNum,
};
}
get lines() {
return this.content.split("\n").length;
}
lineAt(pos: number) {
let lineStart = 0;
let lineEnd = 0;
let lineNumber = 1;
const lines = this.content.split("\n");
for (const line of lines) {
lineEnd = lineStart + line.length;
if (pos >= lineStart && pos <= lineEnd) {
return {
text: line,
from: lineStart,
to: lineEnd,
number: lineNumber,
};
}
lineStart = lineEnd + 1; // +1 for newline
lineNumber++;
}
// Default to last line if position is beyond content
return {
text: lines[lines.length - 1] || "",
from: lineStart - (lines[lines.length - 1]?.length || 0) - 1,
to: lineStart,
number: lines.length,
};
}
}
export class Changes {
_changes: Array<{
fromA: number;
toA: number;
fromB: number;
toB: number;
inserted: Text;
}>;
constructor() {
this._changes = [];
}
get length() {
return this._changes.length;
}
iterChanges(f: (fromA: number, toA: number, fromB: number, toB: number, inserted: Text) => void) {
for (const change of this._changes) {
f(change.fromA, change.toA, change.fromB, change.toB, change.inserted);
}
}
_changes: Array<{
fromA: number;
toA: number;
fromB: number;
toB: number;
inserted: Text;
}>;
constructor() {
this._changes = [];
}
get length() {
return this._changes.length;
}
iterChanges(
f: (
fromA: number,
toA: number,
fromB: number,
toB: number,
inserted: Text
) => void
) {
for (const change of this._changes) {
f(
change.fromA,
change.toA,
change.fromB,
change.toB,
change.inserted
);
}
}
}
export class Transaction {
startState: EditorState;
newDoc: Text;
changes: Changes;
selection: any;
annotations: Map<Annotation<any>, any>;
constructor(options: {
startState?: EditorState,
newDoc?: Text,
changes?: Changes,
selection?: any,
annotations?: Map<Annotation<any>, any>
} = {}) {
this.startState = options.startState || new EditorState();
this.newDoc = options.newDoc || new Text();
this.changes = options.changes || new Changes();
this.selection = options.selection || null;
this.annotations = options.annotations || new Map();
}
annotation(annotation: Annotation<any>) {
return this.annotations.get(annotation);
}
get docChanged() {
return this.changes.length > 0;
}
isUserEvent(type: string) {
return false;
}
startState: EditorState;
newDoc: Text;
changes: Changes;
selection: any;
annotations: Map<Annotation<any>, any>;
constructor(
options: {
startState?: EditorState;
newDoc?: Text;
changes?: Changes;
selection?: any;
annotations?: Map<Annotation<any>, any>;
} = {}
) {
this.startState = options.startState || new EditorState();
this.newDoc = options.newDoc || new Text();
this.changes = options.changes || new Changes();
this.selection = options.selection || null;
this.annotations = options.annotations || new Map();
}
annotation(annotation: Annotation<any>) {
return this.annotations.get(annotation);
}
get docChanged() {
return this.changes.length > 0;
}
isUserEvent(type: string) {
return false;
}
}
export class EditorState {
doc: Text;
selection: any;
constructor(config: { doc?: Text, selection?: any } = {}) {
this.doc = config.doc || new Text();
this.selection = config.selection || null;
}
static create(config: any = {}) {
return new EditorState(config);
}
static transactionFilter = {
of: (f: (tr: Transaction) => TransactionSpec) => {
return {
filter: f
};
}
};
doc: Text;
selection: any;
_fields: Map<any, any>;
constructor(
config: { doc?: Text; selection?: any; extensions?: any[] } = {}
) {
this.doc = config.doc || new Text();
this.selection = config.selection || null;
this._fields = new Map();
if (config.extensions) {
config.extensions.forEach((ext) => {
if (ext && ext.hasOwnProperty("provides")) {
const fieldProvider = ext.provides;
if (
fieldProvider &&
fieldProvider.field &&
fieldProvider.create
) {
this._fields.set(
fieldProvider.field,
fieldProvider.create(this)
);
}
} else if (
ext &&
ext.hasOwnProperty("field") &&
ext.hasOwnProperty("create")
) {
this._fields.set(ext.field, ext.create(this));
}
});
}
}
field<T>(field: any /* StateField<T> | Facet<any, T> */): T | undefined {
return this._fields.get(field);
}
static create(config: any = {}) {
return new EditorState(config);
}
static transactionFilter = {
of: (f: (tr: Transaction) => TransactionSpec) => {
return {
filter: f,
};
},
};
}
export class Annotation<T> {
constructor(public name: string) {}
of(value: T) {
return value;
}
static define<T>() {
return new Annotation<T>('mock-annotation');
}
constructor(public name: string) {}
of(value: T) {
return value;
}
static define<T>() {
return new Annotation<T>("mock-annotation");
}
}
export interface TransactionSpec {
changes?: any;
selection?: any;
annotations?: any;
changes?: any;
selection?: any;
annotations?: any;
}
export const StateEffect = {
define: () => ({
of: (value: any) => ({ value })
})
};
define: () => ({
of: (value: any) => ({ value }),
}),
};
// Add a mock for EditorSelection
export const EditorSelection = {
single: jest.fn((anchor: number, head?: number) => {
// Return a mock SelectionRange or similar structure
// The specific structure depends on what properties your tests need
const resolvedHead = head ?? anchor;
return {
anchor: anchor,
head: resolvedHead,
from: Math.min(anchor, resolvedHead),
to: Math.max(anchor, resolvedHead),
empty: anchor === resolvedHead,
// You might need to add other properties based on actual usage:
// main: { anchor, head: resolvedHead }, // Mock main selection range
// ranges: [{ anchor, head: resolvedHead }], // Mock ranges array
// ... other methods or properties EditorSelection/SelectionRange might need
};
}),
// If your code also uses other static methods or properties of EditorSelection,
// such as EditorSelection.range(), add corresponding mocks here as well
// range: jest.fn((anchor: number, head: number) => { ... }),
};

View file

@ -35,6 +35,16 @@ export class App {
openFile: function () {},
};
},
getActiveFile: function () {
return {
path: "mockFile.md",
// Add other TFile properties if necessary for the tests
name: "mockFile.md",
basename: "mockFile",
extension: "md",
};
},
};
fileManager = {

View file

@ -1,13 +1,3 @@
import { App } from "obsidian";
import {
Text,
Transaction,
TransactionSpec,
EditorState,
ChangeSet,
Annotation,
} from "@codemirror/state";
import TaskProgressBarPlugin from ".."; // Adjust the import path as necessary
import {
handleParentTaskUpdateTransaction,
findTaskStatusChange,
@ -15,239 +5,19 @@ import {
areAllSiblingsCompleted,
anySiblingWithStatus,
getParentTaskStatus,
taskStatusChangeAnnotation, // Import the actual annotation
} from "../editor-ext/autoCompleteParent"; // Adjust the import path as necessary
import { TaskProgressBarSettings } from "src/common/setting-definition";
import { buildIndentString, getTabSize } from "../utils";
import { buildIndentString } from "../utils";
import {
createMockTransaction,
createMockApp,
createMockPlugin,
createMockText,
mockParentTaskStatusChangeAnnotation,
} from "./mockUtils";
// --- Mock Setup ---
// Mock Annotation Type
const mockAnnotationType = {
of: jest.fn().mockImplementation((value: string) => ({
type: mockAnnotationType,
value,
})),
};
// Use the actual annotation object from the source file for checks
const mockParentTaskStatusChangeAnnotation = taskStatusChangeAnnotation;
// Mock Text Object
const createMockText = (content: string): Text => {
const lines = content.split("\n");
const doc = {
toString: () => content,
length: content.length,
lines: lines.length,
line: jest.fn((lineNum: number) => {
if (lineNum < 1 || lineNum > lines.length) {
throw new Error(
`Line ${lineNum} out of range (1-${lines.length})`
);
}
const text = lines[lineNum - 1];
let from = 0;
for (let i = 0; i < lineNum - 1; i++) {
from += lines[i].length + 1; // +1 for newline
}
return {
text: text,
from,
to: from + text.length,
number: lineNum,
length: text.length,
};
}),
lineAt: jest.fn((pos: number) => {
if (pos < 0 || pos > content.length) {
throw new Error(
`Position ${pos} out of range (0-${content.length})`
);
}
let currentPos = 0;
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
const lineStart = currentPos;
const lineEnd = currentPos + lineLength;
if (pos >= lineStart && pos <= lineEnd) {
return {
text: lines[i],
from: lineStart,
to: lineEnd,
number: i + 1,
length: lineLength,
};
}
currentPos += lineLength + 1; // +1 for newline
}
// Handle edge case for position at the very end
if (pos === content.length && lines.length > 0) {
const lastLineIndex = lines.length - 1;
const lastLine = lines[lastLineIndex];
let from = 0;
for (let i = 0; i < lastLineIndex; i++) {
from += lines[i].length + 1;
}
return {
text: lastLine,
from: from,
to: from + lastLine.length,
number: lines.length,
length: lastLine.length,
};
}
throw new Error(`Could not find line at pos ${pos}`);
}),
// Add sliceString if needed by iterChanges simulation
sliceString: jest.fn((from: number, to: number) =>
content.slice(from, to)
),
};
// @ts-ignore - Add self-reference for Text methods if necessary
doc.doc = doc;
return doc as Text;
};
// Mock ChangeSet
const createMockChangeSet = (doc: Text, changes: any[] = []): ChangeSet => {
// Basic mock, might need refinement based on actual usage
return {
length: changes.length, // Length of the new document
// @ts-ignore
iterChanges: jest.fn((callback) => {
changes.forEach((change) => {
callback(
change.fromA,
change.toA,
change.fromB,
change.toB,
createMockText(change.insertedText || "") // inserted text needs to be a Text object
);
});
}),
// Add other ChangeSet methods if needed: mapDesc, compose, mapPos, etc.
} as ChangeSet;
};
// Mock Transaction Object
const createMockTransaction = (options: {
startStateDocContent?: string;
newDocContent?: string;
changes?: {
fromA: number;
toA: number;
fromB: number;
toB: number;
insertedText?: string;
}[];
docChanged?: boolean;
isUserEvent?: string | false; // e.g., 'input.paste' or false
annotations?: any[];
selection?: { anchor: number; head: number };
}): Transaction => {
const startDoc = createMockText(options.startStateDocContent ?? "");
const newDoc = createMockText(
options.newDocContent ?? options.startStateDocContent ?? ""
);
const changeSet = createMockChangeSet(newDoc, options.changes);
const mockTr = {
startState: { doc: startDoc /* other state props */ } as EditorState,
newDoc: newDoc,
changes: changeSet,
docChanged:
options.docChanged !== undefined
? options.docChanged
: !!options.changes?.length,
isUserEvent: jest.fn((type: string) => {
if (options.isUserEvent === false) return false;
return options.isUserEvent === type;
}),
annotation: jest.fn(<T>(type: Annotation<T>): T | undefined => {
const found = options.annotations?.find((ann) => ann.type === type);
return found ? found.value : undefined;
}),
selection: options.selection || { anchor: 0, head: 0 },
// Add other Transaction properties/methods if needed (effects, state, etc.)
};
// @ts-ignore - Allow adding mock state for testing purposes
mockTr.state = { doc: newDoc };
return mockTr as unknown as Transaction;
};
// Mock App Object
const createMockApp = (tabSize: number = 4): App => {
const app = new App();
// Override vault.getConfig if a specific tab size is provided
if (tabSize !== 4) {
// @ts-ignore - Override the getConfig function to return the specified tab size
app.vault.getConfig = jest.fn((key: string) => {
if (key === "tabSize") {
return tabSize;
}
return undefined;
});
}
return app;
};
// Mock Plugin Object
const createMockPlugin = (
settings: Partial<{
markParentInProgressWhenPartiallyComplete: boolean;
taskStatuses: { inProgress: string; completed: string };
workflow: {
enableWorkflow: boolean;
autoRemoveLastStageMarker: boolean;
taskCountAffectsParent: boolean;
autoAddTimestamp: boolean;
timestampFormat: string;
removeTimestampOnTransition: boolean;
calculateSpentTime: boolean;
spentTimeFormat: string;
definitions: any[];
autoAddNextTask: boolean;
calculateFullSpentTime: boolean;
};
}> = {}
): TaskProgressBarPlugin => {
const defaults = {
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: {
inProgress: "/",
completed: "x|X",
},
workflow: {
enableWorkflow: false,
autoRemoveLastStageMarker: true,
taskCountAffectsParent: true,
autoAddTimestamp: false,
timestampFormat: "YYYY-MM-DD HH:mm:ss",
removeTimestampOnTransition: false,
calculateSpentTime: false,
spentTimeFormat: "HH:mm",
definitions: [],
autoAddNextTask: false,
calculateFullSpentTime: false,
},
};
// Deep merge settings
const mergedSettings = {
...defaults,
...settings,
taskStatuses: { ...defaults.taskStatuses, ...settings.taskStatuses },
workflow: { ...defaults.workflow, ...settings.workflow },
};
// @ts-ignore
return {
settings: mergedSettings as unknown as TaskProgressBarSettings,
app: new App(), // Add app property with mock App instance
};
};
// --- Tests ---
@ -639,7 +409,13 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
it("should mark parent as in progress when a child is unchecked (if setting enabled)", () => {
const mockPlugin = createMockPlugin({
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: { inProgress: "/", completed: "x" },
taskStatuses: {
inProgress: "/",
completed: "x",
abandoned: "-",
planned: "?",
notStarted: " ",
},
});
const tr = createMockTransaction({
startStateDocContent: "- [x] Parent\n - [x] Child",
@ -690,7 +466,13 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
it("should mark parent as in progress when first child gets a status (if setting enabled)", () => {
const mockPlugin = createMockPlugin({
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: { inProgress: "/", completed: "x" },
taskStatuses: {
inProgress: "/",
completed: "x",
abandoned: "-",
planned: "?",
notStarted: " ",
},
});
const indent = buildIndentString(createMockApp());
const tr = createMockTransaction({
@ -742,7 +524,13 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
it("should NOT mark parent as in progress if parent already has a status", () => {
const mockPlugin = createMockPlugin({
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: { inProgress: "/", completed: "x" },
taskStatuses: {
inProgress: "/",
completed: "x",
abandoned: "-",
planned: "?",
notStarted: " ",
},
});
const tr = createMockTransaction({
startStateDocContent:
@ -797,7 +585,13 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
const indent = buildIndentString(createMockApp());
const mockPlugin = createMockPlugin({
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: { inProgress: "/", completed: "x" },
taskStatuses: {
inProgress: "/",
completed: "x",
abandoned: "-",
planned: "?",
notStarted: " ",
},
});
const tr = createMockTransaction({
startStateDocContent: "- [ ] Parent\n" + `${indent}- [ ] Child`,
@ -831,7 +625,13 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
it("should mark parent as in progress when one child is completed but others remain incomplete", () => {
const mockPlugin = createMockPlugin({
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: { inProgress: "/", completed: "x" },
taskStatuses: {
inProgress: "/",
completed: "x",
abandoned: "-",
planned: "?",
notStarted: " ",
},
});
const indent = buildIndentString(createMockApp());
const tr = createMockTransaction({
@ -981,14 +781,9 @@ describe("handleParentTaskUpdateTransaction (Integration)", () => {
mockPlugin
);
console.log((result as any).changes);
// The function should identify and prevent such accidental parent status changes
expect(result).toBe(tr);
expect(result.changes).toEqual(tr.changes);
// Verify no new changes were added to modify parent task status
// Use type assertion to access changes property in test mocks
expect((result as any).changes.length).toBe(1);
});
});

View file

@ -1,25 +1,14 @@
import { App, EditorSelection } from "obsidian";
import {
Text,
Transaction,
TransactionSpec,
EditorState,
ChangeSet,
Annotation,
ChangeSpec,
AnnotationType,
} from "@codemirror/state";
import TaskProgressBarPlugin from ".."; // Adjust the import path as necessary
createMockTransaction,
createMockApp,
createMockPlugin,
} from "./mockUtils";
import {
handleCycleCompleteStatusTransaction,
findTaskStatusChanges,
taskStatusChangeAnnotation, // Import the actual annotation
priorityChangeAnnotation, // Import priority annotation
} from "../editor-ext/cycleCompleteStatus"; // Adjust the import path as necessary
import {
TaskProgressBarSettings,
WorkflowSettings,
} from "../common/setting-definition";
import { buildIndentString } from "../utils";
// --- Mock Setup (Reusing mocks from autoCompleteParent.test.ts) ---
@ -31,319 +20,6 @@ const mockAnnotationType = {
value,
})),
};
// Use the actual annotation objects from the source file for checks
const mockTaskStatusAnnotation = taskStatusChangeAnnotation;
const mockPriorityAnnotation = priorityChangeAnnotation;
// Mock Text Object
const createMockText = (content: string): Text => {
const lines = content.split("\n");
const doc = {
toString: () => content,
length: content.length,
lines: lines.length,
line: jest.fn((lineNum: number) => {
if (lineNum < 1 || lineNum > lines.length) {
throw new Error(
`Line ${lineNum} out of range (1-${lines.length})`
);
}
const text = lines[lineNum - 1];
let from = 0;
for (let i = 0; i < lineNum - 1; i++) {
from += lines[i].length + 1; // +1 for newline
}
return {
text: text,
from,
to: from + text.length,
number: lineNum,
length: text.length,
};
}),
lineAt: jest.fn((pos: number) => {
// Ensure pos is within valid range
pos = Math.max(0, Math.min(pos, content.length));
let currentPos = 0;
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
const lineStart = currentPos;
const lineEnd = currentPos + lineLength;
// Check if pos is within the current line or at the very end of the document
if (pos >= lineStart && pos <= lineEnd) {
return {
text: lines[i],
from: lineStart,
to: lineEnd,
number: i + 1,
length: lineLength,
};
}
currentPos += lineLength + 1; // +1 for newline
}
// Handle edge case: position at the very end of the file after the last newline
if (
pos === content.length &&
lines.length > 0 &&
content.endsWith("\n")
) {
const lastLineIndex = lines.length - 1;
const lastLine = lines[lastLineIndex];
let from = content.length - lastLine.length - 1; // Position after the last newline
return {
text: lastLine,
from: from,
to: from + lastLine.length,
number: lines.length,
length: lastLine.length,
};
} else if (
pos === content.length &&
lines.length > 0 &&
!content.endsWith("\n")
) {
// Position exactly at the end of the last line (no trailing newline)
const lastLineIndex = lines.length - 1;
const lastLine = lines[lastLineIndex];
let from = 0;
for (let i = 0; i < lastLineIndex; i++) {
from += lines[i].length + 1;
}
return {
text: lastLine,
from: from,
to: from + lastLine.length,
number: lines.length,
length: lastLine.length,
};
}
// If the content is empty or pos is 0 in an empty doc
if (content === "" && pos === 0) {
return {
text: "",
from: 0,
to: 0,
number: 1,
length: 0,
};
}
throw new Error(
`Could not find line at pos ${pos} in content: "${content}"`
);
}),
sliceString: jest.fn((from: number, to: number) =>
content.slice(from, to)
),
};
// @ts-ignore - Add self-reference for Text methods if necessary
doc.doc = doc;
return doc as Text;
};
// Mock ChangeSet
const createMockChangeSet = (doc: Text, changes: any[] = []): ChangeSet => {
return {
length: doc.length,
// @ts-ignore
iterChanges: jest.fn(
(
callback: (
fromA: number,
toA: number,
fromB: number,
toB: number,
inserted: Text
) => void
) => {
changes.forEach((change) => {
// Basic validation to prevent errors on undefined values
const fromA = change.fromA ?? 0;
const toA = change.toA ?? fromA;
const fromB = change.fromB ?? 0;
const insertedText = change.insertedText ?? "";
const toB = change.toB ?? fromB + insertedText.length;
callback(
fromA,
toA,
fromB,
toB,
createMockText(insertedText) // inserted text needs to be a Text object
);
});
}
),
// Add other necessary ChangeSet methods if needed, even if mocked simply
// @ts-ignore
mapDesc: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
compose: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
mapPos: jest.fn(() => 0),
// @ts-ignore
toJSON: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
any: jest.fn(() => false),
// @ts-ignore
get desc() {
return {
/* mock */
};
},
// @ts-ignore
get empty() {
return changes.length === 0;
},
// ... and potentially others like 'apply', 'invert', etc. if used
} as unknown as ChangeSet;
};
// Mock Transaction Object
const createMockTransaction = (options: {
startStateDocContent?: string;
newDocContent?: string;
changes?: {
fromA: number;
toA: number;
fromB: number;
toB: number;
insertedText?: string;
}[];
docChanged?: boolean;
isUserEvent?: string | false; // e.g., 'input.paste' or false
annotations?: { type: AnnotationType<any>; value: any }[];
selection?: { anchor: number; head: number };
}): Transaction => {
const startDoc = createMockText(options.startStateDocContent ?? "");
const newDoc = createMockText(
options.newDocContent ?? options.startStateDocContent ?? ""
);
// Ensure changes array exists and is valid
const validChanges =
options.changes?.map((c) => ({
fromA: c.fromA ?? 0,
toA: c.toA ?? c.fromA ?? 0,
fromB: c.fromB ?? 0,
insertedText: c.insertedText ?? "",
toB: c.toB ?? (c.fromB ?? 0) + (c.insertedText ?? "").length,
})) || [];
const changeSet = createMockChangeSet(newDoc, validChanges);
// Create a proper EditorSelection object instead of just using an anchor/head object
const selectionObj = options.selection || { anchor: 0, head: 0 };
const editorSelection = {
ranges: [{ anchor: selectionObj.anchor, head: selectionObj.head }],
mainIndex: 0,
map: jest.fn(() => editorSelection),
eq: jest.fn(() => true),
main: { anchor: selectionObj.anchor, head: selectionObj.head },
extend: jest.fn(() => editorSelection),
update: jest.fn(() => editorSelection),
toJSON: jest.fn(() => ({})),
toString: jest.fn(() => ""),
filter: jest.fn(() => editorSelection),
} as unknown as EditorSelection;
const mockTr = {
startState: { doc: startDoc } as EditorState,
newDoc: newDoc,
changes: changeSet,
docChanged:
options.docChanged !== undefined
? options.docChanged
: !!validChanges.length,
isUserEvent: jest.fn((type: string) => {
if (options.isUserEvent === false) return false;
return options.isUserEvent === type;
}),
annotation: jest.fn(<T>(type: AnnotationType<T>): T | undefined => {
const found = options.annotations?.find((ann) => ann.type === type);
return found ? found.value : undefined;
}),
selection: editorSelection,
// Add required Transaction properties with basic mocks
effects: [],
scrollIntoView: false,
newSelection: editorSelection,
state: {
doc: newDoc,
selection: editorSelection,
// Add other required state properties with basic mocks
facet: jest.fn(() => null),
field: jest.fn(() => null),
fieldInvalidated: jest.fn(() => false),
toJSON: jest.fn(() => ({})),
replaceSelection: jest.fn(),
changeByRange: jest.fn(),
changes: jest.fn(),
toText: jest.fn(() => newDoc),
// @ts-ignore
values: [],
// @ts-ignore
apply: jest.fn(() => ({})),
// @ts-ignore
update: jest.fn(() => ({})),
// @ts-ignore
sliceDoc: jest.fn(() => ""),
} as unknown as EditorState,
reconfigured: false,
};
return mockTr as unknown as Transaction;
};
// Mock App Object (basic, only needs vault for getTasksAPI check potentially)
const createMockApp = (): App => {
const app = new App();
return app;
};
// Mock Plugin Object
const createMockPlugin = (
settings: Partial<{
taskStatusCycle: string[];
taskStatusMarks: Record<string, string>;
excludeMarksFromCycle: string[];
}> = {}
): TaskProgressBarPlugin => {
const defaults: Partial<TaskProgressBarSettings> = {
taskStatusCycle: ["TODO", "IN_PROGRESS", "DONE"],
taskStatusMarks: { TODO: " ", IN_PROGRESS: "/", DONE: "x" },
excludeMarksFromCycle: [],
// Add other relevant defaults needed for the tests
workflow: {
enableWorkflow: false,
autoRemoveLastStageMarker: true,
autoAddTimestamp: false,
timestampFormat: "YYYY-MM-DD HH:mm:ss",
removeTimestampOnTransition: false,
calculateSpentTime: false,
spentTimeFormat: "HH:mm",
definitions: [],
autoAddNextTask: false,
calculateFullSpentTime: false,
},
};
// Merge settings
const mergedSettings = {
...defaults,
...settings,
};
// Return the plugin with app property
return {
settings: mergedSettings as TaskProgressBarSettings,
app: new App(),
} as TaskProgressBarPlugin;
};
// --- Tests ---
describe("cycleCompleteStatus Helpers", () => {
describe("findTaskStatusChanges", () => {
@ -1227,7 +903,6 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
mockApp,
mockPlugin
);
console.log(result.changes);
expect(result.annotations).not.toBe("taskStatusChange");
expect(result).toBe(tr);
});

View file

@ -0,0 +1,148 @@
import {
findContinuousTaskBlocks,
SortableTask,
SortableTaskStatus,
} from "../commands/sortTaskCommands";
// 创建SortableTask的辅助函数
function createMockTask(
lineNumber: number,
indentation: number = 0,
children: SortableTask[] = [],
completed: boolean = false
): SortableTask {
const status = completed ? "x" : " ";
return {
id: `test-${lineNumber}`,
lineNumber,
indentation,
children,
parent: undefined,
calculatedStatus: completed
? SortableTaskStatus.Completed
: SortableTaskStatus.Incomplete,
originalMarkdown: `${" ".repeat(
indentation
)}- [${status}] Task at line ${lineNumber}`,
status,
completed,
content: `Task at line ${lineNumber}`,
tags: [],
} as SortableTask;
}
describe("findContinuousTaskBlocks", () => {
it("应该能够识别连续的任务块", () => {
// 创建模拟任务数据
const mockTasks: SortableTask[] = [
// 第一个任务块连续行号0,1,2
createMockTask(0),
createMockTask(1),
createMockTask(2),
// 第二个任务块连续行号5,6- 与第一个块之间有空行
createMockTask(5),
createMockTask(6),
];
// 执行函数
const blocks = findContinuousTaskBlocks(mockTasks);
// 验证结果
expect(blocks.length).toBe(2); // 应该识别出两个不连续的任务块
expect(blocks[0].length).toBe(3); // 第一个块有3个任务
expect(blocks[1].length).toBe(2); // 第二个块有2个任务
// 验证块中任务的行号
expect(blocks[0].map((t) => t.lineNumber)).toEqual([0, 1, 2]);
expect(blocks[1].map((t) => t.lineNumber)).toEqual([5, 6]);
});
it("应该正确处理带有子任务的任务块", () => {
// 创建子任务
const childTask1 = createMockTask(1, 2);
const childTask2 = createMockTask(2, 2);
// 创建带有子任务的模拟数据
const parentTask = createMockTask(0, 0, [childTask1, childTask2]);
childTask1.parent = parentTask;
childTask2.parent = parentTask;
const mockTasks: SortableTask[] = [
// 任务块1父任务包含子任务
parentTask,
// 任务块2独立任务
createMockTask(5),
];
// 执行函数
const blocks = findContinuousTaskBlocks(mockTasks);
// 验证结果
expect(blocks.length).toBe(2); // 应该识别出两个不连续的任务块
// 验证第一个块包含父任务
expect(blocks[0].length).toBe(1);
expect(blocks[0][0].lineNumber).toBe(0);
expect(blocks[0][0].children.length).toBe(2);
expect(blocks[0][0].children[0].lineNumber).toBe(1);
expect(blocks[0][0].children[1].lineNumber).toBe(2);
// 验证第二个块包含独立任务
expect(blocks[1].length).toBe(1);
expect(blocks[1][0].lineNumber).toBe(5);
});
it("应该将任务及其子任务视为一个连续块", () => {
// 创建一个带有子任务的任务,子任务在不连续的行
const child1 = createMockTask(2, 2);
const child2 = createMockTask(4, 2);
const parent1 = createMockTask(0, 0, [child1]);
child1.parent = parent1;
const parent2 = createMockTask(3, 0, [child2]);
child2.parent = parent2;
const mockTasks: SortableTask[] = [parent1, parent2];
// 执行函数 - 行号是 0, 2, 3, 4
const blocks = findContinuousTaskBlocks(mockTasks);
// 验证结果 - 应该是一个连续块,因为父任务的最大行号 + 1 >= 下一个任务的行号
expect(blocks.length).toBe(1); // 应该识别为一个连续块
expect(blocks[0].length).toBe(2); // 包含两个父任务
});
it("在没有任务的情况下应返回空数组", () => {
const emptyTasks: SortableTask[] = [];
const blocks = findContinuousTaskBlocks(emptyTasks);
expect(blocks).toEqual([]);
});
it("对于乱序输入的任务应正确排序并分组", () => {
// 创建乱序排列的任务
const mockTasks: SortableTask[] = [
createMockTask(5), // 第二个块
createMockTask(1), // 第一个块
createMockTask(6), // 第二个块
createMockTask(0), // 第一个块
createMockTask(2), // 第一个块
];
// 执行函数
const blocks = findContinuousTaskBlocks(mockTasks);
// 验证结果 - 应该先排序,然后分成两个块
expect(blocks.length).toBe(2);
// 第一个块0,1,2
expect(blocks[0].length).toBe(3);
expect(blocks[0].map((t) => t.lineNumber).sort()).toEqual([0, 1, 2]);
// 第二个块5,6
expect(blocks[1].length).toBe(2);
expect(blocks[1].map((t) => t.lineNumber).sort()).toEqual([5, 6]);
});
});

397
src/__tests__/mockUtils.ts Normal file
View file

@ -0,0 +1,397 @@
import { App } from "obsidian";
import {
Text,
Transaction,
TransactionSpec,
EditorState,
ChangeSet,
Annotation,
EditorSelection,
AnnotationType,
} from "@codemirror/state";
import TaskProgressBarPlugin from "../index"; // Adjust the import path as necessary
import {
taskStatusChangeAnnotation, // Import the actual annotation
} from "../editor-ext/autoCompleteParent"; // Adjust the import path as necessary
import { TaskProgressBarSettings } from "../common/setting-definition";
import { EditorView } from "@codemirror/view";
const mockAnnotationType = {
of: jest.fn().mockImplementation((value: string) => ({
type: mockAnnotationType,
value,
})),
};
// Use the actual annotation object from the source file for checks
const mockParentTaskStatusChangeAnnotation = taskStatusChangeAnnotation;
// Mock Text Object - Consolidated version
export const createMockText = (content: string): Text => {
const lines = content.split("\n");
const doc = {
toString: () => content,
length: content.length,
lines: lines.length,
line: jest.fn((lineNum: number) => {
if (lineNum < 1 || lineNum > lines.length) {
throw new Error(
`Line ${lineNum} out of range (1-${lines.length})`
);
}
const text = lines[lineNum - 1];
let from = 0;
for (let i = 0; i < lineNum - 1; i++) {
from += lines[i].length + 1; // +1 for newline
}
return {
text: text,
from,
to: from + text.length,
number: lineNum,
length: text.length,
};
}),
lineAt: jest.fn((pos: number) => {
// Ensure pos is within valid range
pos = Math.max(0, Math.min(pos, content.length));
let currentPos = 0;
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length;
const lineStart = currentPos;
const lineEnd = currentPos + lineLength;
// Check if pos is within the current line or at the very end of the document
if (pos >= lineStart && pos <= lineEnd) {
return {
text: lines[i],
from: lineStart,
to: lineEnd,
number: i + 1,
length: lineLength,
};
}
currentPos += lineLength + 1; // +1 for newline
}
// Handle edge case: position at the very end of the file after the last newline
if (
pos === content.length &&
lines.length > 0 &&
content.endsWith("\n")
) {
const lastLineIndex = lines.length - 1;
const lastLine = lines[lastLineIndex];
let from = content.length - lastLine.length - 1; // Position after the last newline
return {
text: lastLine,
from: from,
to: from + lastLine.length,
number: lines.length,
length: lastLine.length,
};
} else if (
pos === content.length &&
lines.length > 0 &&
!content.endsWith("\n")
) {
// Position exactly at the end of the last line (no trailing newline)
const lastLineIndex = lines.length - 1;
const lastLine = lines[lastLineIndex];
let from = 0;
for (let i = 0; i < lastLineIndex; i++) {
from += lines[i].length + 1;
}
return {
text: lastLine,
from: from,
to: from + lastLine.length,
number: lines.length,
length: lastLine.length,
};
}
// If the content is empty or pos is 0 in an empty doc
if (content === "" && pos === 0) {
return {
text: "",
from: 0,
to: 0,
number: 1,
length: 0,
};
}
throw new Error(
`Could not find line at pos ${pos} in content: "${content}"`
);
}),
sliceString: jest.fn((from: number, to: number) =>
content.slice(from, to)
),
};
// @ts-ignore - Add self-reference for Text methods if necessary
doc.doc = doc;
return doc as Text;
};
// Mock ChangeSet - Consolidated version
const createMockChangeSet = (doc: Text, changes: any[] = []): ChangeSet => {
return {
length: doc.length,
// @ts-ignore
iterChanges: jest.fn(
(
callback: (
fromA: number,
toA: number,
fromB: number,
toB: number,
inserted: Text
) => void
) => {
changes.forEach((change) => {
// Basic validation to prevent errors on undefined values
const fromA = change.fromA ?? 0;
const toA = change.toA ?? fromA;
const fromB = change.fromB ?? 0;
const insertedText = change.insertedText ?? "";
const toB = change.toB ?? fromB + insertedText.length;
callback(
fromA,
toA,
fromB,
toB,
createMockText(insertedText) // inserted text needs to be a Text object
);
});
}
),
// Add other necessary ChangeSet methods if needed, even if mocked simply
// @ts-ignore
mapDesc: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
compose: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
mapPos: jest.fn(() => 0),
// @ts-ignore
toJSON: jest.fn(() => ({
/* mock */
})),
// @ts-ignore
any: jest.fn(() => false),
// @ts-ignore
get desc() {
return {
/* mock */
};
},
// @ts-ignore
get empty() {
return changes.length === 0;
},
// ... and potentially others like 'apply', 'invert', etc. if used
} as unknown as ChangeSet;
};
// Mock Transaction Object - Consolidated version
const createMockTransaction = (options: {
startStateDocContent?: string;
newDocContent?: string;
changes?: {
fromA: number;
toA: number;
fromB: number;
toB: number;
insertedText?: string;
}[];
docChanged?: boolean;
isUserEvent?: string | false; // e.g., 'input.paste' or false
annotations?: { type: AnnotationType<any>; value: any }[]; // Use Annotation instead of AnnotationType
selection?: { anchor: number; head: number };
}): Transaction => {
const startDoc = createMockText(options.startStateDocContent ?? "");
const newDoc = createMockText(
options.newDocContent ?? options.startStateDocContent ?? ""
);
// Ensure changes array exists and is valid
const validChanges =
options.changes?.map((c) => ({
fromA: c.fromA ?? 0,
toA: c.toA ?? c.fromA ?? 0,
fromB: c.fromB ?? 0,
insertedText: c.insertedText ?? "",
toB: c.toB ?? (c.fromB ?? 0) + (c.insertedText ?? "").length,
})) || [];
const changeSet = createMockChangeSet(newDoc, validChanges);
// Create a proper EditorSelection object instead of just using an anchor/head object
const selectionObj = options.selection || { anchor: 0, head: 0 };
const editorSelection = EditorSelection.single(
selectionObj.anchor,
selectionObj.head
); // Use EditorSelection.single for proper creation
const mockTr = {
newDoc: newDoc,
changes: changeSet,
docChanged:
options.docChanged !== undefined
? options.docChanged
: !!validChanges.length,
isUserEvent: jest.fn((type: string) => {
if (options.isUserEvent === false) return false;
return options.isUserEvent === type;
}),
annotation: jest.fn(<T>(type: AnnotationType<T>): T | undefined => {
const found = options.annotations?.find((ann) => ann.type === type);
return found ? found.value : undefined;
}),
selection: editorSelection,
// Add required Transaction properties with basic mocks
effects: [],
scrollIntoView: false,
newSelection: editorSelection,
state: {
doc: newDoc,
selection: editorSelection,
// Add other required state properties with basic mocks
facet: jest.fn(() => null),
field: jest.fn(() => null),
fieldInvalidated: jest.fn(() => false),
toJSON: jest.fn(() => ({})),
replaceSelection: jest.fn(),
changeByRange: jest.fn(),
changes: jest.fn(),
toText: jest.fn(() => newDoc),
// @ts-ignore
values: [],
// @ts-ignore
apply: jest.fn(() => ({})),
// @ts-ignore
update: jest.fn(() => ({})),
// @ts-ignore
sliceDoc: jest.fn(() => ""),
} as unknown as EditorState,
startState: EditorState.create({ doc: startDoc }),
reconfigured: false,
};
return mockTr as unknown as Transaction;
};
// Mock App Object - Consolidated version
const createMockApp = (): App => {
const app = new App();
// Add any other necessary App mocks here if needed for tests
return app;
};
// Mock Plugin Object - Consolidated version with merged settings
const createMockPlugin = (
settings: Partial<TaskProgressBarSettings> = {} // Use TaskProgressBarSettings directly
): TaskProgressBarPlugin => {
const defaults: Partial<TaskProgressBarSettings> = {
// Default settings from both original versions combined
markParentInProgressWhenPartiallyComplete: true,
taskStatuses: {
inProgress: "/",
completed: "x|X",
abandoned: "-",
planned: "?",
notStarted: " ",
},
taskStatusCycle: ["TODO", "IN_PROGRESS", "DONE"],
taskStatusMarks: { TODO: " ", IN_PROGRESS: "/", DONE: "x" },
excludeMarksFromCycle: [],
workflow: {
enableWorkflow: false,
autoRemoveLastStageMarker: true,
autoAddTimestamp: false,
timestampFormat: "YYYY-MM-DD HH:mm:ss",
removeTimestampOnTransition: false,
calculateSpentTime: false,
spentTimeFormat: "HH:mm",
definitions: [],
autoAddNextTask: false,
calculateFullSpentTime: false,
},
};
// Deep merge provided settings with defaults
// Basic deep merge - might need a library for complex nested objects if issues arise
const mergedSettings = {
...defaults,
...settings,
taskStatuses: { ...defaults.taskStatuses, ...settings.taskStatuses },
taskStatusMarks: {
...defaults.taskStatusMarks,
...settings.taskStatusMarks,
},
workflow: { ...defaults.workflow, ...settings.workflow },
};
// Return the plugin with app property
return {
settings: mergedSettings as TaskProgressBarSettings,
app: createMockApp(), // Use the consolidated mock app
} as TaskProgressBarPlugin;
};
// Mock EditorView Object
const createMockEditorView = (docContent: string): EditorView => {
const doc = createMockText(docContent);
const mockState = {
doc: doc,
// Add other minimal required EditorState properties/methods if needed by the tests
// For sortTasks, primarily 'doc' is accessed via view.state.doc
facet: jest.fn(() => []),
field: jest.fn(() => undefined),
fieldInvalidated: jest.fn(() => false),
toJSON: jest.fn(() => ({})),
replaceSelection: jest.fn(),
changeByRange: jest.fn(),
changes: jest.fn(() => ({
/* mock ChangeSet */
})),
toText: jest.fn(() => doc),
sliceDoc: jest.fn((from = 0, to = doc.length) =>
doc.sliceString(from, to)
),
// @ts-ignore
values: [],
// @ts-ignore
apply: jest.fn((tr: any) => mockState), // Return the same state for simplicity
// @ts-ignore
update: jest.fn((spec: any) => ({
state: mockState,
transactions: [],
})), // Basic update mock
// @ts-ignore
selection: {
ranges: [{ from: 0, to: 0 }],
mainIndex: 0,
main: { from: 0, to: 0 },
}, // Minimal selection mock
} as unknown as EditorState;
const mockView = {
state: mockState,
dispatch: jest.fn(), // Mock dispatch function
// Add other EditorView properties/methods if needed by tests
// For example, if viewport information is accessed
// viewport: { from: 0, to: doc.length },
// contentDOM: document.createElement('div'), // Basic DOM element mock
} as unknown as EditorView;
return mockView;
};
export {
// createMockText is already exported inline
createMockChangeSet, // Export the consolidated function
createMockTransaction, // Export the consolidated function
createMockApp, // Export the consolidated function
createMockPlugin, // Export the consolidated function
mockParentTaskStatusChangeAnnotation,
createMockEditorView, // Export the new function
};

View file

@ -0,0 +1,306 @@
import {
sortTasksInDocument,
SortCriterion,
} from "../commands/sortTaskCommands";
import {
createMockText,
createMockPlugin,
createMockEditorView,
} from "./mockUtils";
describe("sortTasksInDocument", () => {
// Reset Notice mock after each test
afterEach(() => {
jest.clearAllMocks();
});
it("should identify and sort tasks", () => {
// Original content: mixed task order
const originalContent = `
- [ ] Incomplete task 1
- [x] Completed task
- [/] In progress task`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin();
// Sort criteria: by status (incomplete -> in progress -> completed)
const sortCriteria: SortCriterion[] = [
{ field: "status", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: text sorted by status
const expectedContent = `
- [ ] Incomplete task 1
- [/] In progress task
- [x] Completed task`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should place completed tasks at the end regardless of sort criteria", () => {
// Original content: mixed task order
const originalContent = `
- [x] Completed task 1
- [ ] Incomplete task [priority:: high] [due:: 2025-05-01]
- [/] In progress task [start:: 2025-04-01]
- [x] Completed task 2`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin();
// Sort criteria: by priority (which would normally put tasks with priority first)
const sortCriteria: SortCriterion[] = [
{ field: "priority", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: incomplete tasks sorted by priority, but completed tasks always at the end
const expectedContent = `
- [ ] Incomplete task [priority:: high] [due:: 2025-05-01]
- [/] In progress task [start:: 2025-04-01]
- [x] Completed task 1
- [x] Completed task 2`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should maintain relative position of non-contiguous task blocks", () => {
// Original content: two task blocks separated by non-task lines
const originalContent = `
First task block:
- [x] Completed task 1
- [ ] Incomplete task 1
Middle non-task content
Second task block:
- [x] Completed task 2
- [ ] Incomplete task 2`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin();
// Sort criteria: by status
const sortCriteria: SortCriterion[] = [
{ field: "status", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: each block sorted internally, but blocks maintain relative position
const expectedContent = `
First task block:
- [ ] Incomplete task 1
- [x] Completed task 1
Middle non-task content
Second task block:
- [ ] Incomplete task 2
- [x] Completed task 2`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should preserve task hierarchy (parent-child relationships)", () => {
// Original content: tasks with parent-child relationships
const originalContent = `
- [x] Parent task 1
- [ ] Child task 1
- [/] Child task 2
- [ ] Parent task 2
- [x] Child task 3`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin();
// Sort criteria: by status
const sortCriteria: SortCriterion[] = [
{ field: "status", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: parent tasks sorted, child tasks follow their respective parents
const expectedContent = `
- [ ] Parent task 2
- [x] Child task 3
- [x] Parent task 1
- [ ] Child task 1
- [/] Child task 2`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should sort tasks by multiple criteria", () => {
// Original content: tasks with various metadata
const originalContent = `
- [ ] Low priority [priority:: low] [due:: 2025-05-01]
- [ ] High priority [priority:: high]
- [ ] Medium priority with due date [priority:: medium] [due:: 2025-04-01]
- [ ] Medium priority with later due date [priority:: medium] [due:: 2025-06-01]`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin({
preferMetadataFormat: "dataview",
});
// Sort criteria: first by priority, then by due date
const sortCriteria: SortCriterion[] = [
{ field: "priority", order: "asc" },
{ field: "dueDate", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: sorted first by priority (high->medium->low), then by due date (early->late)
const expectedContent = `
- [ ] High priority [priority:: high]
- [ ] Medium priority with due date [priority:: medium] [due:: 2025-04-01]
- [ ] Medium priority with later due date [priority:: medium] [due:: 2025-06-01]
- [ ] Low priority [priority:: low] [due:: 2025-05-01]`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should return null when there are no tasks to sort", () => {
// Original content: no tasks
const originalContent = `
This is a document with no tasks
Just regular text content`;
// Create mock EditorView and plugin
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin();
// Sort criteria
const sortCriteria: SortCriterion[] = [
{ field: "status", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Verify result is null
expect(result).toBeNull();
});
it("should correctly sort tasks with dataview inline fields", () => {
// Original content: tasks with dataview inline fields
const originalContent = `
- [ ] Task B [priority:: low]
- [ ] Task A [priority:: high]
- [x] Completed Task C`;
// Create mock EditorView and plugin with dataview enabled
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin({
preferMetadataFormat: "dataview",
});
// Sort criteria: by priority
const sortCriteria: SortCriterion[] = [
{ field: "priority", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: sorted by priority, completed task at the end
const expectedContent = `
- [ ] Task A [priority:: high]
- [ ] Task B [priority:: low]
- [x] Completed Task C`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
it("should correctly sort tasks with Tasks plugin emojis", () => {
// Original content: tasks with Tasks plugin emojis
const originalContent = `
- [ ] Task C 📅 2025-01-03
- [ ] Task A 📅 2025-01-01
- [x] Completed Task B 📅 2025-01-02`;
// Create mock EditorView and plugin with tasks plugin enabled
const mockView = createMockEditorView(originalContent);
const mockPlugin = createMockPlugin({ preferMetadataFormat: "tasks" });
// Sort criteria: by due date
const sortCriteria: SortCriterion[] = [
{ field: "dueDate", order: "asc" },
];
// Call sort function
const result = sortTasksInDocument(
mockView,
mockPlugin,
sortCriteria,
true
);
// Expected result: sorted by due date, completed task at the end
const expectedContent = `
- [ ] Task A 📅 2025-01-01
- [ ] Task C 📅 2025-01-03
- [x] Completed Task B 📅 2025-01-02`;
// Verify sort result
expect(result).toEqual(expectedContent);
});
});

View file

@ -11,7 +11,7 @@ import {
MarkdownFileInfo,
moment,
} from "obsidian";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { buildIndentString, getTabSize } from "../utils";
import { t } from "../translations/helper";

View file

@ -1,21 +1,12 @@
import { ChangeSpec } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { Task as IndexerTask } from "../utils/types/TaskIndex"; // Import Task type from worker definitions
import { parseLocalDate } from "../utils/dateUtil";
import {
TASK_REGEX,
EMOJI_PRIORITY_REGEX,
EMOJI_DUE_DATE_REGEX,
DV_PRIORITY_REGEX,
DV_DUE_DATE_REGEX,
} from "../common/regex-define";
import { PRIORITY_MAP } from "../common/default-symbol";
import TaskProgressBarPlugin from ".."; // Import the plugin class
import { TaskProgressBarSettings } from "../common/setting-definition"; // Import settings type
import { TFile, App, MetadataCache, Notice } from "obsidian"; // Added Notice
import { Notice } from "obsidian";
import { parseTaskLine, MetadataFormat } from "../utils/taskUtil";
import { Task as IndexerTask } from "../utils/types/TaskIndex";
import TaskProgressBarPlugin from "../index";
import { TaskProgressBarSettings } from "../common/setting-definition";
// Task statuses (aligned with common usage and sorting needs)
enum SortableTaskStatus {
export enum SortableTaskStatus {
Overdue = "overdue", // Calculated, not a raw status
DueSoon = "due_soon", // Calculated, not a raw status - Placeholder
InProgress = "/",
@ -30,14 +21,18 @@ enum SortableTaskStatus {
// Interface for tasks used within the sorting command, closely matching IndexerTask
// We add calculated fields needed for sorting
interface SortableTask extends Omit<IndexerTask, "id" | "children" | "parent"> {
id: string; // Keep ID for potential mapping/diffing
lineNumber: number; // Original line number (0-based)
indentation: number; // Calculated indentation level
children: SortableTask[]; // Children of the same type
parent?: SortableTask; // Parent reference
calculatedStatus: SortableTaskStatus | string; // Status used for sorting (can include 'overdue')
// Inherited from IndexerTask:
export interface SortableTask
extends Omit<
IndexerTask,
"id" | "children" | "parent" | "filePath" | "line"
> {
id: string; // Use generated ID like line-${lineNumber} or keep parsed one? Let's keep parsed one.
lineNumber: number; // 0-based, relative to document start
indentation: number;
children: SortableTask[];
parent?: SortableTask;
calculatedStatus: SortableTaskStatus | string; // Status used for sorting
// Fields mapped from parsed Task
originalMarkdown: string;
status: string;
completed: boolean;
@ -46,7 +41,14 @@ interface SortableTask extends Omit<IndexerTask, "id" | "children" | "parent"> {
dueDate?: number;
startDate?: number;
scheduledDate?: number;
// Add other inherited fields if needed by sorting or parsing
tags: string[]; // Changed from tags? to tags to match base Task interface
// Add any other fields from IndexerTask if needed by sorting criteria
// createdDate?: number;
// completedDate?: number;
// recurrence?: string;
// project?: string;
// context?: string;
// tags?: string[]; // Keep tags if needed for sorting/filtering later? Not currently used.
}
// Sorting criteria
@ -69,122 +71,86 @@ function getIndentationLevel(line: string): number {
return match[1].length;
}
// Modified: Parses tasks from a text block with a line offset
// --- Refactored Task Parsing using taskUtil ---
function parseTasksForSorting(
blockText: string,
lineOffset: number = 0
lineOffset: number = 0,
filePath: string, // Added filePath
format: MetadataFormat // Added format
): SortableTask[] {
const lines = blockText.split("\n");
const tasks: SortableTask[] = [];
// taskMap uses the absolute line number as key
const taskMap: { [lineNumber: number]: SortableTask } = {};
let currentParentStack: SortableTask[] = [];
lines.forEach((line, index) => {
const match = line.match(TASK_REGEX);
if (match) {
const indentationStr = match[1];
const status = match[3];
let contentWithMetadata = match[4];
const lineNumber = lineOffset + index; // Calculate absolute line number
const lineNumber = lineOffset + index; // Calculate absolute line number (0-based)
// Use the robust parser from taskUtil
// Note: parseTaskLine expects 1-based line number for ID generation, pass lineNumber + 1
const parsedTask = parseTaskLine(
filePath,
line,
lineNumber + 1,
format
); // Pass 1-based line number
if (parsedTask) {
// We have a valid task line, now map it to SortableTask
const indentation = getIndentationLevel(line);
const newTask: Partial<SortableTask> = {
id: `line-${lineNumber}`,
lineNumber: lineNumber,
originalMarkdown: line, // Store original line from the block
status: status,
completed: status.toLowerCase() === "x",
indentation: indentation,
children: [],
};
// --- Metadata Extraction (Simplified) ---
let remainingContent = contentWithMetadata;
let priorityValue: number | undefined = undefined;
let priorityMatch = remainingContent.match(DV_PRIORITY_REGEX);
if (priorityMatch?.[1]) {
const prioStr = priorityMatch[1].trim().toLowerCase();
priorityValue = PRIORITY_MAP[prioStr] ?? parseInt(prioStr, 10);
if (!isNaN(priorityValue)) {
remainingContent = remainingContent.replace(
priorityMatch[0],
""
);
} else {
priorityValue = undefined;
}
}
if (priorityValue === undefined) {
priorityMatch = remainingContent.match(EMOJI_PRIORITY_REGEX);
if (priorityMatch?.[1]) {
priorityValue = PRIORITY_MAP[priorityMatch[1]];
if (priorityValue !== undefined) {
remainingContent = remainingContent.replace(
priorityMatch[0],
""
);
}
}
}
newTask.priority = priorityValue;
let dueDateTimestamp: number | undefined = undefined;
let dateMatch = remainingContent.match(DV_DUE_DATE_REGEX);
if (dateMatch?.[1]) {
dueDateTimestamp = parseLocalDate(dateMatch[1]);
if (dueDateTimestamp !== undefined) {
remainingContent = remainingContent.replace(
dateMatch[0],
""
);
}
}
if (dueDateTimestamp === undefined) {
dateMatch = remainingContent.match(EMOJI_DUE_DATE_REGEX);
if (dateMatch?.[1]) {
dueDateTimestamp = parseLocalDate(dateMatch[1]);
if (dueDateTimestamp !== undefined) {
remainingContent = remainingContent.replace(
dateMatch[0],
""
);
}
}
}
newTask.dueDate = dueDateTimestamp;
// --- Calculated Status ---
let calculatedStatus: SortableTaskStatus | string = status;
// --- Calculate Sortable Status ---
let calculatedStatus: SortableTaskStatus | string =
parsedTask.status;
const now = new Date();
now.setHours(0, 0, 0, 0);
const todayTimestamp = now.getTime();
if (
!newTask.completed &&
status !== SortableTaskStatus.Cancelled && // Use enum value
newTask.dueDate &&
newTask.dueDate < todayTimestamp
!parsedTask.completed &&
parsedTask.status !== SortableTaskStatus.Cancelled && // Compare against enum
parsedTask.dueDate &&
parsedTask.dueDate < todayTimestamp
) {
calculatedStatus = SortableTaskStatus.Overdue; // Use enum value
calculatedStatus = SortableTaskStatus.Overdue; // Use enum
} else {
// Ensure the original status maps to the enum if possible
calculatedStatus = Object.values(SortableTaskStatus).includes(
status as SortableTaskStatus
parsedTask.status as SortableTaskStatus
)
? (status as SortableTaskStatus)
: status;
? (parsedTask.status as SortableTaskStatus)
: parsedTask.status;
}
newTask.calculatedStatus = calculatedStatus;
newTask.content = remainingContent.replace(/\s{2,}/g, " ").trim();
// --- Create SortableTask ---
const sortableTask: SortableTask = {
// Map fields from parsedTask
id: parsedTask.id, // Use ID from parser
originalMarkdown: parsedTask.originalMarkdown,
status: parsedTask.status,
completed: parsedTask.completed,
content: parsedTask.content,
priority: parsedTask.priority,
dueDate: parsedTask.dueDate,
startDate: parsedTask.startDate,
scheduledDate: parsedTask.scheduledDate,
tags: parsedTask.tags || [], // Map tags, default to empty array
// Fields specific to SortableTask / required for sorting logic
lineNumber: lineNumber, // Keep 0-based line number for sorting stability
indentation: indentation,
children: [],
calculatedStatus: calculatedStatus,
// parent will be set below
};
// --- Build Hierarchy (relative to the parsed block) ---
const completeTask = newTask as SortableTask;
taskMap[lineNumber] = completeTask; // Use absolute line number for mapping
// --- Build Hierarchy ---
taskMap[lineNumber] = sortableTask; // Use 0-based absolute line number
// Find parent based on indentation
while (
currentParentStack.length > 0 &&
indentation <=
indentation <= // Child must have greater indentation than parent
currentParentStack[currentParentStack.length - 1]
.indentation
) {
@ -194,16 +160,16 @@ function parseTasksForSorting(
if (currentParentStack.length > 0) {
const parent =
currentParentStack[currentParentStack.length - 1];
parent.children.push(completeTask);
completeTask.parent = parent;
parent.children.push(sortableTask);
sortableTask.parent = parent;
} else {
tasks.push(completeTask); // Top-level task within the block
tasks.push(sortableTask); // Add as top-level task within the block
}
currentParentStack.push(completeTask);
currentParentStack.push(sortableTask); // Push current task onto stack
} else {
// Non-task line in the block
// currentParentStack = []; // Resetting might be too aggressive here
// Non-task line encountered
// Keep the stack, assuming tasks under a non-task might still be related hierarchically.
}
});
@ -298,7 +264,6 @@ function getDynamicStatusOrder(settings: TaskProgressBarSettings): {
if (!(" " in order)) order[" "] = order[" "] ?? 10; // Default incomplete reasonably high
if (!("x" in order)) order["x"] = order["x"] ?? 98; // Default complete towards end
console.debug("Generated Status Order:", order); // For debugging
return order;
}
@ -312,6 +277,12 @@ function compareTasks(
// Generate status order dynamically for this comparison run
const statusOrder = getDynamicStatusOrder(settings);
// First, completed tasks always come last
if (taskA.completed !== taskB.completed) {
return taskA.completed ? 1 : -1;
}
// If both are completed or both incomplete, sort by original criteria
for (const criterion of criteria) {
let valA: any;
let valB: any;
@ -319,73 +290,169 @@ function compareTasks(
switch (criterion.field) {
case "status":
valA = statusOrder[taskA.calculatedStatus] ?? 1000; // Unknown goes last
// Status comparison logic (relies on statusOrder having numbers)
valA = statusOrder[taskA.calculatedStatus] ?? 1000; // Assign a high number for unknown statuses
valB = statusOrder[taskB.calculatedStatus] ?? 1000;
comparison = valA - valB;
if (typeof valA === "number" && typeof valB === "number") {
comparison = valA - valB; // Lower number means higher rank in status order
} else {
// Fallback if statusOrder contains non-numbers (shouldn't happen ideally)
console.warn(
`Non-numeric status order values detected: ${valA}, ${valB}`
);
comparison = 0; // Treat as equal if non-numeric
}
break;
case "priority":
valA = taskA.priority ?? Infinity; // Tasks without priority go last
valB = taskB.priority ?? Infinity;
comparison = valA - valB;
// Assuming numerical mapping where HIGHER number means HIGHER priority (e.g., High=3, Medium=2, Low=1)
valA = taskA.priority; // Use undefined/null directly
valB = taskB.priority;
const aHasPriority = valA !== undefined && valA !== null;
const bHasPriority = valB !== undefined && valB !== null;
if (!aHasPriority && !bHasPriority) {
comparison = 0; // Both lack priority
} else if (!aHasPriority) {
// A lacks priority. 'asc' means High->Low->None. None is last (+1).
comparison = 1;
} else if (!bHasPriority) {
// B lacks priority. 'asc' means High->Low->None. None is last. B is last, so A is first (-1).
comparison = -1;
} else {
// Both have numeric priorities.
// Compare numerically: A - B.
// Example: High(3) vs Low(1) => comparison = 3 - 1 = 2
comparison = valA - valB;
}
// Direction adjustment is handled after the switch
break;
case "dueDate":
case "startDate":
case "scheduledDate":
valA = taskA[criterion.field] ?? Infinity; // No date goes last
valB = taskB[criterion.field] ?? Infinity;
if (valA === Infinity && valB !== Infinity) comparison = 1;
else if (valA !== Infinity && valB === Infinity)
valA = taskA[criterion.field]; // Use undefined/null directly
valB = taskB[criterion.field];
const aHasDate = valA !== undefined && valA !== null;
const bHasDate = valB !== undefined && valB !== null;
if (!aHasDate && !bHasDate) {
comparison = 0; // Both lack date
} else if (!aHasDate) {
// A lacks date. 'asc' means Dates->None. None is last (+1).
comparison = 1;
} else if (!bHasDate) {
// B lacks date. 'asc' means Dates->None. None is last. B is last, so A is first (-1).
comparison = -1;
else comparison = valA - valB;
} else {
// Both have numeric dates (timestamps)
// Compare numerically: A - B. Earlier date first.
comparison = valA - valB;
}
// Direction adjustment is handled after the switch
break;
case "content":
valA = taskA.content.toLowerCase();
valB = taskB.content.toLowerCase();
comparison = valA.localeCompare(valB);
// Direction adjustment is handled after the switch
break;
}
} // End switch
// Apply sort order direction (asc/desc)
if (comparison !== 0) {
return criterion.order === "asc" ? comparison : -comparison;
}
}
// Special handling for priority: 'asc' means HIGHER numeric value first.
if (criterion.field === "priority") {
// If 'asc' (High first), comparison = A - B. High(3) - Low(1) = 2. We need negative result -> return -comparison.
// If 'desc' (Low first), comparison = A - B. High(3) - Low(1) = 2. We need positive result -> return comparison.
return criterion.order === "asc" ? -comparison : comparison;
} else {
// For other fields (status, dates, content): 'asc' means LOWER numeric/alphabetic value first.
// Comparison = A - B or localeCompare(A, B).
// If 'asc', a negative comparison means A is first -> return comparison.
// If 'desc', flip the sign -> return -comparison.
return criterion.order === "asc" ? comparison : -comparison;
}
} // End if comparison !== 0
} // End for loop
// Maintain original relative order if all criteria are equal
return taskA.lineNumber - taskB.lineNumber;
}
// Recursively sorts a list of tasks and their children using settings
function sortTaskList(
tasks: SortableTask[],
criteria: SortCriterion[],
settings: TaskProgressBarSettings // Pass settings here
): SortableTask[] {
// Sort tasks at the current level
tasks.sort((a, b) => compareTasks(a, b, criteria, settings)); // Pass settings
// Find continuous task blocks (including subtasks)
export function findContinuousTaskBlocks(
tasks: SortableTask[]
): SortableTask[][] {
if (tasks.length === 0) return [];
// Recursively sort children
tasks.forEach((task) => {
if (task.children && task.children.length > 0) {
// Pass settings down recursively
sortTaskList(task.children, criteria, settings);
// Sort by line number
const sortedTasks = [...tasks].sort((a, b) => a.lineNumber - b.lineNumber);
// Task blocks array
const blocks: SortableTask[][] = [];
let currentBlock: SortableTask[] = [sortedTasks[0]];
// Recursively find the maximum line number of a task and all its children
function getMaxLineNumberWithChildren(task: SortableTask): number {
if (!task.children || task.children.length === 0)
return task.lineNumber;
let maxLine = task.lineNumber;
for (const child of task.children) {
const childMaxLine = getMaxLineNumberWithChildren(child);
maxLine = Math.max(maxLine, childMaxLine);
}
});
return tasks; // Return the sorted list (though modification is in-place)
return maxLine;
}
// Check all tasks, group into continuous blocks
for (let i = 1; i < sortedTasks.length; i++) {
const prevTask = sortedTasks[i - 1];
const currentTask = sortedTasks[i];
// Check the maximum line number of the previous task (including all subtasks)
const prevMaxLine = getMaxLineNumberWithChildren(prevTask);
// If the current task line number is the next line after the previous task or its subtasks, it belongs to the same block
if (currentTask.lineNumber <= prevMaxLine + 1) {
currentBlock.push(currentTask);
} else {
// Otherwise start a new block
blocks.push([...currentBlock]);
currentBlock = [currentTask];
}
}
// Add the last block
if (currentBlock.length > 0) {
blocks.push(currentBlock);
}
return blocks;
}
// --- 4. Generate Codemirror Changes (Modified) ---
// Recursively sort tasks and their subtasks
function sortTasksRecursively(
tasks: SortableTask[],
criteria: SortCriterion[],
settings: TaskProgressBarSettings
): SortableTask[] {
// Sort tasks at the current level
tasks.sort((a, b) => compareTasks(a, b, criteria, settings));
// Flattens a task tree back into text lines using originalMarkdown
function flattenTasksToText(tasks: SortableTask[]): string {
let lines: string[] = [];
tasks.forEach((task) => {
lines.push(task.originalMarkdown); // Preserves original formatting/indent
// Recursively sort each task's subtasks
for (const task of tasks) {
if (task.children && task.children.length > 0) {
lines.push(flattenTasksToText(task.children));
// Ensure sorted subtasks are saved back to task.children
task.children = sortTasksRecursively(
task.children,
criteria,
settings
);
}
});
return lines.join("\n");
}
return tasks; // Return the sorted task array
}
// Main function: Parses, sorts, and generates Codemirror changes
@ -394,13 +461,14 @@ export function sortTasksInDocument(
plugin: TaskProgressBarPlugin,
sortCriteria: SortCriterion[],
fullDocument: boolean = false // Keep parameter
): ChangeSpec[] | null {
): string | null {
const app = plugin.app;
const activeFile = app.workspace.getActiveFile(); // Assume command runs on active file
if (!activeFile) {
new Notice("Sort Tasks: No active file found.");
return null;
}
const filePath = activeFile.path; // Get file path
const cache = app.metadataCache.getFileCache(activeFile);
if (!cache) {
new Notice("Sort Tasks: Metadata cache not available.");
@ -409,6 +477,7 @@ export function sortTasksInDocument(
const doc = view.state.doc;
const settings = plugin.settings;
const metadataFormat: MetadataFormat = plugin.settings.preferMetadataFormat;
let startLine = 0;
let endLine = doc.lines - 1;
@ -465,9 +534,6 @@ export function sortTasksInDocument(
const toOffsetOriginal = doc.line(endLine + 1).to;
// Ensure offsets are valid
if (fromOffsetOriginal > toOffsetOriginal) {
console.log(
`Sort Tasks: Invalid range calculated (startLine: ${startLine}, endLine: ${endLine}). Aborting.`
);
new Notice(`Sort Tasks: Invalid range calculated for ${scopeMessage}.`);
return null;
}
@ -476,111 +542,142 @@ export function sortTasksInDocument(
toOffsetOriginal
);
// 1. Parse tasks *only* within the determined block, providing the offset
const blockTasks = parseTasksForSorting(originalBlockText, startLine);
// 1. Parse tasks *using the new function*, providing offset, path, and format
const blockTasks = parseTasksForSorting(
originalBlockText,
startLine,
filePath,
metadataFormat // Pass determined format
);
if (blockTasks.length === 0) {
const noticeMsg = `Sort Tasks: No tasks found in the ${scopeMessage} (Lines ${
startLine + 1
}-${endLine + 1}) to sort.`;
console.log(noticeMsg);
new Notice(noticeMsg);
return null;
}
console.log("blockTasks", blockTasks);
// 2. Sort the tasks parsed from the block using plugin settings
// sortTaskList modifies the array in-place and sorts children recursively
const sortedBlockTasks = sortTaskList(
[...blockTasks],
sortCriteria,
settings
); // Pass settings, sort a copy
// Find continuous task blocks
const taskBlocks = findContinuousTaskBlocks(blockTasks);
// 3. Flatten the *sorted* task structure back to text lines
// This text contains only the task lines, in their new sorted order,
// maintaining original markdown (including indentation).
const newSortedTasksOnlyText = flattenTasksToText(sortedBlockTasks);
// 4. Reconstruct the block text, preserving non-task lines
const originalBlockLines = originalBlockText.split("\n");
const sortedTaskLines = newSortedTasksOnlyText.split("\n"); // Individual sorted task lines
// Create a map from original document line number -> original task object
// This helps identify which original lines were tasks.
const originalTaskMap = new Map<number, SortableTask>();
function populateOriginalTaskMap(tasks: SortableTask[]) {
tasks.forEach((task) => {
originalTaskMap.set(task.lineNumber, task);
if (task.children) populateOriginalTaskMap(task.children);
});
// 2. Sort each continuous block separately
for (let i = 0; i < taskBlocks.length; i++) {
// Replace tasks in the original block with sorted tasks
taskBlocks[i] = sortTasksRecursively(
taskBlocks[i],
sortCriteria,
settings
);
}
populateOriginalTaskMap(blockTasks); // Use tasks before sorting
// Create a map from original document line number -> its *new* sorted line content
// This maps where each original task line should end up in the sorted output.
const sortedLineContentMap = new Map<number, string>();
let sortedLineIdx = 0;
function buildSortedLineContentMap(sortedTasks: SortableTask[]) {
sortedTasks.forEach((task) => {
if (sortedLineIdx < sortedTaskLines.length) {
// Map the task's original line number to the corresponding line
// from the flattened sorted text.
sortedLineContentMap.set(
task.lineNumber,
sortedTaskLines[sortedLineIdx++]
);
}
if (task.children) buildSortedLineContentMap(task.children);
});
}
buildSortedLineContentMap(sortedBlockTasks); // Use the sorted task structure
// 3. Update the original blockTasks to reflect sorting results
// Clear the original blockTasks
blockTasks.length = 0;
let newBlockLines: string[] = [];
for (let i = 0; i < originalBlockLines.length; i++) {
const currentOriginalDocLineNumber = startLine + i;
// Check if the line number in the original block corresponded to a task
if (originalTaskMap.has(currentOriginalDocLineNumber)) {
// It was a task. Get its new content from the sorted map.
const newContent = sortedLineContentMap.get(
currentOriginalDocLineNumber
);
if (newContent !== undefined) {
newBlockLines.push(newContent);
} else {
// This case indicates a mismatch between parsed tasks and sorted lines, potentially
// due to issues in flattening or mapping complex hierarchies. Fallback safely.
console.warn(
`Sort Tasks: Missing sorted content for task at original line ${currentOriginalDocLineNumber}. Keeping original line.`
);
newBlockLines.push(originalBlockLines[i]); // Keep original if lookup fails
}
} else {
// It was not a task line, keep the original line
newBlockLines.push(originalBlockLines[i]);
// Merge all sorted blocks back into blockTasks
for (const block of taskBlocks) {
for (const task of block) {
blockTasks.push(task);
}
}
const newBlockText = newBlockLines.join("\n");
// 5. Calculate changes only if the block text has actually changed
// 4. Rebuild text directly from sorted blockTasks
const originalBlockLines = originalBlockText.split("\n");
let newBlockLines: string[] = [...originalBlockLines]; // Copy original lines
const processedLineIndices = new Set<number>(); // Track processed line indices
// Find indices of all task lines
const taskLineIndices = new Set<number>();
for (const task of blockTasks) {
// Convert to index relative to block
const relativeIndex = task.lineNumber - startLine;
if (relativeIndex >= 0 && relativeIndex < originalBlockLines.length) {
taskLineIndices.add(relativeIndex);
}
}
// For each task block, find its starting position and sort tasks at that position
for (const block of taskBlocks) {
// Find the minimum line number (relative to block)
let minRelativeLineIndex = Number.MAX_SAFE_INTEGER;
for (const task of block) {
const relativeIndex = task.lineNumber - startLine;
if (
relativeIndex >= 0 &&
relativeIndex < originalBlockLines.length &&
relativeIndex < minRelativeLineIndex
) {
minRelativeLineIndex = relativeIndex;
}
}
if (minRelativeLineIndex === Number.MAX_SAFE_INTEGER) {
continue; // Skip invalid blocks
}
// Collect all task line content in this block
const blockContent: string[] = [];
// Recursively add tasks and their subtasks
function addSortedTaskContent(task: SortableTask) {
blockContent.push(task.originalMarkdown);
// Mark this line as processed
const relativeIndex = task.lineNumber - startLine;
if (
relativeIndex >= 0 &&
relativeIndex < originalBlockLines.length
) {
processedLineIndices.add(relativeIndex);
}
// Process subtasks
if (task.children && task.children.length > 0) {
for (const child of task.children) {
addSortedTaskContent(child);
}
}
}
// Only process top-level tasks
for (const task of block) {
if (!task.parent) {
// Only process top-level tasks
addSortedTaskContent(task);
}
}
// Replace content at original position
let currentLine = minRelativeLineIndex;
for (const line of blockContent) {
newBlockLines[currentLine++] = line;
}
}
// Remove processed lines (replaced task lines)
const finalLines: string[] = [];
for (let i = 0; i < newBlockLines.length; i++) {
if (!taskLineIndices.has(i) || processedLineIndices.has(i)) {
finalLines.push(newBlockLines[i]);
}
}
const newBlockText = finalLines.join("\n");
// 5. Only return new text if the block actually changed
if (originalBlockText === newBlockText) {
const noticeMsg = `Sort Tasks: Tasks are already sorted in the ${scopeMessage} (Lines ${
startLine + 1
}-${endLine + 1}).`;
console.log(noticeMsg);
new Notice(noticeMsg);
return null;
}
const changes: ChangeSpec = {
from: fromOffsetOriginal,
to: toOffsetOriginal,
insert: newBlockText,
};
const noticeMsg = `Sort Tasks: Sorted tasks in the ${scopeMessage} (Lines ${
startLine + 1
}-${endLine + 1}).`;
console.log(noticeMsg);
new Notice(noticeMsg);
return [changes];
// Directly return the changed text
return newBlockText;
}

View file

@ -1,6 +1,5 @@
import { Editor, MarkdownFileInfo, MarkdownView } from "obsidian";
import TaskProgressBarPlugin from "..";
import { taskStatusChangeAnnotation } from "../editor-ext/taskStatusSwitcher";
import TaskProgressBarPlugin from "../index";
/**
* Cycles the task status on the current line forward

View file

@ -10,7 +10,7 @@ import {
MarkdownView,
MarkdownFileInfo,
} from "obsidian";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { buildIndentString } from "../utils";
import { t } from "../translations/helper";

View file

@ -1,5 +1,5 @@
import { AbstractInputSuggest, App, prepareFuzzySearch, TFile } from "obsidian";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../index";
abstract class BaseSuggest<T> extends AbstractInputSuggest<T> {
constructor(app: App, public inputEl: HTMLInputElement) {

View file

@ -1,7 +1,7 @@
import { debounce, getIconIds, Notice, setIcon } from "obsidian";
import { ButtonComponent } from "obsidian";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../index";
export const attachIconMenu = (
btn: ButtonComponent,

View file

@ -1,6 +1,6 @@
import { App, Modal, Setting } from "obsidian";
import { RewardItem } from "../common/setting-definition";
import { t } from "src/translations/helper";
import { t } from "../translations/helper";
import "../styles/reward.css";
export class RewardModal extends Modal {

View file

@ -1,6 +1,6 @@
import { ExtraButtonComponent, Menu } from "obsidian";
import { Component } from "obsidian";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../index";
import { Task } from "../utils/types/TaskIndex";
import { createTaskCheckbox } from "./task-view/details";
import { getStatusText } from "./task-view/details";

View file

@ -1,6 +1,6 @@
import { Modal, App, Setting } from "obsidian";
import TaskProgressBarPlugin from "src";
import { WorkflowStage } from "src/common/setting-definition";
import TaskProgressBarPlugin from "../index";
import { WorkflowStage } from "../common/setting-definition";
import { t } from "../translations/helper";
import { StageEditModal } from "./StageEditModal";

View file

@ -7,7 +7,7 @@ import {
moment,
setIcon,
} from "obsidian";
import { Task } from "src/utils/types/TaskIndex"; // Assuming Task type exists here
import { Task } from "../../utils/types/TaskIndex"; // Assuming Task type exists here
// Removed: import { renderCalendarEvent } from "./event";
import "../../styles/calendar/view.css"; // Import the CSS file
import "../../styles/calendar/event.css"; // Import the CSS file
@ -19,7 +19,7 @@ import { WeekView } from "./views/week-view";
import { DayView } from "./views/day-view";
import { AgendaView } from "./views/agenda-view";
import { YearView } from "./views/year-view";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../../index";
import { QuickCaptureModal } from "../QuickCaptureModal";
// Import algorithm functions (optional for now, could be used within views)
// import { calculateEventLayout, determineEventColor } from './algorithm';

View file

@ -1,16 +1,10 @@
import {
App,
Component,
debounce,
moment,
MarkdownRenderer as ObsidianMarkdownRenderer,
} from "obsidian";
import { CalendarEvent } from ".."; // Adjust path as needed
import { App, Component, debounce, moment } from "obsidian";
import { CalendarEvent } from "../index"; // Adjust path as needed
import { EventLayout, determineEventColor } from "../algorithm"; // Adjust path as needed
import {
clearAllMarks,
MarkdownRendererComponent,
} from "src/components/MarkdownRenderer";
} from "../../MarkdownRenderer";
import { createTaskCheckbox } from "../../task-view/details";
export type EventViewType =

View file

@ -1,5 +1,5 @@
import { App, Component, moment } from "obsidian";
import { CalendarEvent } from "..";
import { CalendarEvent } from "../index";
import { renderCalendarEvent } from "../rendering/event-renderer"; // Use new renderer
import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; // Import base class
import TaskProgressBarPlugin from "../../../index"; // Import plugin type

View file

@ -1,6 +1,6 @@
import { App, Component } from "obsidian";
import { CalendarEvent } from "..";
import TaskProgressBarPlugin from "src";
import { CalendarEvent } from "../index";
import TaskProgressBarPlugin from "../../../index";
interface EventMap {
onEventClick: (ev: MouseEvent, event: CalendarEvent) => void;

View file

@ -1,9 +1,6 @@
import { App, Component, debounce, moment } from "obsidian";
import { CalendarEvent } from "..";
import {
renderCalendarEvent,
EventViewType,
} from "../rendering/event-renderer";
import { App, Component, moment } from "obsidian";
import { CalendarEvent } from "../index";
import { renderCalendarEvent } from "../rendering/event-renderer";
import { CalendarViewComponent, CalendarViewOptions } from "./base-view";
import TaskProgressBarPlugin from "../../../index";

View file

@ -1,10 +1,5 @@
import { App, Component, debounce, moment } from "obsidian";
import { CalendarEvent } from "..";
import {
calculateEventLayout,
determineEventColor,
EventLayout,
} from "../algorithm"; // Import layout functions
import { CalendarEvent } from "../index";
import { renderCalendarEvent } from "../rendering/event-renderer"; // Use new renderer
import {
CalendarSpecificConfig,

View file

@ -1,5 +1,5 @@
import { App, Component, debounce, moment } from "obsidian";
import { CalendarEvent } from "..";
import { CalendarEvent } from "../index";
import {
CalendarSpecificConfig,
getViewSettingOrDefault,

View file

@ -13,11 +13,11 @@ import { DateHelper } from "../../utils/DateHelper";
import { TimelineHeaderComponent } from "./timeline-header";
import { GridBackgroundComponent } from "./grid-background";
import { TaskRendererComponent } from "./task-renderer";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../../index";
import { FilterComponent, buildFilterOptionsFromTasks } from "../filter/filter";
import { ActiveFilter, FilterCategory } from "../filter/filter-type";
import { ScrollToDateButton } from "../filter/custom/scroll-to-date-button";
import { PRIORITY_MAP } from "src/common/default-symbol";
import { PRIORITY_MAP } from "../../common/default-symbol";
// Define the PRIORITY_MAP here as well, or import it if moved to a shared location
// This is needed to convert filter value (icon/text) back to number for comparison

View file

@ -6,14 +6,14 @@ import {
ScheduledHabitProps,
MappingHabitProps,
} from "../../types/habit-card"; // Assuming types are in src/types
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../../index";
import {
DailyHabitCard,
CountHabitCard,
ScheduledHabitCard,
MappingHabitCard,
} from "./habitcard/index"; // Import the habit card classes
import { t } from "src/translations/helper";
import { t } from "../../translations/helper";
import "../../styles/habit.css";
export class Habit extends Component {

View file

@ -1,8 +1,8 @@
import { ButtonComponent, Component, Notice, setIcon } from "obsidian";
import { CountHabitProps } from "src/types/habit-card";
import { CountHabitProps } from "../../../types/habit-card";
import { HabitCard } from "./habitcard";
import { t } from "src/translations/helper";
import TaskProgressBarPlugin from "src/index";
import { t } from "../../../translations/helper";
import TaskProgressBarPlugin from "../../../index";
export class CountHabitCard extends HabitCard {
constructor(

View file

@ -1,8 +1,8 @@
import { Component, Notice, setIcon } from "obsidian";
import { DailyHabitProps } from "src/types/habit-card";
import { DailyHabitProps } from "../../../types/habit-card";
import { HabitCard } from "./habitcard";
import { t } from "src/translations/helper";
import TaskProgressBarPlugin from "src/index";
import { t } from "../../../translations/helper";
import TaskProgressBarPlugin from "../../../index";
export class DailyHabitCard extends HabitCard {
constructor(

View file

@ -3,8 +3,8 @@ import {
DailyHabitProps,
HabitProps,
MappingHabitProps,
} from "src/types/habit-card";
import TaskProgressBarPlugin from "src/index";
} from "../../../types/habit-card";
import TaskProgressBarPlugin from "../../../index";
function getDatesInRange(startDate: string, endDate: string): string[] {
const dates = [];

View file

@ -6,9 +6,9 @@ import {
Setting,
SliderComponent,
} from "obsidian";
import { MappingHabitProps } from "src/types/habit-card";
import { MappingHabitProps } from "../../../types/habit-card";
import { HabitCard } from "./habitcard";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../../../index";
export class MappingHabitCard extends HabitCard {
constructor(

View file

@ -5,10 +5,10 @@ import {
setIcon,
Setting,
} from "obsidian";
import { ScheduledHabitProps } from "src/types/habit-card";
import { ScheduledHabitProps } from "../../../types/habit-card";
import { HabitCard } from "./habitcard";
import TaskProgressBarPlugin from "src";
import { t } from "src/translations/helper";
import TaskProgressBarPlugin from "../../../index";
import { t } from "../../../translations/helper";
import { EventDetailModal } from "../habit";
function renderPieDotSVG(completed: number, total: number): string {

View file

@ -1,5 +1,5 @@
import { App, Component, MarkdownRenderer, Menu, TFile } from "obsidian";
import { Task } from "src/utils/types/TaskIndex"; // Adjust path
import { Task } from "../../utils/types/TaskIndex"; // Adjust path
import { MarkdownRendererComponent } from "../MarkdownRenderer"; // Adjust path
import TaskProgressBarPlugin from "../../index"; // Adjust path
import { KanbanSpecificConfig } from "../../common/setting-definition";

View file

@ -1,5 +1,5 @@
import { App, Component, setIcon } from "obsidian";
import { Task } from "src/utils/types/TaskIndex"; // Adjust path
import { Task } from "../../utils/types/TaskIndex"; // Adjust path
import { KanbanCardComponent } from "./kanban-card";
import TaskProgressBarPlugin from "../../index"; // Adjust path
import { QuickCaptureModal } from "../QuickCaptureModal"; // Import QuickCaptureModal

View file

@ -1,6 +1,6 @@
import { App, Component, WorkspaceLeaf } from "obsidian";
import TaskProgressBarPlugin from "../../index"; // Adjust path as needed
import { Task } from "src/utils/types/TaskIndex"; // Adjust path as needed
import { Task } from "../../utils/types/TaskIndex"; // Adjust path as needed
import { KanbanColumnComponent } from "./kanban-column";
import { DragManager, DragMoveEvent, DragEndEvent } from "../DragManager";
import "../../styles/kanban/kanban.css";

View file

@ -1,4 +1,4 @@
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import {
Component,
debounce,
@ -12,7 +12,7 @@ import {
checkIfParentElementHasGoalFormat,
extractTaskAndGoalInfoReadMode,
getCustomTotalGoalReadMode,
} from "src/utils/goal/readMode";
} from "../utils/goal/readMode";
interface GroupElement {
parentElement: HTMLElement;

View file

@ -7,7 +7,7 @@ import {
TFile,
} from "obsidian";
import { getTasksAPI } from "../utils";
import { parseTaskLine } from "src/utils/taskUtil";
import { parseTaskLine } from "../utils/taskUtil";
// This component replaces standard checkboxes with custom text marks in reading view
export function applyTaskTextMarks({

View file

@ -9,7 +9,7 @@ import { Task } from "../../utils/types/TaskIndex";
import { t } from "../../translations/helper";
import "../../styles/project-view.css";
import { TaskListRendererComponent } from "./TaskList";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../../index";
interface SelectedProjects {
projects: string[];

View file

@ -8,7 +8,7 @@ import {
ViewMode,
getViewSettingOrDefault,
} from "../../common/setting-definition";
import { TASK_SPECIFIC_VIEW_TYPE } from "src/pages/TaskSpecificView";
import { TASK_SPECIFIC_VIEW_TYPE } from "../../pages/TaskSpecificView";
import { ViewConfigModal } from "../ViewConfigModal";
// Remove the enum if it exists, use ViewMode type directly

View file

@ -7,7 +7,7 @@ import {
} from "@codemirror/state";
import { getTabSize } from "../utils";
import { taskStatusChangeAnnotation } from "./taskStatusSwitcher";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import {
isLastWorkflowStageOrNotWorkflow,
workflowChangeAnnotation,
@ -126,12 +126,6 @@ function handleParentTaskUpdateTransaction(
) {
const inProgressStatuses =
plugin.settings.taskStatuses.inProgress.split("|") || ["/"];
// Prevent updating parent if the trigger was an auto-complete itself
console.log(
isAutoCompleteAnnotation,
anySiblingsWithStatus,
tr.changes
);
if (!isAutoCompleteAnnotation) {
return markParentAsInProgress(
tr,

View file

@ -5,11 +5,11 @@ import {
Transaction,
TransactionSpec,
} from "@codemirror/state";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { taskStatusChangeAnnotation } from "./taskStatusSwitcher";
import { getTasksAPI } from "../utils";
import { priorityChangeAnnotation } from "./priorityPicker";
import { parseTaskLine } from "src/utils/taskUtil";
import { parseTaskLine } from "../utils/taskUtil";
/**
* Creates an editor extension that cycles through task statuses when a user clicks on a task marker
* @param app The Obsidian app instance
@ -279,8 +279,6 @@ export function findTaskStatusChanges(
isTaskChange = true;
}
console.log(newLineText, insertedText);
if (
tasksPluginLoaded &&
newLineText === insertedText &&
@ -296,14 +294,6 @@ export function findTaskStatusChanges(
triggerByTasks = true;
}
console.log({
triggerByTasks,
wasCompleteTask,
isTaskChange,
changedPosition,
currentMark,
});
if (
changedPosition !== null &&
currentMark !== null &&

View file

@ -10,7 +10,7 @@ import {
PluginSpec,
} from "@codemirror/view";
import { App, editorLivePreviewField, Menu, MenuItem, moment } from "obsidian";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { Annotation } from "@codemirror/state";
// @ts-ignore - This import is necessary but TypeScript can't find it
import { syntaxTree, tokenClassNodeProp } from "@codemirror/language";

View file

@ -1,6 +1,6 @@
import { App, editorInfoField } from "obsidian";
import { EditorState, Transaction, Text } from "@codemirror/state";
import TaskProgressBarPlugin from ".."; // Adjust path if needed
import TaskProgressBarPlugin from "../index"; // Adjust path if needed
import { parseTaskLine } from "../utils/taskUtil"; // Adjust path if needed
/**
@ -33,7 +33,6 @@ function handleMonitorTaskCompletionTransaction(
app: App,
plugin: TaskProgressBarPlugin
) {
console.log("handleMonitorTaskCompletionTransaction");
// Only process transactions that change the document
if (!tr.docChanged) {
return;

View file

@ -10,7 +10,7 @@ import {
PluginSpec,
} from "@codemirror/view";
import { App, editorLivePreviewField, Keymap, Menu } from "obsidian";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { Annotation } from "@codemirror/state";
// @ts-ignore - This import is necessary but TypeScript can't find it
import { syntaxTree, tokenClassNodeProp } from "@codemirror/language";

View file

@ -12,10 +12,10 @@ import { EditorState, Range, Text } from "@codemirror/state";
// @ts-ignore - This import is necessary but TypeScript can't find it
import { foldable, syntaxTree, tokenClassNodeProp } from "@codemirror/language";
import { RegExpCursor } from "./regexp-cursor";
import TaskProgressBarPlugin, { showPopoverWithProgressBar } from "..";
import TaskProgressBarPlugin, { showPopoverWithProgressBar } from "../index";
import { shouldHideProgressBarInLivePriview } from "../utils";
import "../styles/progressbar.css";
import { extractTaskAndGoalInfo } from "src/utils/goal/editMode";
import { extractTaskAndGoalInfo } from "../utils/goal/editMode";
interface Tasks {
completed: number;

View file

@ -16,12 +16,11 @@ import {
Keymap,
Menu,
} from "obsidian";
import TaskProgressBarPlugin from "..";
import { Annotation, EditorSelection, SelectionRange } from "@codemirror/state";
import TaskProgressBarPlugin from "../index";
import { Annotation, EditorSelection } from "@codemirror/state";
// @ts-ignore - This import is necessary but TypeScript can't find it
import { foldable, syntaxTree, tokenClassNodeProp } from "@codemirror/language";
import { getTasksAPI } from "src/utils";
import { parseTaskLine } from "src/utils/taskUtil";
import { syntaxTree, tokenClassNodeProp } from "@codemirror/language";
import { getTasksAPI } from "../utils";
export type TaskState = string;
export const taskStatusChangeAnnotation = Annotation.define();

View file

@ -7,7 +7,7 @@ import {
Text,
} from "@codemirror/state";
import { Annotation } from "@codemirror/state";
import TaskProgressBarPlugin from "..";
import TaskProgressBarPlugin from "../index";
import { taskStatusChangeAnnotation } from "./taskStatusSwitcher";
import { priorityChangeAnnotation } from "./priorityPicker";
import { buildIndentString, getTabSize } from "../utils";

View file

@ -466,7 +466,11 @@ export default class TaskProgressBarPlugin extends Plugin {
const changes = sortTasksInDocument(editorView, this, criteria);
if (changes) {
editorView.dispatch({ changes });
const info = editorView.state.field(editorInfoField);
if (!info || !info.file) return;
this.app.vault.process(info.file, (data) => {
return changes;
});
} else {
new Notice("Tasks already sorted or no tasks found.");
}

View file

@ -37,7 +37,7 @@ import { CalendarComponent, CalendarEvent } from "../components/calendar";
import { KanbanComponent } from "../components/kanban/kanban";
import { GanttComponent } from "../components/gantt/gantt";
import { TaskPropertyTwoColumnView } from "../components/task-view/TaskPropertyTwoColumnView";
import { Habit } from "src/components/habit/habit";
import { Habit } from "../components/habit/habit";
export const TASK_VIEW_TYPE = "task-genius-view";

View file

@ -121,8 +121,6 @@ class TranslationManager {
}
}
console.log(this.currentLocale);
// If still not found, use fallback
if (!result) {
console.warn(
@ -156,8 +154,6 @@ class TranslationManager {
// Remove leading/trailing quotes if present
result = result.replace(/^["""']|["""']$/g, "");
console.log(result);
return result;
}

View file

@ -15,7 +15,7 @@ import {
import { TaskIndexer } from "./import/TaskIndexer";
import { TaskWorkerManager } from "./workers/TaskWorkerManager";
import { LocalStorageCache } from "./persister";
import TaskProgressBarPlugin from "src";
import TaskProgressBarPlugin from "../index";
import { RRule, RRuleSet, rrulestr } from "rrule";
/**
@ -1515,37 +1515,70 @@ export class TaskManager extends Component {
// Start with current due date or today if no due date
const baseDate = task.dueDate ? new Date(task.dueDate) : new Date();
// Ensure baseDate is at the beginning of the day for date-based recurrence
baseDate.setHours(0, 0, 0, 0);
try {
// Parse different recurrence formats
// Tasks plugin style: "every day", "every 2 weeks", etc.
const recurrence = task.recurrence.trim().toLowerCase();
// Attempt to parse using rrule first
try {
// Use the task's recurrence string directly if it's a valid RRULE string
// Provide dtstart to rrulestr for context, especially for rules that might depend on the start date.
const rule = rrulestr(task.recurrence, { dtstart: baseDate });
// Default to adding 1 day if we can't parse the recurrence
let nextDate = new Date(baseDate);
nextDate.setDate(nextDate.getDate() + 1);
// We want the first occurrence strictly *after* the baseDate.
// Adding a small time offset ensures we get the next instance even if baseDate itself is an occurrence.
const afterDate = new Date(baseDate.getTime() + 1000); // 1 second after baseDate
const nextOccurrence = rule.after(afterDate); // Find the first occurrence after this adjusted date
if (nextOccurrence) {
// Set time to start of day, assuming date-only recurrence for now
nextOccurrence.setHours(0, 0, 0, 0);
this.log(
`Calculated next date using rrule for '${
task.recurrence
}': ${nextOccurrence.toISOString()}`
);
return nextOccurrence.getTime();
} else {
// No next occurrence found by rrule (e.g., rule has COUNT and finished)
this.log(
`[TaskManager] rrule couldn't find next occurrence for rule: ${task.recurrence}. Falling back.`
);
// Fall through to simple logic below
}
} catch (e) {
// rrulestr failed, likely not a standard RRULE format. Fall back to simple parsing.
if (e instanceof Error) {
this.log(
`[TaskManager] Failed to parse recurrence '${task.recurrence}' with rrule. Falling back to simple logic. Error: ${e.message}`
);
} else {
this.log(
`[TaskManager] Failed to parse recurrence '${task.recurrence}' with rrule. Falling back to simple logic. Unknown error.`
);
}
}
// --- Fallback Simple Parsing Logic ---
this.log(
`[TaskManager] Using fallback logic for recurrence: ${task.recurrence}`
);
const recurrence = task.recurrence.trim().toLowerCase();
let nextDate = new Date(baseDate); // Start calculation from the base date
// Parse "every X days/weeks/months/years" format
if (recurrence.startsWith("every")) {
const parts = recurrence.split(" ");
// Handle "every day/week/month/year"
if (parts.length >= 2) {
let interval = 1;
let unit = parts[1];
// Check if there's a number after "every"
if (parts.length >= 3 && !isNaN(parseInt(parts[1]))) {
interval = parseInt(parts[1]);
unit = parts[2];
}
// Make unit singular for matching
if (unit.endsWith("s")) {
unit = unit.substring(0, unit.length - 1);
}
// Calculate next date based on unit
switch (unit) {
case "day":
nextDate.setDate(baseDate.getDate() + interval);
@ -1562,9 +1595,17 @@ export class TaskManager extends Component {
);
break;
default:
// For unknown units, default to days
nextDate.setDate(baseDate.getDate() + interval);
this.log(
`[TaskManager] Unknown unit in recurrence '${recurrence}'. Defaulting to days.`
);
nextDate.setDate(baseDate.getDate() + interval); // Default to days
}
} else {
// Malformed "every" rule, fallback to +1 day from baseDate
this.log(
`[TaskManager] Malformed 'every' rule '${recurrence}'. Defaulting to next day.`
);
nextDate.setDate(baseDate.getDate() + 1);
}
}
// Handle specific weekday recurrences like "every Monday"
@ -1577,7 +1618,7 @@ export class TaskManager extends Component {
recurrence.includes("saturday") ||
recurrence.includes("sunday")
) {
const weekdays = {
const weekdays: { [key: string]: number } = {
sunday: 0,
monday: 1,
tuesday: 2,
@ -1586,8 +1627,6 @@ export class TaskManager extends Component {
friday: 5,
saturday: 6,
};
// Find which weekday is mentioned
let targetDay = -1;
for (const [day, value] of Object.entries(weekdays)) {
if (recurrence.includes(day)) {
@ -1595,25 +1634,53 @@ export class TaskManager extends Component {
break;
}
}
if (targetDay >= 0) {
// Start from tomorrow to avoid repeating on the same day
// Start calculation from the day *after* the baseDate
nextDate.setDate(baseDate.getDate() + 1);
// Find the next occurrence of the target day
while (nextDate.getDay() !== targetDay) {
nextDate.setDate(nextDate.getDate() + 1);
}
} else {
// Malformed weekday rule, fallback to +1 day from baseDate
this.log(
`[TaskManager] Malformed weekday rule '${recurrence}'. Defaulting to next day.`
);
nextDate.setDate(baseDate.getDate() + 1);
}
} else {
// Unknown format, fallback to +1 day from baseDate
this.log(
`[TaskManager] Unknown recurrence format '${recurrence}'. Defaulting to next day.`
);
nextDate.setDate(baseDate.getDate() + 1);
}
// Ensure the calculated date is at the start of the day
nextDate.setHours(0, 0, 0, 0);
this.log(
`Calculated next date using simple logic for '${
task.recurrence
}': ${nextDate.toISOString()}`
);
return nextDate.getTime();
} catch (error) {
console.error("Error calculating next due date:", error);
// Default fallback: add one day
const tomorrow = new Date(baseDate);
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.getTime();
// Default fallback: add one day to baseDate
const fallbackDate = new Date(baseDate);
fallbackDate.setDate(fallbackDate.getDate() + 1);
fallbackDate.setHours(0, 0, 0, 0);
if (task.recurrence) {
this.log(
`Error calculating next due date for '${
task.recurrence
}'. Defaulting to ${fallbackDate.toISOString()}`
);
} else {
this.log(
`Error calculating next due date for task without recurrence. Defaulting to ${fallbackDate.toISOString()}`
);
}
return fallbackDate.getTime();
}
}

View file

@ -1,4 +1,4 @@
import { PRIORITY_MAP } from "src/common/default-symbol";
import { PRIORITY_MAP } from "../common/default-symbol";
import { parseLocalDate } from "./dateUtil";
import { Task } from "./types/TaskIndex";
import {