mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
chore: bump version
This commit is contained in:
commit
5b1c9a4799
52 changed files with 7522 additions and 1814 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -42,4 +42,6 @@ translation-templates
|
|||
|
||||
styles.css
|
||||
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
.kiro
|
||||
.claude
|
||||
|
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||
moduleNameMapper: {
|
||||
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
|
||||
"^moment$": "<rootDir>/src/__mocks__/moment.js",
|
||||
"^@codemirror/state$": "<rootDir>/src/__mocks__/codemirror-state.ts",
|
||||
"^@codemirror/view$": "<rootDir>/src/__mocks__/codemirror-view.ts",
|
||||
"^@codemirror/language$":
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "obsidian-task-progress-bar",
|
||||
"name": "Task Genius",
|
||||
"version": "9.1.0-beta.9",
|
||||
"version": "9.1.0-beta.10",
|
||||
"minAppVersion": "0.15.2",
|
||||
"description": "Comprehensive task management that includes progress bars, task status cycling, and advanced task tracking features.",
|
||||
"author": "Boninall",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "task-genius",
|
||||
"version": "9.1.0-beta.9",
|
||||
"version": "9.1.0-beta.10",
|
||||
"description": "Comprehensive task management plugin for Obsidian with progress bars, task status cycling, and advanced task tracking features.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
126
src/__mocks__/moment.js
Normal file
126
src/__mocks__/moment.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Global moment.js mock
|
||||
const moment = function(input) {
|
||||
let date;
|
||||
if (input instanceof Date) {
|
||||
date = input;
|
||||
} else if (typeof input === "string") {
|
||||
date = new Date(input);
|
||||
} else if (typeof input === "number") {
|
||||
date = new Date(input);
|
||||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
return {
|
||||
format: function(format) {
|
||||
if (format === "YYYY-MM-DD") {
|
||||
return date.toISOString().split("T")[0];
|
||||
} else if (format === "D") {
|
||||
return date.getDate().toString();
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
},
|
||||
diff: function() {
|
||||
return 0;
|
||||
},
|
||||
startOf: function(unit) {
|
||||
return this;
|
||||
},
|
||||
endOf: function(unit) {
|
||||
return this;
|
||||
},
|
||||
isSame: function(other, unit) {
|
||||
return true;
|
||||
},
|
||||
isSameOrBefore: function(other, unit) {
|
||||
return true;
|
||||
},
|
||||
isSameOrAfter: function(other, unit) {
|
||||
return true;
|
||||
},
|
||||
isBefore: function(other, unit) {
|
||||
return false;
|
||||
},
|
||||
isAfter: function(other, unit) {
|
||||
return false;
|
||||
},
|
||||
isBetween: function(start, end, unit, inclusivity) {
|
||||
return true;
|
||||
},
|
||||
clone: function() {
|
||||
return moment(date);
|
||||
},
|
||||
add: function(amount, unit) {
|
||||
return this;
|
||||
},
|
||||
subtract: function(amount, unit) {
|
||||
return this;
|
||||
},
|
||||
valueOf: function() {
|
||||
return date.getTime();
|
||||
},
|
||||
toDate: function() {
|
||||
return date;
|
||||
},
|
||||
weekday: function(day) {
|
||||
if (day !== undefined) {
|
||||
return this;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
day: function() {
|
||||
return date.getDay();
|
||||
},
|
||||
date: function() {
|
||||
return date.getDate();
|
||||
},
|
||||
_date: date,
|
||||
};
|
||||
};
|
||||
|
||||
// Static methods
|
||||
moment.utc = function() {
|
||||
return {
|
||||
format: function() {
|
||||
return "00:00:00";
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
moment.duration = function() {
|
||||
return {
|
||||
asMilliseconds: function() {
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
moment.locale = function(locale) {
|
||||
if (locale) {
|
||||
moment._currentLocale = locale;
|
||||
return locale;
|
||||
}
|
||||
return moment._currentLocale || "en";
|
||||
};
|
||||
|
||||
moment._currentLocale = "en";
|
||||
|
||||
moment.weekdaysShort = function(localeData) {
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
};
|
||||
|
||||
moment.weekdaysMin = function(localeData) {
|
||||
return ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
};
|
||||
|
||||
moment.months = function() {
|
||||
return ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
};
|
||||
|
||||
moment.monthsShort = function() {
|
||||
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
};
|
||||
|
||||
module.exports = moment;
|
||||
|
|
@ -27,6 +27,36 @@ export class App {
|
|||
if (key === "useTab") return false;
|
||||
return null;
|
||||
},
|
||||
// Event system for vault
|
||||
_events: {} as Record<string, Function[]>,
|
||||
on: function (eventName: string, callback: Function) {
|
||||
if (!this._events[eventName]) {
|
||||
this._events[eventName] = [];
|
||||
}
|
||||
this._events[eventName].push(callback);
|
||||
return { unload: () => this.off(eventName, callback) };
|
||||
},
|
||||
off: function (eventName: string, callback: Function) {
|
||||
if (this._events[eventName]) {
|
||||
const index = this._events[eventName].indexOf(callback);
|
||||
if (index > -1) {
|
||||
this._events[eventName].splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
trigger: function (eventName: string, ...args: any[]) {
|
||||
if (this._events[eventName]) {
|
||||
this._events[eventName].forEach((callback: any) => callback(...args));
|
||||
}
|
||||
},
|
||||
getFileByPath: function (path: string) {
|
||||
// Mock implementation for getFileByPath
|
||||
return {
|
||||
path: path,
|
||||
name: path.split('/').pop() || path,
|
||||
children: [], // For directory-like behavior
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
workspace = {
|
||||
|
|
@ -298,9 +328,16 @@ function momentFn(input?: any) {
|
|||
};
|
||||
|
||||
(momentFn as any).locale = function (locale?: string) {
|
||||
return locale || "en";
|
||||
if (locale) {
|
||||
(momentFn as any)._currentLocale = locale;
|
||||
return locale;
|
||||
}
|
||||
return (momentFn as any)._currentLocale || "en";
|
||||
};
|
||||
|
||||
// Initialize default locale
|
||||
(momentFn as any)._currentLocale = "en";
|
||||
|
||||
(momentFn as any).weekdaysShort = function (localeData?: boolean) {
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
};
|
||||
|
|
@ -309,6 +346,16 @@ function momentFn(input?: any) {
|
|||
return ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
};
|
||||
|
||||
(momentFn as any).months = function () {
|
||||
return ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
};
|
||||
|
||||
(momentFn as any).monthsShort = function () {
|
||||
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
};
|
||||
|
||||
export const moment = momentFn as any;
|
||||
|
||||
// Mock Component class
|
||||
|
|
@ -366,6 +413,12 @@ export class Component {
|
|||
// Mock implementation
|
||||
return id;
|
||||
}
|
||||
|
||||
private _events: Array<{ unload: () => void }> = [];
|
||||
|
||||
registerEvent(eventRef: { unload: () => void }): void {
|
||||
this._events.push(eventRef);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock other common Obsidian utilities
|
||||
|
|
@ -391,4 +444,26 @@ export function debounce<T extends (...args: any[]) => any>(
|
|||
}) as T;
|
||||
}
|
||||
|
||||
// Mock EditorSuggest class
|
||||
export abstract class EditorSuggest<T> extends Component {
|
||||
app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
super();
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
abstract getSuggestions(context: any): T[] | Promise<T[]>;
|
||||
abstract renderSuggestion(suggestion: T, el: HTMLElement): void;
|
||||
abstract selectSuggestion(suggestion: T, evt: MouseEvent | KeyboardEvent): void;
|
||||
|
||||
onTrigger(cursor: any, editor: any, file: any): any {
|
||||
return null;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Mock implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Add any other Obsidian classes or functions needed for tests
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ describe("FileMetadataTaskParser", () => {
|
|||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
enableMtimeOptimization: false,
|
||||
mtimeCacheSize: 1000,
|
||||
};
|
||||
parser = new FileMetadataTaskParser(config);
|
||||
});
|
||||
|
|
@ -206,6 +208,8 @@ describe("FileMetadataTaskParser", () => {
|
|||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
enableMtimeOptimization: false,
|
||||
mtimeCacheSize: 1000,
|
||||
};
|
||||
const disabledParser = new FileMetadataTaskParser(disabledConfig);
|
||||
|
||||
|
|
@ -358,6 +362,8 @@ describe("FileMetadataTaskUpdater", () => {
|
|||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
enableMtimeOptimization: false,
|
||||
mtimeCacheSize: 1000,
|
||||
};
|
||||
|
||||
// Mock Obsidian App
|
||||
|
|
|
|||
261
src/__tests__/ProjectConfigManager.cache.test.ts
Normal file
261
src/__tests__/ProjectConfigManager.cache.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Performance tests for ProjectConfigManager cache optimizations
|
||||
*/
|
||||
|
||||
import { ProjectConfigManager, ProjectConfigManagerOptions } from "../utils/ProjectConfigManager";
|
||||
import { TFile, Vault, MetadataCache } from "obsidian";
|
||||
|
||||
// Mock implementations
|
||||
const createMockFile = (path: string, mtime: number, frontmatter?: any): TFile => ({
|
||||
path,
|
||||
name: path.split("/").pop() || "",
|
||||
basename: path.split("/").pop()?.replace(/\.[^/.]+$/, "") || "",
|
||||
extension: path.split(".").pop() || "",
|
||||
stat: {
|
||||
ctime: mtime - 1000,
|
||||
mtime,
|
||||
size: 1000
|
||||
},
|
||||
vault: {} as Vault,
|
||||
parent: null,
|
||||
} as TFile);
|
||||
|
||||
const createMockVault = (files: Map<string, TFile>) => ({
|
||||
getFileByPath: (path: string) => files.get(path) || null,
|
||||
getAbstractFileByPath: (path: string) => files.get(path) || null,
|
||||
read: async (file: TFile) => `---\nproject: test\n---\nContent`,
|
||||
} as Vault);
|
||||
|
||||
const createMockMetadataCache = (metadata: Map<string, any>) => ({
|
||||
getFileCache: (file: TFile) => ({
|
||||
frontmatter: metadata.get(file.path) || {}
|
||||
}),
|
||||
} as MetadataCache);
|
||||
|
||||
describe("ProjectConfigManager Cache Performance", () => {
|
||||
let projectConfigManager: ProjectConfigManager;
|
||||
let mockFiles: Map<string, TFile>;
|
||||
let mockMetadata: Map<string, any>;
|
||||
let vault: Vault;
|
||||
let metadataCache: MetadataCache;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFiles = new Map();
|
||||
mockMetadata = new Map();
|
||||
vault = createMockVault(mockFiles);
|
||||
metadataCache = createMockMetadataCache(mockMetadata);
|
||||
|
||||
const options: ProjectConfigManagerOptions = {
|
||||
vault,
|
||||
metadataCache,
|
||||
configFileName: "task-genius.config.md",
|
||||
searchRecursively: true,
|
||||
metadataKey: "project",
|
||||
pathMappings: [],
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
enabled: false,
|
||||
},
|
||||
enhancedProjectEnabled: true,
|
||||
};
|
||||
|
||||
projectConfigManager = new ProjectConfigManager(options);
|
||||
});
|
||||
|
||||
describe("getFileMetadata caching", () => {
|
||||
it("should cache file metadata based on mtime", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = Date.now();
|
||||
const frontmatter = { project: "test-project", priority: "high" };
|
||||
|
||||
// Setup mock file and metadata
|
||||
mockFiles.set(filePath, createMockFile(filePath, mtime, frontmatter));
|
||||
mockMetadata.set(filePath, frontmatter);
|
||||
|
||||
// First call - should read from metadataCache
|
||||
const result1 = projectConfigManager.getFileMetadata(filePath);
|
||||
expect(result1).toEqual(frontmatter);
|
||||
|
||||
// Second call with same mtime - should return cached result
|
||||
const result2 = projectConfigManager.getFileMetadata(filePath);
|
||||
expect(result2).toEqual(frontmatter);
|
||||
expect(result2).toBe(result1); // Should be same object reference (cached)
|
||||
});
|
||||
|
||||
it("should invalidate cache when file mtime changes", () => {
|
||||
const filePath = "test.md";
|
||||
const initialMtime = Date.now();
|
||||
const updatedMtime = initialMtime + 1000;
|
||||
const initialFrontmatter = { project: "initial" };
|
||||
const updatedFrontmatter = { project: "updated" };
|
||||
|
||||
// Setup initial file
|
||||
mockFiles.set(filePath, createMockFile(filePath, initialMtime));
|
||||
mockMetadata.set(filePath, initialFrontmatter);
|
||||
|
||||
// First call
|
||||
const result1 = projectConfigManager.getFileMetadata(filePath);
|
||||
expect(result1).toEqual(initialFrontmatter);
|
||||
|
||||
// Update file mtime and metadata
|
||||
mockFiles.set(filePath, createMockFile(filePath, updatedMtime));
|
||||
mockMetadata.set(filePath, updatedFrontmatter);
|
||||
|
||||
// Second call - should detect file change and return new data
|
||||
const result2 = projectConfigManager.getFileMetadata(filePath);
|
||||
expect(result2).toEqual(updatedFrontmatter);
|
||||
expect(result2).not.toBe(result1); // Should be different object (cache miss)
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnhancedMetadata caching", () => {
|
||||
it("should cache enhanced metadata based on composite key", async () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = Date.now();
|
||||
const frontmatter = { priority: "high" };
|
||||
|
||||
// Setup mock file
|
||||
mockFiles.set(filePath, createMockFile(filePath, mtime));
|
||||
mockMetadata.set(filePath, frontmatter);
|
||||
|
||||
// First call
|
||||
const result1 = await projectConfigManager.getEnhancedMetadata(filePath);
|
||||
expect(result1).toEqual(frontmatter);
|
||||
|
||||
// Second call with same file state - should return cached result
|
||||
const result2 = await projectConfigManager.getEnhancedMetadata(filePath);
|
||||
expect(result2).toEqual(frontmatter);
|
||||
});
|
||||
|
||||
it("should invalidate cache when either file or config changes", async () => {
|
||||
const filePath = "test.md";
|
||||
const initialMtime = Date.now();
|
||||
const updatedMtime = initialMtime + 1000;
|
||||
const initialFrontmatter = { priority: "high" };
|
||||
const updatedFrontmatter = { priority: "low" };
|
||||
|
||||
// Setup initial state
|
||||
mockFiles.set(filePath, createMockFile(filePath, initialMtime));
|
||||
mockMetadata.set(filePath, initialFrontmatter);
|
||||
|
||||
// First call
|
||||
const result1 = await projectConfigManager.getEnhancedMetadata(filePath);
|
||||
expect(result1).toEqual(initialFrontmatter);
|
||||
|
||||
// Update file
|
||||
mockFiles.set(filePath, createMockFile(filePath, updatedMtime));
|
||||
mockMetadata.set(filePath, updatedFrontmatter);
|
||||
|
||||
// Second call - should detect change and return new data
|
||||
const result2 = await projectConfigManager.getEnhancedMetadata(filePath);
|
||||
expect(result2).toEqual(updatedFrontmatter);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache statistics", () => {
|
||||
it("should provide accurate cache statistics", () => {
|
||||
const filePath1 = "test1.md";
|
||||
const filePath2 = "test2.md";
|
||||
const mtime = Date.now();
|
||||
|
||||
// Setup files
|
||||
mockFiles.set(filePath1, createMockFile(filePath1, mtime));
|
||||
mockFiles.set(filePath2, createMockFile(filePath2, mtime));
|
||||
mockMetadata.set(filePath1, { project: "test1" });
|
||||
mockMetadata.set(filePath2, { project: "test2" });
|
||||
|
||||
// Load data into cache
|
||||
projectConfigManager.getFileMetadata(filePath1);
|
||||
projectConfigManager.getFileMetadata(filePath2);
|
||||
|
||||
const stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(2);
|
||||
expect(stats.totalMemoryUsage.estimatedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache clearing", () => {
|
||||
it("should clear specific file from all related caches", async () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = Date.now();
|
||||
|
||||
mockFiles.set(filePath, createMockFile(filePath, mtime));
|
||||
mockMetadata.set(filePath, { project: "test" });
|
||||
|
||||
// Load data into caches
|
||||
projectConfigManager.getFileMetadata(filePath);
|
||||
await projectConfigManager.getEnhancedMetadata(filePath);
|
||||
|
||||
// Verify data is cached
|
||||
let stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(1);
|
||||
expect(stats.enhancedMetadataCache.size).toBe(1);
|
||||
|
||||
// Clear cache for specific file
|
||||
projectConfigManager.clearCache(filePath);
|
||||
|
||||
// Verify cache is cleared
|
||||
stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(0);
|
||||
expect(stats.enhancedMetadataCache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should clear all caches when no file path provided", async () => {
|
||||
const filePath1 = "test1.md";
|
||||
const filePath2 = "test2.md";
|
||||
const mtime = Date.now();
|
||||
|
||||
mockFiles.set(filePath1, createMockFile(filePath1, mtime));
|
||||
mockFiles.set(filePath2, createMockFile(filePath2, mtime));
|
||||
mockMetadata.set(filePath1, { project: "test1" });
|
||||
mockMetadata.set(filePath2, { project: "test2" });
|
||||
|
||||
// Load data into caches
|
||||
projectConfigManager.getFileMetadata(filePath1);
|
||||
projectConfigManager.getFileMetadata(filePath2);
|
||||
|
||||
// Verify data is cached
|
||||
let stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(2);
|
||||
|
||||
// Clear all caches
|
||||
projectConfigManager.clearCache();
|
||||
|
||||
// Verify all caches are cleared
|
||||
stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(0);
|
||||
expect(stats.enhancedMetadataCache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stale cache cleanup", () => {
|
||||
it("should remove stale entries when clearStaleEntries is called", async () => {
|
||||
const filePath = "test.md";
|
||||
const initialMtime = Date.now();
|
||||
const frontmatter = { project: "test" };
|
||||
|
||||
// Setup initial file
|
||||
mockFiles.set(filePath, createMockFile(filePath, initialMtime));
|
||||
mockMetadata.set(filePath, frontmatter);
|
||||
|
||||
// Load into cache
|
||||
projectConfigManager.getFileMetadata(filePath);
|
||||
|
||||
// Verify cache is populated
|
||||
let stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(1);
|
||||
|
||||
// Simulate file deletion by removing from mock vault
|
||||
mockFiles.delete(filePath);
|
||||
|
||||
// Clear stale entries
|
||||
const clearedCount = await projectConfigManager.clearStaleEntries();
|
||||
expect(clearedCount).toBe(1);
|
||||
|
||||
// Verify cache is cleaned
|
||||
stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -50,8 +50,48 @@ jest.mock("obsidian", () => ({
|
|||
Platform: { isPhone: false },
|
||||
MarkdownRenderer: jest.fn(),
|
||||
moment: () => ({ format: jest.fn(() => "2025-01-04") }),
|
||||
EditorSuggest: class {
|
||||
constructor() {}
|
||||
getSuggestions() { return []; }
|
||||
renderSuggestion() {}
|
||||
selectSuggestion() {}
|
||||
onTrigger() { return null; }
|
||||
close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock moment module
|
||||
jest.mock("moment", () => {
|
||||
const moment = function(input?: any) {
|
||||
return {
|
||||
format: () => "2024-01-01",
|
||||
diff: () => 0,
|
||||
startOf: () => moment(input),
|
||||
endOf: () => moment(input),
|
||||
isSame: () => true,
|
||||
isSameOrBefore: () => true,
|
||||
isSameOrAfter: () => true,
|
||||
isBefore: () => false,
|
||||
isAfter: () => false,
|
||||
isBetween: () => true,
|
||||
clone: () => moment(input),
|
||||
add: () => moment(input),
|
||||
subtract: () => moment(input),
|
||||
valueOf: () => Date.now(),
|
||||
toDate: () => new Date(),
|
||||
weekday: () => 0,
|
||||
day: () => 1,
|
||||
date: () => 1,
|
||||
};
|
||||
};
|
||||
moment.locale = jest.fn(() => "en");
|
||||
moment.utc = () => ({ format: () => "00:00:00" });
|
||||
moment.duration = () => ({ asMilliseconds: () => 0 });
|
||||
moment.weekdaysShort = () => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
moment.weekdaysMin = () => ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return moment;
|
||||
});
|
||||
|
||||
jest.mock("../editor-ext/markdownEditor", () => ({
|
||||
createEmbeddableMarkdownEditor: jest.fn(() => ({
|
||||
value: "",
|
||||
|
|
|
|||
302
src/__tests__/SuggestBackwardCompatibility.test.ts
Normal file
302
src/__tests__/SuggestBackwardCompatibility.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { App, Editor, EditorPosition, EditorSuggestContext } from "obsidian";
|
||||
import { MinimalQuickCaptureSuggest } from "../components/MinimalQuickCaptureSuggest";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
|
||||
// Mock Obsidian modules
|
||||
jest.mock("obsidian", () => ({
|
||||
App: jest.fn(),
|
||||
Editor: jest.fn(),
|
||||
EditorPosition: jest.fn(),
|
||||
EditorSuggest: class {
|
||||
constructor() {}
|
||||
getSuggestions() { return []; }
|
||||
renderSuggestion() {}
|
||||
selectSuggestion() {}
|
||||
onTrigger() { return null; }
|
||||
close() {}
|
||||
},
|
||||
setIcon: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock moment module
|
||||
jest.mock("moment", () => {
|
||||
const moment = function(input?: any) {
|
||||
return {
|
||||
format: () => "2024-01-01",
|
||||
diff: () => 0,
|
||||
startOf: () => moment(input),
|
||||
endOf: () => moment(input),
|
||||
isSame: () => true,
|
||||
isSameOrBefore: () => true,
|
||||
isSameOrAfter: () => true,
|
||||
isBefore: () => false,
|
||||
isAfter: () => false,
|
||||
isBetween: () => true,
|
||||
clone: () => moment(input),
|
||||
add: () => moment(input),
|
||||
subtract: () => moment(input),
|
||||
valueOf: () => Date.now(),
|
||||
toDate: () => new Date(),
|
||||
weekday: () => 0,
|
||||
day: () => 1,
|
||||
date: () => 1,
|
||||
};
|
||||
};
|
||||
moment.locale = jest.fn(() => "en");
|
||||
moment.utc = () => ({ format: () => "00:00:00" });
|
||||
moment.duration = () => ({ asMilliseconds: () => 0 });
|
||||
moment.weekdaysShort = () => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
moment.weekdaysMin = () => ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return moment;
|
||||
});
|
||||
|
||||
// Mock plugin
|
||||
const mockPlugin = {
|
||||
app: {
|
||||
workspace: {
|
||||
getLastOpenFiles: () => ["test1.md", "test2.md"],
|
||||
},
|
||||
metadataCache: {
|
||||
getTags: () => ({
|
||||
"#work": 5,
|
||||
"#personal": 3,
|
||||
}),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
quickCapture: {
|
||||
minimalModeSettings: {
|
||||
suggestTrigger: "/",
|
||||
},
|
||||
},
|
||||
preferMetadataFormat: "tasks",
|
||||
},
|
||||
} as any as TaskProgressBarPlugin;
|
||||
|
||||
describe("Backward Compatibility Tests", () => {
|
||||
let suggest: MinimalQuickCaptureSuggest;
|
||||
let app: App;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
suggest = new MinimalQuickCaptureSuggest(app, mockPlugin);
|
||||
});
|
||||
|
||||
test("should maintain original MinimalQuickCaptureSuggest interface", () => {
|
||||
// Test that all original methods exist
|
||||
expect(typeof suggest.setMinimalMode).toBe("function");
|
||||
expect(typeof suggest.onTrigger).toBe("function");
|
||||
expect(typeof suggest.getSuggestions).toBe("function");
|
||||
expect(typeof suggest.renderSuggestion).toBe("function");
|
||||
expect(typeof suggest.selectSuggestion).toBe("function");
|
||||
});
|
||||
|
||||
test("should handle legacy @ trigger mapping to * for target", () => {
|
||||
suggest.setMinimalMode(true);
|
||||
|
||||
const mockContext: EditorSuggestContext = {
|
||||
query: "@",
|
||||
start: { line: 0, ch: 0 },
|
||||
end: { line: 0, ch: 1 },
|
||||
editor: {} as Editor,
|
||||
file: {} as any,
|
||||
};
|
||||
|
||||
const suggestions = suggest.getSuggestions(mockContext);
|
||||
|
||||
// Should return target suggestions when @ is used
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions.some(s => s.id.includes("target") || s.replacement === "*")).toBe(true);
|
||||
});
|
||||
|
||||
test("should provide fallback suggestions when new system returns empty", () => {
|
||||
suggest.setMinimalMode(true);
|
||||
|
||||
// Test with an unknown trigger that would return empty from new system
|
||||
const mockContext: EditorSuggestContext = {
|
||||
query: "unknown",
|
||||
start: { line: 0, ch: 0 },
|
||||
end: { line: 0, ch: 1 },
|
||||
editor: {} as Editor,
|
||||
file: {} as any,
|
||||
};
|
||||
|
||||
const suggestions = suggest.getSuggestions(mockContext);
|
||||
|
||||
// Should return fallback suggestions
|
||||
expect(suggestions.length).toBe(4); // date, priority, target, tag
|
||||
expect(suggestions.map(s => s.id)).toEqual(["date", "priority", "target", "tag"]);
|
||||
});
|
||||
|
||||
test("should handle selectSuggestion with both new and legacy actions", () => {
|
||||
const mockEditor = {
|
||||
replaceRange: jest.fn(),
|
||||
setCursor: jest.fn(),
|
||||
} as any as Editor;
|
||||
|
||||
const mockCursor = { line: 0, ch: 1 };
|
||||
|
||||
// Mock the context
|
||||
(suggest as any).context = {
|
||||
editor: mockEditor,
|
||||
end: mockCursor,
|
||||
};
|
||||
|
||||
// Test with new system suggestion (has action)
|
||||
const newSuggestion = {
|
||||
id: "priority-high",
|
||||
label: "High Priority",
|
||||
icon: "arrow-up",
|
||||
description: "High priority task",
|
||||
replacement: "! ⏫",
|
||||
trigger: "!",
|
||||
action: jest.fn(),
|
||||
};
|
||||
|
||||
suggest.selectSuggestion(newSuggestion, {} as MouseEvent);
|
||||
|
||||
expect(mockEditor.replaceRange).toHaveBeenCalledWith("! ⏫", { line: 0, ch: 0 }, { line: 0, ch: 1 });
|
||||
expect(mockEditor.setCursor).toHaveBeenCalledWith({ line: 0, ch: 4 });
|
||||
expect(newSuggestion.action).toHaveBeenCalledWith(mockEditor, { line: 0, ch: 4 });
|
||||
});
|
||||
|
||||
test("should handle legacy modal-based actions", () => {
|
||||
const mockEditor = {
|
||||
replaceRange: jest.fn(),
|
||||
setCursor: jest.fn(),
|
||||
} as any as Editor;
|
||||
|
||||
const mockCursor = { line: 0, ch: 1 };
|
||||
|
||||
// Mock the context
|
||||
(suggest as any).context = {
|
||||
editor: mockEditor,
|
||||
end: mockCursor,
|
||||
};
|
||||
|
||||
// Mock the modal element and modal instance
|
||||
const mockModal = {
|
||||
showDatePickerAtCursor: jest.fn(),
|
||||
showPriorityMenuAtCursor: jest.fn(),
|
||||
showLocationMenuAtCursor: jest.fn(),
|
||||
showTagSelectorAtCursor: jest.fn(),
|
||||
};
|
||||
|
||||
const mockModalEl = {
|
||||
closest: jest.fn().mockReturnValue({
|
||||
__minimalQuickCaptureModal: mockModal,
|
||||
}),
|
||||
};
|
||||
|
||||
const mockEditorEl = {
|
||||
cm: {
|
||||
dom: mockModalEl,
|
||||
},
|
||||
coordsAtPos: jest.fn().mockReturnValue({ left: 100, top: 200 }),
|
||||
};
|
||||
|
||||
(mockEditor as any).cm = { dom: mockModalEl };
|
||||
(mockEditor as any).coordsAtPos = mockEditorEl.coordsAtPos;
|
||||
|
||||
// Test legacy date suggestion
|
||||
const dateSuggestion = {
|
||||
id: "date",
|
||||
label: "Date",
|
||||
icon: "calendar",
|
||||
description: "Add date",
|
||||
replacement: "~",
|
||||
};
|
||||
|
||||
suggest.selectSuggestion(dateSuggestion, {} as MouseEvent);
|
||||
|
||||
expect(mockEditor.replaceRange).toHaveBeenCalledWith("~", { line: 0, ch: 0 }, { line: 0, ch: 1 });
|
||||
expect(mockModal.showDatePickerAtCursor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should maintain original trigger character behavior", () => {
|
||||
const mockEditor = {
|
||||
getLine: jest.fn().mockReturnValue("test /"),
|
||||
} as any as Editor;
|
||||
|
||||
const mockFile = {} as any;
|
||||
const mockCursor = { line: 0, ch: 6 };
|
||||
|
||||
// Mock minimal mode context
|
||||
(mockEditor as any).cm = {
|
||||
dom: {
|
||||
closest: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
suggest.setMinimalMode(true);
|
||||
|
||||
const triggerInfo = suggest.onTrigger(mockCursor, mockEditor, mockFile);
|
||||
|
||||
expect(triggerInfo).toEqual({
|
||||
start: { line: 0, ch: 5 },
|
||||
end: { line: 0, ch: 6 },
|
||||
query: "/",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle disabled state correctly", () => {
|
||||
const mockEditor = {} as Editor;
|
||||
const mockFile = {} as any;
|
||||
const mockCursor = { line: 0, ch: 1 };
|
||||
|
||||
// When not in minimal mode, should return null
|
||||
suggest.setMinimalMode(false);
|
||||
const triggerInfo = suggest.onTrigger(mockCursor, mockEditor, mockFile);
|
||||
|
||||
expect(triggerInfo).toBeNull();
|
||||
});
|
||||
|
||||
test("should render suggestions with correct DOM structure", () => {
|
||||
const mockEl = {
|
||||
addClass: jest.fn(),
|
||||
createDiv: jest.fn().mockReturnValue({
|
||||
createDiv: jest.fn(),
|
||||
}),
|
||||
} as any as HTMLElement;
|
||||
|
||||
const suggestion = {
|
||||
id: "test",
|
||||
label: "Test Label",
|
||||
icon: "star",
|
||||
description: "Test Description",
|
||||
replacement: "test",
|
||||
};
|
||||
|
||||
suggest.renderSuggestion(suggestion, mockEl);
|
||||
|
||||
expect(mockEl.addClass).toHaveBeenCalledWith("menu-item");
|
||||
expect(mockEl.addClass).toHaveBeenCalledWith("tappable");
|
||||
expect(mockEl.createDiv).toHaveBeenCalledWith("menu-item-icon");
|
||||
});
|
||||
|
||||
test("should integrate with new suggest system while maintaining compatibility", () => {
|
||||
suggest.setMinimalMode(true);
|
||||
|
||||
// Test all original trigger characters
|
||||
const triggerChars = ["!", "~", "#"];
|
||||
|
||||
for (const trigger of triggerChars) {
|
||||
const mockContext: EditorSuggestContext = {
|
||||
query: trigger,
|
||||
start: { line: 0, ch: 0 },
|
||||
end: { line: 0, ch: 1 },
|
||||
editor: {} as Editor,
|
||||
file: {} as any,
|
||||
};
|
||||
|
||||
const suggestions = suggest.getSuggestions(mockContext);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have suggestions that match the trigger
|
||||
const hasMatchingSuggestion = suggestions.some(s =>
|
||||
s.replacement.includes(trigger) || s.trigger === trigger
|
||||
);
|
||||
expect(hasMatchingSuggestion).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
280
src/__tests__/SuggestPerformance.test.ts
Normal file
280
src/__tests__/SuggestPerformance.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import { App, Editor, TFile } from "obsidian";
|
||||
import { SuggestManager, UniversalEditorSuggest } from "../components/suggest";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { getSuggestOptionsByTrigger } from "../components/suggest/SpecialCharacterSuggests";
|
||||
|
||||
// Mock Obsidian modules
|
||||
jest.mock("obsidian", () => ({
|
||||
App: jest.fn(),
|
||||
Editor: jest.fn(),
|
||||
TFile: jest.fn(),
|
||||
EditorSuggest: class {
|
||||
constructor() {}
|
||||
getSuggestions() { return []; }
|
||||
renderSuggestion() {}
|
||||
selectSuggestion() {}
|
||||
onTrigger() { return null; }
|
||||
close() {}
|
||||
},
|
||||
setIcon: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock moment module
|
||||
jest.mock("moment", () => {
|
||||
const moment = function(input?: any) {
|
||||
return {
|
||||
format: () => "2024-01-01",
|
||||
diff: () => 0,
|
||||
startOf: () => moment(input),
|
||||
endOf: () => moment(input),
|
||||
isSame: () => true,
|
||||
isSameOrBefore: () => true,
|
||||
isSameOrAfter: () => true,
|
||||
isBefore: () => false,
|
||||
isAfter: () => false,
|
||||
isBetween: () => true,
|
||||
clone: () => moment(input),
|
||||
add: () => moment(input),
|
||||
subtract: () => moment(input),
|
||||
valueOf: () => Date.now(),
|
||||
toDate: () => new Date(),
|
||||
weekday: () => 0,
|
||||
day: () => 1,
|
||||
date: () => 1,
|
||||
};
|
||||
};
|
||||
moment.locale = jest.fn(() => "en");
|
||||
moment.utc = () => ({ format: () => "00:00:00" });
|
||||
moment.duration = () => ({ asMilliseconds: () => 0 });
|
||||
moment.weekdaysShort = () => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
moment.weekdaysMin = () => ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return moment;
|
||||
});
|
||||
|
||||
// Mock plugin with realistic data
|
||||
const mockPlugin = {
|
||||
app: {
|
||||
workspace: {
|
||||
getLastOpenFiles: () => [
|
||||
"file1.md",
|
||||
"file2.md",
|
||||
"file3.md",
|
||||
"file4.md",
|
||||
"file5.md",
|
||||
],
|
||||
},
|
||||
metadataCache: {
|
||||
getTags: () => ({
|
||||
"#work": 10,
|
||||
"#personal": 8,
|
||||
"#urgent": 5,
|
||||
"#important": 7,
|
||||
"#project": 12,
|
||||
"#meeting": 3,
|
||||
"#todo": 15,
|
||||
"#review": 4,
|
||||
}),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
preferMetadataFormat: "tasks",
|
||||
},
|
||||
} as any as TaskProgressBarPlugin;
|
||||
|
||||
describe("Suggest Performance Tests", () => {
|
||||
let app: App;
|
||||
let manager: SuggestManager;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
(app as any).workspace = {
|
||||
editorSuggest: {
|
||||
suggests: [],
|
||||
},
|
||||
};
|
||||
manager = new SuggestManager(app, mockPlugin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.cleanup();
|
||||
});
|
||||
|
||||
test("should handle rapid suggest creation and destruction", () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
manager.startManaging();
|
||||
|
||||
// Create and destroy 100 suggests rapidly
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const suggest = manager.createUniversalSuggest(`test-${i}`);
|
||||
suggest.enable();
|
||||
manager.removeManagedSuggest(`universal-test-${i}`);
|
||||
}
|
||||
|
||||
manager.stopManaging();
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time (adjust threshold as needed)
|
||||
expect(duration).toBeLessThan(1000); // 1 second
|
||||
expect(manager.getActiveSuggests().size).toBe(0);
|
||||
});
|
||||
|
||||
test("should efficiently generate suggestions for all trigger characters", () => {
|
||||
const triggerChars = ["!", "~", "*", "#"];
|
||||
const iterations = 1000;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
for (const trigger of triggerChars) {
|
||||
const suggestions = getSuggestOptionsByTrigger(trigger, mockPlugin);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should generate suggestions efficiently
|
||||
expect(duration).toBeLessThan(500); // 500ms for 4000 operations
|
||||
|
||||
console.log(`Generated ${iterations * triggerChars.length} suggestions in ${duration}ms`);
|
||||
});
|
||||
|
||||
test("should handle large number of active suggests", () => {
|
||||
manager.startManaging();
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Create 50 active suggests
|
||||
const suggests: UniversalEditorSuggest[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const suggest = manager.createUniversalSuggest(`bulk-test-${i}`);
|
||||
suggest.enable();
|
||||
suggests.push(suggest);
|
||||
}
|
||||
|
||||
expect(manager.getActiveSuggests().size).toBe(50);
|
||||
|
||||
// Cleanup all at once
|
||||
manager.removeAllManagedSuggests();
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(100); // Should be very fast
|
||||
expect(manager.getActiveSuggests().size).toBe(0);
|
||||
|
||||
console.log(`Managed 50 suggests in ${duration}ms`);
|
||||
});
|
||||
|
||||
test("should efficiently handle context filtering", () => {
|
||||
const mockEditor = {} as Editor;
|
||||
const mockFile = {} as TFile;
|
||||
|
||||
// Create context filters
|
||||
const filters = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const filter = (editor: Editor, file: TFile) => {
|
||||
// Simulate some filtering logic
|
||||
return editor === mockEditor && file === mockFile;
|
||||
};
|
||||
manager.addContextFilter(`filter-${i}`, filter);
|
||||
filters.push(filter);
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Test context filtering performance
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const suggest = manager.createUniversalSuggest(`context-test-${i % 10}`, {
|
||||
contextFilter: filters[i % filters.length],
|
||||
});
|
||||
// Simulate context check
|
||||
const config = suggest.getConfig();
|
||||
if (config.contextFilter) {
|
||||
config.contextFilter(mockEditor, mockFile);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(200); // Should be reasonably fast
|
||||
|
||||
console.log(`Context filtering test completed in ${duration}ms`);
|
||||
|
||||
// Cleanup
|
||||
for (let i = 0; i < 100; i++) {
|
||||
manager.removeContextFilter(`filter-${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle memory efficiently during suggest lifecycle", () => {
|
||||
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0;
|
||||
|
||||
manager.startManaging();
|
||||
|
||||
// Create and destroy suggests in cycles
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
// Create 20 suggests
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const suggest = manager.createUniversalSuggest(`memory-test-${cycle}-${i}`);
|
||||
suggest.enable();
|
||||
}
|
||||
|
||||
// Remove all suggests
|
||||
manager.removeAllManagedSuggests();
|
||||
}
|
||||
|
||||
manager.stopManaging();
|
||||
|
||||
// Force garbage collection if available
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0;
|
||||
const memoryDiff = finalMemory - initialMemory;
|
||||
|
||||
// Memory usage should not grow significantly
|
||||
// This is a rough check - actual values depend on environment
|
||||
if (initialMemory > 0) {
|
||||
expect(memoryDiff).toBeLessThan(1024 * 1024); // Less than 1MB growth
|
||||
console.log(`Memory difference: ${memoryDiff} bytes`);
|
||||
}
|
||||
});
|
||||
|
||||
test("should maintain performance with workspace suggest array manipulation", () => {
|
||||
const mockSuggests = Array.from({ length: 100 }, (_, i) => ({ id: `mock-${i}` }));
|
||||
(app as any).workspace.editorSuggest.suggests = [...mockSuggests];
|
||||
|
||||
manager.startManaging();
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Add suggests to beginning of array (high priority)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const suggest = manager.createUniversalSuggest(`priority-test-${i}`);
|
||||
suggest.enable();
|
||||
}
|
||||
|
||||
// Verify they were added to the beginning
|
||||
const workspaceSuggests = (app as any).workspace.editorSuggest.suggests;
|
||||
expect(workspaceSuggests.length).toBe(150); // 100 original + 50 new
|
||||
|
||||
// Remove all managed suggests
|
||||
manager.removeAllManagedSuggests();
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should handle array manipulation efficiently
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(workspaceSuggests.length).toBe(100); // Back to original
|
||||
|
||||
console.log(`Array manipulation completed in ${duration}ms`);
|
||||
});
|
||||
});
|
||||
172
src/__tests__/TaskIndexer.mtime.test.ts
Normal file
172
src/__tests__/TaskIndexer.mtime.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Tests for TaskIndexer mtime-based caching functionality
|
||||
*/
|
||||
|
||||
import { TaskIndexer } from "../utils/import/TaskIndexer";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
// Mock obsidian Component class
|
||||
jest.mock("obsidian", () => ({
|
||||
...jest.requireActual("obsidian"),
|
||||
Component: class {
|
||||
registerEvent = jest.fn();
|
||||
unload = jest.fn();
|
||||
},
|
||||
TFile: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const mockApp = {} as any;
|
||||
const mockVault = {
|
||||
on: jest.fn().mockReturnValue({}),
|
||||
off: jest.fn(),
|
||||
} as any;
|
||||
const mockMetadataCache = {} as any;
|
||||
|
||||
describe("TaskIndexer mtime functionality", () => {
|
||||
let indexer: TaskIndexer;
|
||||
|
||||
beforeEach(() => {
|
||||
indexer = new TaskIndexer(mockApp, mockVault, mockMetadataCache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (indexer && typeof indexer.unload === 'function') {
|
||||
indexer.unload();
|
||||
}
|
||||
});
|
||||
|
||||
describe("mtime comparison", () => {
|
||||
test("should detect file changes when mtime is newer", () => {
|
||||
const filePath = "test.md";
|
||||
const oldMtime = 1000;
|
||||
const newMtime = 2000;
|
||||
|
||||
// Set initial mtime
|
||||
indexer.updateFileMtime(filePath, oldMtime);
|
||||
|
||||
// Check if file is changed with newer mtime
|
||||
expect(indexer.isFileChanged(filePath, newMtime)).toBe(true);
|
||||
});
|
||||
|
||||
test("should not detect changes when mtime is same", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = 1000;
|
||||
|
||||
// Set initial mtime
|
||||
indexer.updateFileMtime(filePath, mtime);
|
||||
|
||||
// Check if file is changed with same mtime
|
||||
expect(indexer.isFileChanged(filePath, mtime)).toBe(false);
|
||||
});
|
||||
|
||||
test("should detect changes for unknown files", () => {
|
||||
const filePath = "unknown.md";
|
||||
const mtime = 1000;
|
||||
|
||||
// Check if unknown file is considered changed
|
||||
expect(indexer.isFileChanged(filePath, mtime)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache validation", () => {
|
||||
test("should have valid cache when file hasn't changed and has tasks", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = 1000;
|
||||
const tasks: Task[] = [
|
||||
{
|
||||
id: "task1",
|
||||
content: "Test task",
|
||||
filePath,
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
project: undefined,
|
||||
context: undefined,
|
||||
priority: undefined,
|
||||
dueDate: undefined,
|
||||
startDate: undefined,
|
||||
scheduledDate: undefined,
|
||||
completedDate: undefined,
|
||||
cancelledDate: undefined,
|
||||
createdDate: undefined,
|
||||
recurrence: undefined,
|
||||
dependsOn: [],
|
||||
onCompletion: undefined,
|
||||
taskId: undefined,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add tasks and set mtime
|
||||
indexer.updateIndexWithTasks(filePath, tasks, mtime);
|
||||
|
||||
// Check if cache is valid
|
||||
expect(indexer.hasValidCache(filePath, mtime)).toBe(true);
|
||||
});
|
||||
|
||||
test("should not have valid cache when file has changed", () => {
|
||||
const filePath = "test.md";
|
||||
const oldMtime = 1000;
|
||||
const newMtime = 2000;
|
||||
const tasks: Task[] = [];
|
||||
|
||||
// Add tasks with old mtime
|
||||
indexer.updateIndexWithTasks(filePath, tasks, oldMtime);
|
||||
|
||||
// Check if cache is invalid with new mtime
|
||||
expect(indexer.hasValidCache(filePath, newMtime)).toBe(false);
|
||||
});
|
||||
|
||||
test("should not have valid cache when no tasks exist", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = 1000;
|
||||
|
||||
// Don't add any tasks, just set mtime
|
||||
indexer.updateFileMtime(filePath, mtime);
|
||||
|
||||
// Check if cache is invalid when no tasks exist
|
||||
expect(indexer.hasValidCache(filePath, mtime)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache cleanup", () => {
|
||||
test("should clean up file cache properly", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = 1000;
|
||||
const tasks: Task[] = [];
|
||||
|
||||
// Add tasks and set mtime
|
||||
indexer.updateIndexWithTasks(filePath, tasks, mtime);
|
||||
|
||||
// Verify cache exists
|
||||
expect(indexer.getFileLastMtime(filePath)).toBe(mtime);
|
||||
|
||||
// Clean up cache
|
||||
indexer.cleanupFileCache(filePath);
|
||||
|
||||
// Verify cache is cleaned
|
||||
expect(indexer.getFileLastMtime(filePath)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache consistency", () => {
|
||||
test("should validate and fix cache consistency", () => {
|
||||
const filePath = "test.md";
|
||||
const mtime = 1000;
|
||||
|
||||
// Manually add mtime without tasks (inconsistent state)
|
||||
indexer.updateFileMtime(filePath, mtime);
|
||||
|
||||
// Validate consistency (should clean up orphaned mtime)
|
||||
indexer.validateCacheConsistency();
|
||||
|
||||
// Verify orphaned mtime is cleaned up
|
||||
expect(indexer.getFileLastMtime(filePath)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
269
src/__tests__/TaskWorkerManager.mtime.test.ts
Normal file
269
src/__tests__/TaskWorkerManager.mtime.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Integration tests for TaskWorkerManager mtime optimization
|
||||
*/
|
||||
|
||||
import { TaskWorkerManager } from "../utils/workers/TaskWorkerManager";
|
||||
import { TaskIndexer } from "../utils/import/TaskIndexer";
|
||||
import { TFile } from "obsidian";
|
||||
|
||||
// Mock dependencies
|
||||
const mockVault = {
|
||||
cachedRead: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockMetadataCache = {
|
||||
getFileCache: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockApp = {} as any;
|
||||
|
||||
// Mock TFile
|
||||
const createMockFile = (path: string, mtime: number): TFile => ({
|
||||
path,
|
||||
stat: { mtime, ctime: mtime, size: 100 },
|
||||
extension: "md",
|
||||
name: path.split("/").pop() || path,
|
||||
basename: path.split("/").pop()?.replace(".md", "") || path,
|
||||
} as any);
|
||||
|
||||
describe("TaskWorkerManager mtime optimization", () => {
|
||||
let workerManager: TaskWorkerManager;
|
||||
let indexer: TaskIndexer;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock vault.cachedRead to return empty content
|
||||
mockVault.cachedRead.mockResolvedValue("");
|
||||
mockMetadataCache.getFileCache.mockReturnValue(null);
|
||||
|
||||
try {
|
||||
// Create indexer
|
||||
indexer = new TaskIndexer(mockApp, mockVault, mockMetadataCache);
|
||||
|
||||
// Create worker manager with mtime optimization enabled
|
||||
workerManager = new TaskWorkerManager(mockVault, mockMetadataCache, {
|
||||
maxWorkers: 1,
|
||||
settings: {
|
||||
fileParsingConfig: {
|
||||
enableMtimeOptimization: true,
|
||||
mtimeCacheSize: 1000,
|
||||
enableFileMetadataParsing: false,
|
||||
metadataFieldsToParseAsTasks: [],
|
||||
enableTagBasedTaskParsing: false,
|
||||
tagsToParseAsTasks: [],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
},
|
||||
preferMetadataFormat: "tasks",
|
||||
useDailyNotePathAsDate: false,
|
||||
dailyNoteFormat: "yyyy-MM-dd",
|
||||
useAsDateType: "due",
|
||||
dailyNotePath: "",
|
||||
ignoreHeading: "",
|
||||
focusHeading: "",
|
||||
fileMetadataInheritance: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Set indexer reference
|
||||
if (workerManager && indexer) {
|
||||
workerManager.setTaskIndexer(indexer);
|
||||
}
|
||||
} catch (error) {
|
||||
// Create stub objects if initialization fails
|
||||
indexer = { unload: jest.fn() } as any;
|
||||
workerManager = { unload: jest.fn(), setTaskIndexer: jest.fn() } as any;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (workerManager && typeof workerManager.unload === 'function') {
|
||||
workerManager.unload();
|
||||
}
|
||||
if (indexer && typeof indexer.unload === 'function') {
|
||||
indexer.unload();
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cache optimization", () => {
|
||||
test("should skip processing files with valid cache", async () => {
|
||||
const file = createMockFile("test.md", 1000);
|
||||
const tasks = [
|
||||
{
|
||||
id: "task1",
|
||||
content: "Test task",
|
||||
filePath: file.path,
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
project: undefined,
|
||||
context: undefined,
|
||||
priority: undefined,
|
||||
dueDate: undefined,
|
||||
startDate: undefined,
|
||||
scheduledDate: undefined,
|
||||
completedDate: undefined,
|
||||
cancelledDate: undefined,
|
||||
createdDate: undefined,
|
||||
recurrence: undefined,
|
||||
dependsOn: [],
|
||||
onCompletion: undefined,
|
||||
taskId: undefined,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Pre-populate cache
|
||||
indexer.updateIndexWithTasks(file.path, tasks, file.stat.mtime);
|
||||
|
||||
// Process file - should use cache
|
||||
const result = await workerManager.processFile(file);
|
||||
|
||||
// Should return cached tasks without calling vault.cachedRead
|
||||
expect(result).toEqual(tasks);
|
||||
expect(mockVault.cachedRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should process files when cache is invalid", async () => {
|
||||
const file = createMockFile("test.md", 2000);
|
||||
const oldTasks = [
|
||||
{
|
||||
id: "task1",
|
||||
content: "Old task",
|
||||
filePath: file.path,
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Old task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
project: undefined,
|
||||
context: undefined,
|
||||
priority: undefined,
|
||||
dueDate: undefined,
|
||||
startDate: undefined,
|
||||
scheduledDate: undefined,
|
||||
completedDate: undefined,
|
||||
cancelledDate: undefined,
|
||||
createdDate: undefined,
|
||||
recurrence: undefined,
|
||||
dependsOn: [],
|
||||
onCompletion: undefined,
|
||||
taskId: undefined,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Pre-populate cache with older mtime
|
||||
indexer.updateIndexWithTasks(file.path, oldTasks, 1000);
|
||||
|
||||
// Mock worker processing (since we can't easily test actual worker)
|
||||
// This would normally go through the worker, but for testing we'll simulate
|
||||
// the file being processed
|
||||
expect(indexer.hasValidCache(file.path, file.stat.mtime)).toBe(false);
|
||||
});
|
||||
|
||||
test("should optimize batch processing", async () => {
|
||||
const files = [
|
||||
createMockFile("cached1.md", 1000),
|
||||
createMockFile("cached2.md", 1000),
|
||||
createMockFile("new.md", 2000),
|
||||
];
|
||||
|
||||
const cachedTasks = [
|
||||
{
|
||||
id: "cached-task",
|
||||
content: "Cached task",
|
||||
filePath: "cached1.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Cached task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
project: undefined,
|
||||
context: undefined,
|
||||
priority: undefined,
|
||||
dueDate: undefined,
|
||||
startDate: undefined,
|
||||
scheduledDate: undefined,
|
||||
completedDate: undefined,
|
||||
cancelledDate: undefined,
|
||||
createdDate: undefined,
|
||||
recurrence: undefined,
|
||||
dependsOn: [],
|
||||
onCompletion: undefined,
|
||||
taskId: undefined,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Pre-populate cache for first two files
|
||||
indexer.updateIndexWithTasks("cached1.md", cachedTasks, 1000);
|
||||
indexer.updateIndexWithTasks("cached2.md", cachedTasks, 1000);
|
||||
|
||||
// Process batch
|
||||
const result = await workerManager.processBatch(files);
|
||||
|
||||
// Should have results for cached files
|
||||
expect(result.has("cached1.md")).toBe(true);
|
||||
expect(result.has("cached2.md")).toBe(true);
|
||||
expect(result.get("cached1.md")).toEqual(cachedTasks);
|
||||
expect(result.get("cached2.md")).toEqual(cachedTasks);
|
||||
|
||||
// Check statistics
|
||||
const stats = workerManager.getStats();
|
||||
expect(stats.filesSkipped).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration", () => {
|
||||
test("should respect mtime optimization setting", () => {
|
||||
// Create worker manager with optimization disabled
|
||||
const workerManagerDisabled = new TaskWorkerManager(mockVault, mockMetadataCache, {
|
||||
settings: {
|
||||
fileParsingConfig: {
|
||||
enableMtimeOptimization: false,
|
||||
mtimeCacheSize: 1000,
|
||||
enableFileMetadataParsing: false,
|
||||
metadataFieldsToParseAsTasks: [],
|
||||
enableTagBasedTaskParsing: false,
|
||||
tagsToParseAsTasks: [],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
},
|
||||
preferMetadataFormat: "tasks",
|
||||
useDailyNotePathAsDate: false,
|
||||
dailyNoteFormat: "yyyy-MM-dd",
|
||||
useAsDateType: "due",
|
||||
dailyNotePath: "",
|
||||
ignoreHeading: "",
|
||||
focusHeading: "",
|
||||
fileMetadataInheritance: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
workerManagerDisabled.setTaskIndexer(indexer);
|
||||
|
||||
const file = createMockFile("test.md", 1000);
|
||||
|
||||
// Pre-populate cache
|
||||
indexer.updateIndexWithTasks(file.path, [], 1000);
|
||||
|
||||
// Should always process when optimization is disabled
|
||||
// (We can't easily test the private shouldProcessFile method,
|
||||
// but this demonstrates the configuration is respected)
|
||||
expect(indexer.hasValidCache(file.path, file.stat.mtime)).toBe(false); // No tasks in cache
|
||||
|
||||
workerManagerDisabled.unload();
|
||||
});
|
||||
});
|
||||
});
|
||||
255
src/__tests__/UniversalSuggest.test.ts
Normal file
255
src/__tests__/UniversalSuggest.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { App, Editor, EditorPosition, TFile } from "obsidian";
|
||||
import { UniversalEditorSuggest, SuggestManager } from "../components/suggest";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { getSuggestOptionsByTrigger } from "../components/suggest/SpecialCharacterSuggests";
|
||||
|
||||
// Mock Obsidian modules
|
||||
jest.mock("obsidian", () => ({
|
||||
App: jest.fn(),
|
||||
Editor: jest.fn(),
|
||||
EditorPosition: jest.fn(),
|
||||
TFile: jest.fn(),
|
||||
EditorSuggest: class {
|
||||
constructor() {}
|
||||
getSuggestions() { return []; }
|
||||
renderSuggestion() {}
|
||||
selectSuggestion() {}
|
||||
onTrigger() { return null; }
|
||||
close() {}
|
||||
},
|
||||
setIcon: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock moment module
|
||||
jest.mock("moment", () => {
|
||||
const moment = function(input?: any) {
|
||||
return {
|
||||
format: () => "2024-01-01",
|
||||
diff: () => 0,
|
||||
startOf: () => moment(input),
|
||||
endOf: () => moment(input),
|
||||
isSame: () => true,
|
||||
isSameOrBefore: () => true,
|
||||
isSameOrAfter: () => true,
|
||||
isBefore: () => false,
|
||||
isAfter: () => false,
|
||||
isBetween: () => true,
|
||||
clone: () => moment(input),
|
||||
add: () => moment(input),
|
||||
subtract: () => moment(input),
|
||||
valueOf: () => Date.now(),
|
||||
toDate: () => new Date(),
|
||||
weekday: () => 0,
|
||||
day: () => 1,
|
||||
date: () => 1,
|
||||
};
|
||||
};
|
||||
moment.locale = jest.fn(() => "en");
|
||||
moment.utc = () => ({ format: () => "00:00:00" });
|
||||
moment.duration = () => ({ asMilliseconds: () => 0 });
|
||||
moment.weekdaysShort = () => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
moment.weekdaysMin = () => ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return moment;
|
||||
});
|
||||
|
||||
// Mock plugin
|
||||
const mockPlugin = {
|
||||
app: {
|
||||
workspace: {
|
||||
getLastOpenFiles: () => ["file1.md", "file2.md", "file3.md"],
|
||||
},
|
||||
metadataCache: {
|
||||
getTags: () => ({
|
||||
"#tag1": 5,
|
||||
"#tag2": 3,
|
||||
"#重要": 2,
|
||||
}),
|
||||
},
|
||||
} as any,
|
||||
settings: {
|
||||
preferMetadataFormat: "tasks",
|
||||
},
|
||||
} as TaskProgressBarPlugin;
|
||||
|
||||
describe("UniversalEditorSuggest", () => {
|
||||
let suggest: UniversalEditorSuggest;
|
||||
let app: App;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
suggest = new UniversalEditorSuggest(app, mockPlugin, {
|
||||
triggerChars: ["!", "~", "*", "#"],
|
||||
});
|
||||
});
|
||||
|
||||
test("should initialize with correct trigger characters", () => {
|
||||
const config = suggest.getConfig();
|
||||
expect(config.triggerChars).toEqual(["!", "~", "*", "#"]);
|
||||
});
|
||||
|
||||
test("should enable and disable correctly", () => {
|
||||
suggest.enable();
|
||||
expect(suggest["isEnabled"]).toBe(true);
|
||||
|
||||
suggest.disable();
|
||||
expect(suggest["isEnabled"]).toBe(false);
|
||||
});
|
||||
|
||||
test("should add and remove suggest options", () => {
|
||||
const customOption = {
|
||||
id: "custom",
|
||||
label: "Custom",
|
||||
icon: "star",
|
||||
description: "Custom option",
|
||||
replacement: "%",
|
||||
trigger: "%",
|
||||
};
|
||||
|
||||
suggest.addSuggestOption(customOption);
|
||||
const config = suggest.getConfig();
|
||||
expect(config.triggerChars).toContain("%");
|
||||
|
||||
suggest.removeSuggestOption("custom");
|
||||
// Note: This test would need access to internal suggestOptions to verify removal
|
||||
});
|
||||
});
|
||||
|
||||
describe("SuggestManager", () => {
|
||||
let manager: SuggestManager;
|
||||
let app: App;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
// Mock the workspace.editorSuggest.suggests array
|
||||
(app as any).workspace = {
|
||||
editorSuggest: {
|
||||
suggests: [],
|
||||
},
|
||||
};
|
||||
manager = new SuggestManager(app, mockPlugin);
|
||||
});
|
||||
|
||||
test("should start and stop managing correctly", () => {
|
||||
expect(manager.isCurrentlyManaging()).toBe(false);
|
||||
|
||||
manager.startManaging();
|
||||
expect(manager.isCurrentlyManaging()).toBe(true);
|
||||
|
||||
manager.stopManaging();
|
||||
expect(manager.isCurrentlyManaging()).toBe(false);
|
||||
});
|
||||
|
||||
test("should add suggests with priority", () => {
|
||||
const mockSuggest = {} as any;
|
||||
manager.startManaging();
|
||||
|
||||
manager.addSuggestWithPriority(mockSuggest, "test");
|
||||
|
||||
const activeSuggests = manager.getActiveSuggests();
|
||||
expect(activeSuggests.has("test")).toBe(true);
|
||||
expect(activeSuggests.get("test")).toBe(mockSuggest);
|
||||
});
|
||||
|
||||
test("should remove managed suggests", () => {
|
||||
const mockSuggest = {} as any;
|
||||
manager.startManaging();
|
||||
|
||||
manager.addSuggestWithPriority(mockSuggest, "test");
|
||||
expect(manager.getActiveSuggests().has("test")).toBe(true);
|
||||
|
||||
manager.removeManagedSuggest("test");
|
||||
expect(manager.getActiveSuggests().has("test")).toBe(false);
|
||||
});
|
||||
|
||||
test("should cleanup properly", () => {
|
||||
manager.startManaging();
|
||||
manager.addSuggestWithPriority({} as any, "test1");
|
||||
manager.addSuggestWithPriority({} as any, "test2");
|
||||
|
||||
expect(manager.getActiveSuggests().size).toBe(2);
|
||||
|
||||
manager.cleanup();
|
||||
expect(manager.isCurrentlyManaging()).toBe(false);
|
||||
expect(manager.getActiveSuggests().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpecialCharacterSuggests", () => {
|
||||
test("should return priority suggestions for ! trigger", () => {
|
||||
const suggestions = getSuggestOptionsByTrigger("!", mockPlugin);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].trigger).toBe("!");
|
||||
expect(suggestions.some(s => s.id.includes("priority"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should return date suggestions for ~ trigger", () => {
|
||||
const suggestions = getSuggestOptionsByTrigger("~", mockPlugin);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].trigger).toBe("~");
|
||||
expect(suggestions.some(s => s.id.includes("date"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should return target suggestions for * trigger", () => {
|
||||
const suggestions = getSuggestOptionsByTrigger("*", mockPlugin);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].trigger).toBe("*");
|
||||
expect(suggestions.some(s => s.id.includes("target"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should return tag suggestions for # trigger", () => {
|
||||
const suggestions = getSuggestOptionsByTrigger("#", mockPlugin);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].trigger).toBe("#");
|
||||
expect(suggestions.some(s => s.id.includes("tag"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should return empty array for unknown trigger", () => {
|
||||
const suggestions = getSuggestOptionsByTrigger("?", mockPlugin);
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration Tests", () => {
|
||||
test("should create universal suggest for minimal modal context", () => {
|
||||
const app = new App();
|
||||
(app as any).workspace = {
|
||||
editorSuggest: {
|
||||
suggests: [],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new SuggestManager(app, mockPlugin);
|
||||
manager.startManaging();
|
||||
|
||||
const mockEditor = {} as Editor;
|
||||
const suggest = manager.enableForMinimalModal(mockEditor);
|
||||
|
||||
expect(suggest).toBeInstanceOf(UniversalEditorSuggest);
|
||||
expect(manager.getActiveSuggests().has("universal-minimal-modal")).toBe(true);
|
||||
|
||||
manager.cleanup();
|
||||
});
|
||||
|
||||
test("should handle context filters correctly", () => {
|
||||
const app = new App();
|
||||
(app as any).workspace = {
|
||||
editorSuggest: {
|
||||
suggests: [],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new SuggestManager(app, mockPlugin);
|
||||
|
||||
// Add custom context filter
|
||||
const testFilter = (editor: Editor, file: TFile) => true;
|
||||
manager.addContextFilter("test", testFilter);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.contextFilters["test"]).toBe(testFilter);
|
||||
|
||||
// Remove context filter
|
||||
manager.removeContextFilter("test");
|
||||
const updatedConfig = manager.getConfig();
|
||||
expect(updatedConfig.contextFilters["test"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -97,7 +97,7 @@ describe("Chinese Tag Parsing Performance", () => {
|
|||
console.log(
|
||||
`Parsed 500 mixed Chinese/English tags in ${parseTime.toFixed(2)}ms`
|
||||
);
|
||||
expect(parseTime).toBeLessThan(50);
|
||||
expect(parseTime).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test("should handle deeply nested Chinese tags efficiently", () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
const tasksPluginLoaded = false; // Assume false for simpler tests unless specifically testing Tasks interaction
|
||||
|
||||
it("should return empty if no task-related change occurred", () => {
|
||||
const mockPlugin = createMockPlugin();
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "Some text",
|
||||
newDocContent: "Some other text",
|
||||
|
|
@ -40,10 +41,11 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded)).toEqual([]);
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should detect a status change from [ ] to [x] via single char insert", () => {
|
||||
const mockPlugin = createMockPlugin();
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [ ] Task 1",
|
||||
newDocContent: "- [x] Task 1",
|
||||
|
|
@ -51,7 +53,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
{ fromA: 3, toA: 3, fromB: 3, toB: 4, insertedText: "x" },
|
||||
], // Insert 'x' at position 3
|
||||
});
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded);
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].position).toBe(3);
|
||||
expect(changes[0].currentMark).toBe(" "); // Mark *before* the change
|
||||
|
|
@ -60,6 +62,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
});
|
||||
|
||||
it("should detect a status change from [x] to [ ] via single char insert", () => {
|
||||
const mockPlugin = createMockPlugin();
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task 1",
|
||||
newDocContent: "- [ ] Task 1",
|
||||
|
|
@ -67,7 +70,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
{ fromA: 3, toA: 3, fromB: 3, toB: 4, insertedText: " " },
|
||||
], // Insert ' ' at position 3
|
||||
});
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded);
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].position).toBe(3);
|
||||
expect(changes[0].currentMark).toBe("x");
|
||||
|
|
@ -76,6 +79,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
});
|
||||
|
||||
it("should detect a status change from [ ] to [/] via replacing space", () => {
|
||||
const mockPlugin = createMockPlugin();
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: " - [ ] Task 1",
|
||||
newDocContent: " - [/] Task 1",
|
||||
|
|
@ -83,7 +87,7 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
{ fromA: 5, toA: 6, fromB: 5, toB: 6, insertedText: "/" },
|
||||
], // Replace ' ' with '/'
|
||||
});
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded);
|
||||
const changes = findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].position).toBe(5); // Position where change happens
|
||||
expect(changes[0].currentMark).toBe(" ");
|
||||
|
|
@ -109,7 +113,8 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
// The current implementation might return empty or behave unexpectedly.
|
||||
// Let's assume it returns empty based on current logic needing `match` on originalLine.
|
||||
// If needed, `handleCycleCompleteStatusTransaction` might need adjustment or `findTaskStatusChanges` refined.
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded)).toEqual([]);
|
||||
const mockPlugin = createMockPlugin();
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should NOT detect change when only text after marker changes", () => {
|
||||
|
|
@ -126,7 +131,8 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded)).toEqual([]);
|
||||
const mockPlugin = createMockPlugin();
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should NOT detect change when inserting text before the task marker", () => {
|
||||
|
|
@ -143,7 +149,8 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded)).toEqual([]);
|
||||
const mockPlugin = createMockPlugin();
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for multi-line indentation changes", () => {
|
||||
|
|
@ -164,7 +171,8 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
|
||||
// Skip the problematic test - this was causing stack overflow
|
||||
// We expect it to return [] because it should detect multi-line indentation.
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded)).toEqual([]);
|
||||
const mockPlugin = createMockPlugin();
|
||||
expect(findTaskStatusChanges(tr, tasksPluginLoaded, mockPlugin)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should detect pasted task content", () => {
|
||||
|
|
@ -197,7 +205,8 @@ describe("cycleCompleteStatus Helpers", () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
const changes = findTaskStatusChanges(trReplace, tasksPluginLoaded);
|
||||
const mockPlugin = createMockPlugin();
|
||||
const changes = findTaskStatusChanges(trReplace, tasksPluginLoaded, mockPlugin);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].position).toBe(3); // Position of the mark in the new content
|
||||
expect(changes[0].currentMark).toBe(" "); // Mark from the original content before paste
|
||||
|
|
@ -1054,7 +1063,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
});
|
||||
|
||||
// First, let's test what findTaskStatusChanges returns
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
const taskChanges = findTaskStatusChanges(tr, false, mockPlugin);
|
||||
expect(taskChanges).toHaveLength(1);
|
||||
|
||||
// The currentMark should be 'x' (the original mark that was replaced)
|
||||
|
|
@ -1095,7 +1104,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
});
|
||||
|
||||
// Verify that this is detected as a task status change
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
const taskChanges = findTaskStatusChanges(tr, false, mockPlugin);
|
||||
expect(taskChanges).toHaveLength(1);
|
||||
expect(taskChanges[0].currentMark).toBe("x"); // Original mark before replacement
|
||||
expect(taskChanges[0].wasCompleteTask).toBe(true);
|
||||
|
|
@ -1130,7 +1139,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
});
|
||||
|
||||
// Debug: Check what findTaskStatusChanges detects
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
const taskChanges = findTaskStatusChanges(tr, false, mockPlugin);
|
||||
console.log("Debug - taskChanges for space replacement:", taskChanges);
|
||||
|
||||
if (taskChanges.length > 0) {
|
||||
|
|
@ -1182,7 +1191,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const taskChanges1 = findTaskStatusChanges(tr1, false);
|
||||
const taskChanges1 = findTaskStatusChanges(tr1, false, mockPlugin);
|
||||
const result1 = handleCycleCompleteStatusTransaction(
|
||||
tr1,
|
||||
mockApp,
|
||||
|
|
@ -1201,7 +1210,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const taskChanges2 = findTaskStatusChanges(tr2, false);
|
||||
const taskChanges2 = findTaskStatusChanges(tr2, false, mockPlugin);
|
||||
const result2 = handleCycleCompleteStatusTransaction(
|
||||
tr2,
|
||||
mockApp,
|
||||
|
|
@ -1220,7 +1229,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const taskChanges3 = findTaskStatusChanges(tr3, false);
|
||||
const taskChanges3 = findTaskStatusChanges(tr3, false, mockPlugin);
|
||||
const result3 = handleCycleCompleteStatusTransaction(
|
||||
tr3,
|
||||
mockApp,
|
||||
|
|
@ -1239,7 +1248,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const taskChanges4 = findTaskStatusChanges(tr4, false);
|
||||
const taskChanges4 = findTaskStatusChanges(tr4, false, mockPlugin);
|
||||
const result4 = handleCycleCompleteStatusTransaction(
|
||||
tr4,
|
||||
mockApp,
|
||||
|
|
@ -1271,7 +1280,7 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
const taskChanges = findTaskStatusChanges(tr, false, mockPlugin);
|
||||
console.log("Problem case - taskChanges:", taskChanges);
|
||||
|
||||
// The issue: currentMark should be 'x' (original), but
|
||||
|
|
|
|||
235
src/__tests__/forceReindex.integration.test.ts
Normal file
235
src/__tests__/forceReindex.integration.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Integration test for forceReindex cache clearing behavior
|
||||
* This test focuses on testing the cache clearing logic without mocking the full TaskManager
|
||||
*/
|
||||
|
||||
import { TaskParsingService } from "../utils/TaskParsingService";
|
||||
import { ProjectConfigManager } from "../utils/ProjectConfigManager";
|
||||
import { getConfig } from "../common/task-parser-config";
|
||||
|
||||
// Mock Obsidian components
|
||||
const mockVault = {
|
||||
getFileByPath: jest.fn(),
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
read: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockMetadataCache = {
|
||||
getFileCache: jest.fn(),
|
||||
} as any;
|
||||
|
||||
describe("ForceReindex Cache Clearing Integration", () => {
|
||||
let taskParsingService: TaskParsingService;
|
||||
let projectConfigManager: ProjectConfigManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create ProjectConfigManager
|
||||
projectConfigManager = new ProjectConfigManager({
|
||||
vault: mockVault,
|
||||
metadataCache: mockMetadataCache,
|
||||
configFileName: "task-genius.config.md",
|
||||
searchRecursively: true,
|
||||
metadataKey: "project",
|
||||
pathMappings: [],
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
enabled: false,
|
||||
},
|
||||
enhancedProjectEnabled: true,
|
||||
});
|
||||
|
||||
// Create TaskParsingService with proper config
|
||||
const parserConfig = getConfig("tasks");
|
||||
parserConfig.projectConfig = {
|
||||
enableEnhancedProject: true,
|
||||
pathMappings: [],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
enabled: true,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "task-genius.config.md",
|
||||
searchRecursively: true,
|
||||
enabled: true,
|
||||
},
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
taskParsingService = new TaskParsingService({
|
||||
vault: mockVault,
|
||||
metadataCache: mockMetadataCache,
|
||||
parserConfig,
|
||||
projectConfigOptions: {
|
||||
configFileName: "task-genius.config.md",
|
||||
searchRecursively: true,
|
||||
metadataKey: "project",
|
||||
pathMappings: [],
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
enabled: false,
|
||||
},
|
||||
metadataConfigEnabled: true,
|
||||
configFileEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskParsingService.clearAllCaches()", () => {
|
||||
it("should exist and be callable", () => {
|
||||
expect(typeof taskParsingService.clearAllCaches).toBe('function');
|
||||
expect(() => taskParsingService.clearAllCaches()).not.toThrow();
|
||||
});
|
||||
|
||||
it("should clear project config manager caches", () => {
|
||||
const clearCacheSpy = jest.spyOn(projectConfigManager, 'clearCache');
|
||||
|
||||
// Access the private projectConfigManager and spy on it
|
||||
const taskParsingServiceInternal = taskParsingService as any;
|
||||
if (taskParsingServiceInternal.projectConfigManager) {
|
||||
jest.spyOn(taskParsingServiceInternal.projectConfigManager, 'clearCache');
|
||||
}
|
||||
|
||||
taskParsingService.clearAllCaches();
|
||||
|
||||
// The clearAllCaches should call clearCache methods
|
||||
// This verifies the method exists and can be called
|
||||
expect(true).toBe(true); // Basic existence test
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectConfigManager cache methods", () => {
|
||||
it("should have getCacheStats method", () => {
|
||||
expect(typeof projectConfigManager.getCacheStats).toBe('function');
|
||||
|
||||
const stats = projectConfigManager.getCacheStats();
|
||||
expect(stats).toHaveProperty('fileMetadataCache');
|
||||
expect(stats).toHaveProperty('enhancedMetadataCache');
|
||||
expect(stats).toHaveProperty('totalMemoryUsage');
|
||||
});
|
||||
|
||||
it("should have clearStaleEntries method", async () => {
|
||||
expect(typeof projectConfigManager.clearStaleEntries).toBe('function');
|
||||
|
||||
// Mock file system to return no files (so no stale entries to clear)
|
||||
mockVault.getFileByPath.mockReturnValue(null);
|
||||
|
||||
const clearedCount = await projectConfigManager.clearStaleEntries();
|
||||
expect(typeof clearedCount).toBe('number');
|
||||
expect(clearedCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("should clear specific cache types", () => {
|
||||
// Add some mock data to caches first
|
||||
const testPath = "test.md";
|
||||
const mockFile = {
|
||||
path: testPath,
|
||||
stat: { mtime: Date.now() }
|
||||
};
|
||||
|
||||
mockVault.getFileByPath.mockReturnValue(mockFile);
|
||||
mockMetadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: { project: "test" }
|
||||
});
|
||||
|
||||
// Get metadata to populate cache
|
||||
const result = projectConfigManager.getFileMetadata(testPath);
|
||||
expect(result).toEqual({ project: "test" });
|
||||
|
||||
// Check cache is populated
|
||||
let stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(1);
|
||||
|
||||
// Clear cache
|
||||
projectConfigManager.clearCache(testPath);
|
||||
|
||||
// Check cache is cleared
|
||||
stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should clear all caches when no path specified", () => {
|
||||
// Add some mock data
|
||||
const testPath = "test.md";
|
||||
const mockFile = {
|
||||
path: testPath,
|
||||
stat: { mtime: Date.now() }
|
||||
};
|
||||
|
||||
mockVault.getFileByPath.mockReturnValue(mockFile);
|
||||
mockMetadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: { project: "test" }
|
||||
});
|
||||
|
||||
// Populate cache
|
||||
projectConfigManager.getFileMetadata(testPath);
|
||||
|
||||
// Verify cache has data
|
||||
let stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(1);
|
||||
|
||||
// Clear all caches
|
||||
projectConfigManager.clearCache();
|
||||
|
||||
// Verify all caches are cleared
|
||||
stats = projectConfigManager.getCacheStats();
|
||||
expect(stats.fileMetadataCache.size).toBe(0);
|
||||
expect(stats.enhancedMetadataCache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskParsingService detailed cache stats", () => {
|
||||
it("should provide detailed cache statistics", () => {
|
||||
expect(typeof taskParsingService.getDetailedCacheStats).toBe('function');
|
||||
|
||||
const stats = taskParsingService.getDetailedCacheStats();
|
||||
expect(stats).toHaveProperty('summary');
|
||||
expect(stats.summary).toHaveProperty('totalCachedFiles');
|
||||
expect(stats.summary).toHaveProperty('estimatedMemoryUsage');
|
||||
expect(stats.summary).toHaveProperty('cacheTypes');
|
||||
expect(Array.isArray(stats.summary.cacheTypes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache invalidation behavior", () => {
|
||||
it("should invalidate cache when file timestamp changes", () => {
|
||||
const testPath = "test.md";
|
||||
const initialTime = Date.now();
|
||||
const laterTime = initialTime + 1000;
|
||||
|
||||
// Initial file state
|
||||
mockVault.getFileByPath.mockReturnValue({
|
||||
path: testPath,
|
||||
stat: { mtime: initialTime }
|
||||
});
|
||||
mockMetadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: { project: "initial" }
|
||||
});
|
||||
|
||||
// First access - should cache
|
||||
const result1 = projectConfigManager.getFileMetadata(testPath);
|
||||
expect(result1).toEqual({ project: "initial" });
|
||||
|
||||
// Update file timestamp and content
|
||||
mockVault.getFileByPath.mockReturnValue({
|
||||
path: testPath,
|
||||
stat: { mtime: laterTime }
|
||||
});
|
||||
mockMetadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: { project: "updated" }
|
||||
});
|
||||
|
||||
// Second access - should detect change and return new data
|
||||
const result2 = projectConfigManager.getFileMetadata(testPath);
|
||||
expect(result2).toEqual({ project: "updated" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,9 +10,10 @@ import {
|
|||
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
|
||||
// Remove circular dependency import
|
||||
// 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";
|
||||
import { Task } from "../types/task";
|
||||
|
|
@ -23,8 +24,13 @@ const mockAnnotationType = {
|
|||
value,
|
||||
})),
|
||||
};
|
||||
// Use the actual annotation object from the source file for checks
|
||||
const mockParentTaskStatusChangeAnnotation = taskStatusChangeAnnotation;
|
||||
// Create mock annotation object to avoid circular dependency
|
||||
const mockParentTaskStatusChangeAnnotation = {
|
||||
of: jest.fn().mockImplementation((value: string) => ({
|
||||
type: mockParentTaskStatusChangeAnnotation,
|
||||
value,
|
||||
})),
|
||||
};
|
||||
|
||||
// Mock Text Object - Consolidated version
|
||||
export const createMockText = (content: string): Text => {
|
||||
|
|
@ -228,6 +234,13 @@ const createMockTransaction = (options: {
|
|||
selectionObj.anchor,
|
||||
selectionObj.head
|
||||
); // Use EditorSelection.single for proper creation
|
||||
|
||||
// Create start state selection
|
||||
const startSelectionObj = { anchor: 0, head: 0 };
|
||||
const startEditorSelection = EditorSelection.single(
|
||||
startSelectionObj.anchor,
|
||||
startSelectionObj.head
|
||||
);
|
||||
|
||||
const mockTr = {
|
||||
newDoc: newDoc,
|
||||
|
|
@ -270,7 +283,10 @@ const createMockTransaction = (options: {
|
|||
// @ts-ignore
|
||||
sliceDoc: jest.fn(() => ""),
|
||||
} as unknown as EditorState,
|
||||
startState: EditorState.create({ doc: startDoc }),
|
||||
startState: EditorState.create({
|
||||
doc: startDoc,
|
||||
selection: startEditorSelection
|
||||
}),
|
||||
reconfigured: false,
|
||||
};
|
||||
|
||||
|
|
|
|||
110
src/__tests__/taskMarkCleanup.test.ts
Normal file
110
src/__tests__/taskMarkCleanup.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { clearAllMarks } from "../components/MarkdownRenderer";
|
||||
|
||||
describe("Task Mark Cleanup", () => {
|
||||
describe("clearAllMarks function", () => {
|
||||
test("should remove priority marks", () => {
|
||||
const input = "Complete this task ! ⏫";
|
||||
const expected = "Complete this task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove emoji priority marks", () => {
|
||||
const input = "Important task 🔺";
|
||||
const expected = "Important task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove letter priority marks", () => {
|
||||
const input = "High priority task [#A]";
|
||||
const expected = "High priority task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove date marks", () => {
|
||||
const input = "Task with date 📅 2024-01-15";
|
||||
const expected = "Task with date";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove multiple marks", () => {
|
||||
const input = "Complex task ! 📅 2024-01-15 ⏫ #tag";
|
||||
const expected = "Complex task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should preserve meaningful content", () => {
|
||||
const input = "Write documentation for the API";
|
||||
const expected = "Write documentation for the API";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle empty content", () => {
|
||||
const input = "";
|
||||
const expected = "";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle content with only marks", () => {
|
||||
const input = "! ⏫ 📅 2024-01-15";
|
||||
const expected = "";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should preserve links and code", () => {
|
||||
const input = "Check [[Important Note]] and `code snippet` ! ⏫";
|
||||
const expected = "Check [[Important Note]] and `code snippet`";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle mixed content", () => {
|
||||
const input =
|
||||
"Review [documentation](https://example.com) ! 📅 2024-01-15";
|
||||
const expected = "Review [documentation](https://example.com)";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove tilde date prefix marks", () => {
|
||||
const input = "Complete task ~ 2024-01-15";
|
||||
const expected = "Complete task 2024-01-15";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove target location marks", () => {
|
||||
const input = "Meeting target: office 📁";
|
||||
const expected = "Meeting office";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle complex example from user", () => {
|
||||
const input = "今天要过去吃饭 #123-123-123 ~ 📅 2025-07-18";
|
||||
const expected = "今天要过去吃饭 #123-123-123 2025-07-18";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Task line scenarios", () => {
|
||||
test("should handle task with priority mark in middle", () => {
|
||||
const input = "Complete this ! important task";
|
||||
const expected = "Complete this important task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle task with multiple priority marks", () => {
|
||||
const input = "Very ! important ⏫ task";
|
||||
const expected = "Very important task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle task with trailing marks", () => {
|
||||
const input = "Simple task !";
|
||||
const expected = "Simple task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle task with leading marks", () => {
|
||||
const input = "! Important task";
|
||||
const expected = "Important task";
|
||||
expect(clearAllMarks(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -287,6 +287,11 @@ export interface QuickCaptureSettings {
|
|||
folder: string; // Folder path for daily notes
|
||||
template: string; // Template file path for daily notes
|
||||
};
|
||||
// Minimal mode settings
|
||||
enableMinimalMode: boolean;
|
||||
minimalModeSettings: {
|
||||
suggestTrigger: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Define the structure for task gutter settings */
|
||||
|
|
@ -494,6 +499,10 @@ export interface FileParsingConfiguration {
|
|||
defaultTaskStatus: string;
|
||||
/** Whether to use worker for file parsing performance */
|
||||
enableWorkerProcessing: boolean;
|
||||
/** Whether to enable mtime-based cache optimization */
|
||||
enableMtimeOptimization: boolean;
|
||||
/** Maximum number of files to track in mtime cache */
|
||||
mtimeCacheSize: number;
|
||||
}
|
||||
|
||||
/** Timeline Sidebar Settings */
|
||||
|
|
@ -799,6 +808,10 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
folder: "",
|
||||
template: "",
|
||||
},
|
||||
enableMinimalMode: false,
|
||||
minimalModeSettings: {
|
||||
suggestTrigger: "/",
|
||||
},
|
||||
},
|
||||
|
||||
// Workflow Defaults
|
||||
|
|
@ -926,6 +939,8 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
enableMtimeOptimization: true,
|
||||
mtimeCacheSize: 10000,
|
||||
},
|
||||
|
||||
// Date Settings
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ export function clearAllMarks(markdown: string): string {
|
|||
"❌", // cancelledDate
|
||||
].filter(Boolean); // Filter out any potentially undefined symbols
|
||||
|
||||
// Remove date fields (symbol followed by date)
|
||||
// Special handling for tilde prefix dates: remove ~ and 📅 but keep date
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/\s*~\s*📅\s*/g, " ");
|
||||
|
||||
// Remove date fields (symbol followed by date) - normal case
|
||||
symbolsToRemove.forEach((symbol) => {
|
||||
if (!symbol) return; // Should be redundant due to filter, but safe
|
||||
// Escape the symbol for use in regex
|
||||
|
|
@ -92,6 +95,11 @@ export function clearAllMarks(markdown: string): string {
|
|||
""
|
||||
);
|
||||
|
||||
// Remove standalone exclamation marks (priority indicators)
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/\s+!\s*/g, " ");
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/^\s*!\s*/, "");
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/\s*!\s*$/, "");
|
||||
|
||||
// Remove non-date metadata fields (id, dependsOn, onCompletion)
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/🆔\s*[^\s]+/g, ""); // Remove id
|
||||
cleanedMarkdown = cleanedMarkdown.replace(/⛔\s*[^\s]+/g, ""); // Remove dependsOn
|
||||
|
|
@ -222,11 +230,20 @@ export function clearAllMarks(markdown: string): string {
|
|||
// Remove tags from temporary markdown (where links/code are placeholders)
|
||||
tempMarkdown = removeTagsWithLinkProtection(tempMarkdown);
|
||||
|
||||
// Remove context tags from temporary markdown
|
||||
// Remove context tags from temporary markdown
|
||||
tempMarkdown = tempMarkdown.replace(/@[\w-]+/g, "");
|
||||
|
||||
// Remove any remaining tags that might have been missed
|
||||
tempMarkdown = tempMarkdown.replace(TAG_REGEX, "");
|
||||
// Remove target location patterns (like "target: office 📁")
|
||||
tempMarkdown = tempMarkdown.replace(/\btarget:\s*/gi, "");
|
||||
tempMarkdown = tempMarkdown.replace(/\s*📁\s*/g, " ");
|
||||
|
||||
// Remove any remaining simple tags but preserve special tags like #123-123-123
|
||||
tempMarkdown = tempMarkdown.replace(/#(?![0-9-]+\b)[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g, "");
|
||||
|
||||
// Remove any remaining tilde symbols (~ symbol) that weren't handled by the special case
|
||||
tempMarkdown = tempMarkdown.replace(/\s+~\s+/g, " ");
|
||||
tempMarkdown = tempMarkdown.replace(/\s+~(?=\s|$)/g, "");
|
||||
tempMarkdown = tempMarkdown.replace(/^~\s+/, "");
|
||||
|
||||
// Now restore the preserved segments by replacing placeholders with original content
|
||||
for (const [placeholder, originalText] of placeholderMap) {
|
||||
|
|
|
|||
653
src/components/MinimalQuickCaptureModal.ts
Normal file
653
src/components/MinimalQuickCaptureModal.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import {
|
||||
App,
|
||||
Modal,
|
||||
Notice,
|
||||
TFile,
|
||||
moment,
|
||||
EditorPosition,
|
||||
Menu,
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
import {
|
||||
createEmbeddableMarkdownEditor,
|
||||
EmbeddableMarkdownEditor,
|
||||
} from "../editor-ext/markdownEditor";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { saveCapture } from "../utils/fileUtils";
|
||||
import { t } from "../translations/helper";
|
||||
import { MinimalQuickCaptureSuggest } from "./MinimalQuickCaptureSuggest";
|
||||
import { DatePickerPopover } from "./date-picker/DatePickerPopover";
|
||||
import { TagSuggest } from "./AutoComplete";
|
||||
import { SuggestManager, UniversalEditorSuggest } from "./suggest";
|
||||
import { ConfigurableTaskParser } from "../utils/workers/ConfigurableTaskParser";
|
||||
import { clearAllMarks } from "./MarkdownRenderer";
|
||||
|
||||
interface TaskMetadata {
|
||||
startDate?: Date;
|
||||
dueDate?: Date;
|
||||
scheduledDate?: Date;
|
||||
priority?: number;
|
||||
project?: string;
|
||||
context?: string;
|
||||
tags?: string[];
|
||||
location?: "fixed" | "daily-note";
|
||||
targetFile?: string;
|
||||
}
|
||||
|
||||
export class MinimalQuickCaptureModal extends Modal {
|
||||
plugin: TaskProgressBarPlugin;
|
||||
markdownEditor: EmbeddableMarkdownEditor | null = null;
|
||||
capturedContent: string = "";
|
||||
taskMetadata: TaskMetadata = {};
|
||||
|
||||
// UI Elements
|
||||
private dateButton: HTMLButtonElement | null = null;
|
||||
private priorityButton: HTMLButtonElement | null = null;
|
||||
private locationButton: HTMLButtonElement | null = null;
|
||||
private tagButton: HTMLButtonElement | null = null;
|
||||
|
||||
// Suggest instances
|
||||
private minimalSuggest: MinimalQuickCaptureSuggest;
|
||||
private suggestManager: SuggestManager;
|
||||
private universalSuggest: UniversalEditorSuggest | null = null;
|
||||
|
||||
constructor(app: App, plugin: TaskProgressBarPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.minimalSuggest = plugin.minimalQuickCaptureSuggest;
|
||||
|
||||
// Initialize suggest manager
|
||||
this.suggestManager = new SuggestManager(app, plugin);
|
||||
|
||||
// Initialize default metadata with fallback
|
||||
const minimalSettings =
|
||||
this.plugin.settings.quickCapture.minimalModeSettings;
|
||||
this.taskMetadata.location =
|
||||
this.plugin.settings.quickCapture.targetType || "fixed";
|
||||
this.taskMetadata.targetFile = this.getTargetFile();
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.modalEl.addClass("quick-capture-modal");
|
||||
this.modalEl.addClass("minimal");
|
||||
|
||||
// Store modal instance reference for suggest system
|
||||
(this.modalEl as any).__minimalQuickCaptureModal = this;
|
||||
|
||||
// Start managing suggests with high priority
|
||||
this.suggestManager.startManaging();
|
||||
|
||||
// Set up the suggest system
|
||||
if (this.minimalSuggest) {
|
||||
this.minimalSuggest.setMinimalMode(true);
|
||||
}
|
||||
|
||||
// Create the interface
|
||||
this.createMinimalInterface(contentEl);
|
||||
|
||||
// Enable universal suggest for minimal modal after editor is created
|
||||
setTimeout(() => {
|
||||
if (this.markdownEditor?.editor?.editor) {
|
||||
this.universalSuggest =
|
||||
this.suggestManager.enableForMinimalModal(
|
||||
this.markdownEditor.editor.editor
|
||||
);
|
||||
this.universalSuggest.enable();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
// Clean up universal suggest
|
||||
if (this.universalSuggest) {
|
||||
this.universalSuggest.disable();
|
||||
this.universalSuggest = null;
|
||||
}
|
||||
|
||||
// Stop managing suggests and restore original order
|
||||
this.suggestManager.stopManaging();
|
||||
|
||||
// Clean up suggest
|
||||
if (this.minimalSuggest) {
|
||||
this.minimalSuggest.setMinimalMode(false);
|
||||
}
|
||||
|
||||
// Clean up editor
|
||||
if (this.markdownEditor) {
|
||||
this.markdownEditor.destroy();
|
||||
this.markdownEditor = null;
|
||||
}
|
||||
|
||||
// Clean up modal reference
|
||||
delete (this.modalEl as any).__minimalQuickCaptureModal;
|
||||
|
||||
// Clear content
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
private createMinimalInterface(contentEl: HTMLElement) {
|
||||
// Title
|
||||
this.titleEl.setText(t("Minimal Quick Capture"));
|
||||
|
||||
// Editor container
|
||||
const editorContainer = contentEl.createDiv({
|
||||
cls: "quick-capture-minimal-editor-container",
|
||||
});
|
||||
|
||||
this.setupMarkdownEditor(editorContainer);
|
||||
|
||||
// Bottom buttons container
|
||||
const buttonsContainer = contentEl.createDiv({
|
||||
cls: "quick-capture-minimal-buttons",
|
||||
});
|
||||
|
||||
this.createQuickActionButtons(buttonsContainer);
|
||||
this.createMainButtons(buttonsContainer);
|
||||
}
|
||||
|
||||
private setupMarkdownEditor(container: HTMLElement) {
|
||||
setTimeout(() => {
|
||||
this.markdownEditor = createEmbeddableMarkdownEditor(
|
||||
this.app,
|
||||
container,
|
||||
{
|
||||
placeholder: t("Enter your task..."),
|
||||
singleLine: true, // Single line mode
|
||||
|
||||
onEnter: (editor, mod, shift) => {
|
||||
if (mod) {
|
||||
// Submit on Cmd/Ctrl+Enter
|
||||
this.handleSubmit();
|
||||
return true;
|
||||
}
|
||||
// In minimal mode, Enter should also submit
|
||||
this.handleSubmit();
|
||||
return true;
|
||||
},
|
||||
|
||||
onEscape: (editor) => {
|
||||
this.close();
|
||||
},
|
||||
|
||||
onChange: (update) => {
|
||||
this.capturedContent = this.markdownEditor?.value || "";
|
||||
// Parse content and update button states
|
||||
this.parseContentAndUpdateButtons();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Focus the editor
|
||||
this.markdownEditor?.editor?.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private createQuickActionButtons(container: HTMLElement) {
|
||||
const settings =
|
||||
this.plugin.settings.quickCapture.minimalModeSettings || {};
|
||||
const leftContainer = container.createDiv({
|
||||
cls: "quick-actions-left",
|
||||
});
|
||||
|
||||
this.dateButton = leftContainer.createEl("button", {
|
||||
cls: ["quick-action-button", "clickable-icon"],
|
||||
attr: { "aria-label": t("Set date") },
|
||||
});
|
||||
setIcon(this.dateButton, "calendar");
|
||||
this.dateButton.addEventListener("click", () => this.showDatePicker());
|
||||
this.updateButtonState(this.dateButton, !!this.taskMetadata.dueDate);
|
||||
|
||||
this.priorityButton = leftContainer.createEl("button", {
|
||||
cls: ["quick-action-button", "clickable-icon"],
|
||||
attr: { "aria-label": t("Set priority") },
|
||||
});
|
||||
setIcon(this.priorityButton, "zap");
|
||||
this.priorityButton.addEventListener("click", () =>
|
||||
this.showPriorityMenu()
|
||||
);
|
||||
this.updateButtonState(
|
||||
this.priorityButton,
|
||||
!!this.taskMetadata.priority
|
||||
);
|
||||
|
||||
this.locationButton = leftContainer.createEl("button", {
|
||||
cls: ["quick-action-button", "clickable-icon"],
|
||||
attr: { "aria-label": t("Set location") },
|
||||
});
|
||||
setIcon(this.locationButton, "folder");
|
||||
this.locationButton.addEventListener("click", () =>
|
||||
this.showLocationMenu()
|
||||
);
|
||||
this.updateButtonState(
|
||||
this.locationButton,
|
||||
this.taskMetadata.location !==
|
||||
(this.plugin.settings.quickCapture.targetType || "fixed")
|
||||
);
|
||||
|
||||
this.tagButton = leftContainer.createEl("button", {
|
||||
cls: ["quick-action-button", "clickable-icon"],
|
||||
attr: { "aria-label": t("Add tags") },
|
||||
});
|
||||
setIcon(this.tagButton, "tag");
|
||||
this.tagButton.addEventListener("click", () => {});
|
||||
this.updateButtonState(
|
||||
this.tagButton,
|
||||
!!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
private createMainButtons(container: HTMLElement) {
|
||||
const rightContainer = container.createDiv({
|
||||
cls: "quick-actions-right",
|
||||
});
|
||||
|
||||
// Save button
|
||||
const saveButton = rightContainer.createEl("button", {
|
||||
text: t("Save"),
|
||||
cls: "mod-cta quick-action-save",
|
||||
});
|
||||
saveButton.addEventListener("click", () => this.handleSubmit());
|
||||
}
|
||||
|
||||
private updateButtonState(button: HTMLButtonElement, isActive: boolean) {
|
||||
if (isActive) {
|
||||
button.addClass("active");
|
||||
} else {
|
||||
button.removeClass("active");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show menu at specified coordinates
|
||||
*/
|
||||
private showMenuAtCoords(menu: Menu, x: number, y: number): void {
|
||||
menu.showAtMouseEvent(
|
||||
new MouseEvent("click", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Methods called by MinimalQuickCaptureSuggest
|
||||
public showDatePickerAtCursor(cursorCoords: any, cursor: EditorPosition) {
|
||||
this.showDatePicker(cursor, cursorCoords);
|
||||
}
|
||||
|
||||
public showDatePicker(cursor?: EditorPosition, coords?: any) {
|
||||
const quickDates = [
|
||||
{ label: t("Tomorrow"), date: moment().add(1, "day").toDate() },
|
||||
{
|
||||
label: t("Day after tomorrow"),
|
||||
date: moment().add(2, "day").toDate(),
|
||||
},
|
||||
{ label: t("Next week"), date: moment().add(1, "week").toDate() },
|
||||
{ label: t("Next month"), date: moment().add(1, "month").toDate() },
|
||||
];
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
quickDates.forEach((quickDate) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(quickDate.label);
|
||||
item.setIcon("calendar");
|
||||
item.onClick(() => {
|
||||
this.taskMetadata.dueDate = quickDate.date;
|
||||
this.updateButtonState(this.dateButton!, true);
|
||||
|
||||
// If called from suggest, replace the ~ with date text
|
||||
if (cursor && this.markdownEditor) {
|
||||
this.replaceAtCursor(
|
||||
cursor,
|
||||
this.formatDate(quickDate.date)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Choose date..."));
|
||||
item.setIcon("calendar-days");
|
||||
item.onClick(() => {
|
||||
// Open full date picker
|
||||
// TODO: Implement full date picker integration
|
||||
});
|
||||
});
|
||||
|
||||
// Show menu at cursor position if provided, otherwise at button
|
||||
if (coords) {
|
||||
this.showMenuAtCoords(menu, coords.left, coords.top);
|
||||
} else if (this.dateButton) {
|
||||
const rect = this.dateButton.getBoundingClientRect();
|
||||
this.showMenuAtCoords(
|
||||
menu,
|
||||
rect.left,
|
||||
rect.bottom + 5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public showPriorityMenuAtCursor(cursorCoords: any, cursor: EditorPosition) {
|
||||
this.showPriorityMenu(cursor, cursorCoords);
|
||||
}
|
||||
|
||||
public showPriorityMenu(cursor?: EditorPosition, coords?: any) {
|
||||
const priorities = [
|
||||
{ level: 5, label: t("Highest"), icon: "🔺" },
|
||||
{ level: 4, label: t("High"), icon: "⏫" },
|
||||
{ level: 3, label: t("Medium"), icon: "🔼" },
|
||||
{ level: 2, label: t("Low"), icon: "🔽" },
|
||||
{ level: 1, label: t("Lowest"), icon: "⏬" },
|
||||
];
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
priorities.forEach((priority) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`${priority.icon} ${priority.label}`);
|
||||
item.onClick(() => {
|
||||
this.taskMetadata.priority = priority.level;
|
||||
this.updateButtonState(this.priorityButton!, true);
|
||||
|
||||
// If called from suggest, replace the ! with priority icon
|
||||
if (cursor && this.markdownEditor) {
|
||||
this.replaceAtCursor(cursor, priority.icon);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Show menu at cursor position if provided, otherwise at button
|
||||
if (coords) {
|
||||
this.showMenuAtCoords(menu, coords.left, coords.top);
|
||||
} else if (this.priorityButton) {
|
||||
const rect = this.priorityButton.getBoundingClientRect();
|
||||
this.showMenuAtCoords(
|
||||
menu,
|
||||
rect.left,
|
||||
rect.bottom + 5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public showLocationMenuAtCursor(cursorCoords: any, cursor: EditorPosition) {
|
||||
this.showLocationMenu(cursor, cursorCoords);
|
||||
}
|
||||
|
||||
public showLocationMenu(cursor?: EditorPosition, coords?: any) {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Fixed location"));
|
||||
item.setIcon("file");
|
||||
item.onClick(() => {
|
||||
this.taskMetadata.location = "fixed";
|
||||
this.taskMetadata.targetFile =
|
||||
this.plugin.settings.quickCapture.targetFile;
|
||||
this.updateButtonState(
|
||||
this.locationButton!,
|
||||
this.taskMetadata.location !==
|
||||
(this.plugin.settings.quickCapture.targetType ||
|
||||
"fixed")
|
||||
);
|
||||
|
||||
// If called from suggest, replace the 📁 with file text
|
||||
if (cursor && this.markdownEditor) {
|
||||
this.replaceAtCursor(cursor, t("Fixed location"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Daily note"));
|
||||
item.setIcon("calendar");
|
||||
item.onClick(() => {
|
||||
this.taskMetadata.location = "daily-note";
|
||||
this.taskMetadata.targetFile = this.getDailyNoteFile();
|
||||
this.updateButtonState(
|
||||
this.locationButton!,
|
||||
this.taskMetadata.location !==
|
||||
(this.plugin.settings.quickCapture?.targetType ||
|
||||
"fixed")
|
||||
);
|
||||
|
||||
// If called from suggest, replace the 📁 with daily note text
|
||||
if (cursor && this.markdownEditor) {
|
||||
this.replaceAtCursor(cursor, t("Daily note"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Show menu at cursor position if provided, otherwise at button
|
||||
if (coords) {
|
||||
this.showMenuAtCoords(menu, coords.left, coords.top);
|
||||
} else if (this.locationButton) {
|
||||
const rect = this.locationButton.getBoundingClientRect();
|
||||
this.showMenuAtCoords(
|
||||
menu,
|
||||
rect.left,
|
||||
rect.bottom + 5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public showTagSelectorAtCursor(cursorCoords: any, cursor: EditorPosition) {}
|
||||
|
||||
private replaceAtCursor(cursor: EditorPosition, replacement: string) {
|
||||
if (!this.markdownEditor) return;
|
||||
|
||||
// Replace the character at cursor position using CodeMirror API
|
||||
const cm = (this.markdownEditor.editor as any).cm;
|
||||
if (cm && cm.replaceRange) {
|
||||
cm.replaceRange(
|
||||
replacement,
|
||||
{ line: cursor.line, ch: cursor.ch - 1 },
|
||||
cursor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getTargetFile(): string {
|
||||
const settings = this.plugin.settings.quickCapture;
|
||||
if (this.taskMetadata.location === "daily-note") {
|
||||
return this.getDailyNoteFile();
|
||||
}
|
||||
return settings.targetFile;
|
||||
}
|
||||
|
||||
private getDailyNoteFile(): string {
|
||||
const settings = this.plugin.settings.quickCapture.dailyNoteSettings;
|
||||
const dateStr = moment().format(settings.format);
|
||||
return settings.folder
|
||||
? `${settings.folder}/${dateStr}.md`
|
||||
: `${dateStr}.md`;
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return moment(date).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
private processMinimalContent(content: string): string {
|
||||
if (!content.trim()) return "";
|
||||
|
||||
const lines = content.split("\n");
|
||||
const processedLines = lines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("- [")) {
|
||||
// Use clearAllMarks to completely clean the content
|
||||
const cleanedContent = clearAllMarks(trimmed);
|
||||
return `- [ ] ${cleanedContent}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
return processedLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean temporary marks from user input that might conflict with formal metadata
|
||||
*/
|
||||
private cleanTemporaryMarks(content: string): string {
|
||||
let cleaned = content;
|
||||
|
||||
// Remove standalone exclamation marks that users might type for priority
|
||||
cleaned = cleaned.replace(/\s*!\s*/g, " ");
|
||||
|
||||
// Remove standalone tilde marks that users might type for date
|
||||
cleaned = cleaned.replace(/\s*~\s*/g, " ");
|
||||
|
||||
// Remove standalone priority symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[🔺⏫🔼🔽⏬️]\s*/g, " ");
|
||||
|
||||
// Remove standalone date symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[📅🛫⏳✅➕❌]\s*/g, " ");
|
||||
|
||||
// Remove location/folder symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[📁🏠🏢🏪🏫🏬🏭🏯🏰]\s*/g, " ");
|
||||
|
||||
// Remove other metadata symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[🆔⛔🏁🔁]\s*/g, " ");
|
||||
|
||||
// Remove target/location prefix patterns (like @location, target:)
|
||||
cleaned = cleaned.replace(/\s*@\w*\s*/g, " ");
|
||||
cleaned = cleaned.replace(/\s*target:\s*/gi, " ");
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private addMetadataToContent(content: string): string {
|
||||
const metadata: string[] = [];
|
||||
|
||||
// Add date metadata
|
||||
if (this.taskMetadata.dueDate) {
|
||||
metadata.push(`📅 ${this.formatDate(this.taskMetadata.dueDate)}`);
|
||||
}
|
||||
|
||||
// Add priority metadata
|
||||
if (this.taskMetadata.priority) {
|
||||
const priorityIcons = ["⏬", "🔽", "🔼", "⏫", "🔺"];
|
||||
metadata.push(priorityIcons[this.taskMetadata.priority - 1]);
|
||||
}
|
||||
|
||||
// Add tags
|
||||
if (this.taskMetadata.tags && this.taskMetadata.tags.length > 0) {
|
||||
metadata.push(...this.taskMetadata.tags.map((tag) => `#${tag}`));
|
||||
}
|
||||
|
||||
// Add metadata to content
|
||||
if (metadata.length > 0) {
|
||||
return `${content} ${metadata.join(" ")}`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async handleSubmit() {
|
||||
const content = this.capturedContent.trim();
|
||||
|
||||
if (!content) {
|
||||
new Notice(t("Nothing to capture"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process content
|
||||
let processedContent = this.processMinimalContent(content);
|
||||
processedContent = this.addMetadataToContent(processedContent);
|
||||
|
||||
// Save options
|
||||
const captureOptions = {
|
||||
...this.plugin.settings.quickCapture,
|
||||
targetFile:
|
||||
this.taskMetadata.targetFile || this.getTargetFile(),
|
||||
targetType: this.taskMetadata.location || "fixed",
|
||||
};
|
||||
|
||||
await saveCapture(this.app, processedContent, captureOptions);
|
||||
new Notice(t("Captured successfully"));
|
||||
this.close();
|
||||
} catch (error) {
|
||||
new Notice(`${t("Failed to save:")} ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content and update button states based on extracted metadata
|
||||
* Only update taskMetadata if actual marks exist in content, preserve manually set values
|
||||
*/
|
||||
public parseContentAndUpdateButtons(): void {
|
||||
try {
|
||||
const content = this.capturedContent.trim();
|
||||
if (!content) {
|
||||
// Update button states based on existing taskMetadata
|
||||
this.updateButtonState(this.dateButton!, !!this.taskMetadata.dueDate);
|
||||
this.updateButtonState(this.priorityButton!, !!this.taskMetadata.priority);
|
||||
this.updateButtonState(this.tagButton!, !!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0));
|
||||
this.updateButtonState(this.locationButton!, !!(this.taskMetadata.location || this.taskMetadata.targetFile));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a parser to extract metadata
|
||||
const parser = new ConfigurableTaskParser({
|
||||
// Use default configuration
|
||||
});
|
||||
|
||||
// Extract metadata and tags
|
||||
const [cleanedContent, metadata, tags] = parser.extractMetadataAndTags(content);
|
||||
|
||||
// Only update taskMetadata if we found actual marks in the content
|
||||
// This preserves manually set values from suggest system
|
||||
|
||||
// Due date - only update if found in content
|
||||
if (metadata.dueDate) {
|
||||
this.taskMetadata.dueDate = new Date(metadata.dueDate);
|
||||
}
|
||||
// Don't delete existing dueDate if not found in content
|
||||
|
||||
// Priority - only update if found in content
|
||||
if (metadata.priority) {
|
||||
const priorityMap: Record<string, number> = {
|
||||
"highest": 5,
|
||||
"high": 4,
|
||||
"medium": 3,
|
||||
"low": 2,
|
||||
"lowest": 1
|
||||
};
|
||||
this.taskMetadata.priority = priorityMap[metadata.priority] || 3;
|
||||
}
|
||||
// Don't delete existing priority if not found in content
|
||||
|
||||
// Tags - only add new tags, don't replace existing ones
|
||||
if (tags && tags.length > 0) {
|
||||
if (!this.taskMetadata.tags) {
|
||||
this.taskMetadata.tags = [];
|
||||
}
|
||||
// Merge new tags with existing ones, avoid duplicates
|
||||
tags.forEach(tag => {
|
||||
if (!this.taskMetadata.tags!.includes(tag)) {
|
||||
this.taskMetadata.tags!.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update button states based on current taskMetadata
|
||||
this.updateButtonState(this.dateButton!, !!this.taskMetadata.dueDate);
|
||||
this.updateButtonState(this.priorityButton!, !!this.taskMetadata.priority);
|
||||
this.updateButtonState(this.tagButton!, !!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0));
|
||||
this.updateButtonState(this.locationButton!, !!(this.taskMetadata.location || this.taskMetadata.targetFile || metadata.project || metadata.location));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error parsing content:", error);
|
||||
// On error, still update button states based on existing taskMetadata
|
||||
this.updateButtonState(this.dateButton!, !!this.taskMetadata.dueDate);
|
||||
this.updateButtonState(this.priorityButton!, !!this.taskMetadata.priority);
|
||||
this.updateButtonState(this.tagButton!, !!(this.taskMetadata.tags && this.taskMetadata.tags.length > 0));
|
||||
this.updateButtonState(this.locationButton!, !!(this.taskMetadata.location || this.taskMetadata.targetFile));
|
||||
}
|
||||
}
|
||||
}
|
||||
253
src/components/MinimalQuickCaptureSuggest.ts
Normal file
253
src/components/MinimalQuickCaptureSuggest.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorPosition,
|
||||
EditorSuggest,
|
||||
EditorSuggestContext,
|
||||
EditorSuggestTriggerInfo,
|
||||
TFile,
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
import { Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { t } from "../translations/helper";
|
||||
import { getSuggestOptionsByTrigger } from "./suggest/SpecialCharacterSuggests";
|
||||
|
||||
interface SuggestOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
replacement: string;
|
||||
trigger?: string;
|
||||
action?: (editor: Editor, cursor: EditorPosition) => void;
|
||||
}
|
||||
|
||||
export class MinimalQuickCaptureSuggest extends EditorSuggest<SuggestOption> {
|
||||
plugin: TaskProgressBarPlugin;
|
||||
private isMinimalMode: boolean = false;
|
||||
|
||||
constructor(app: App, plugin: TaskProgressBarPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the minimal mode context
|
||||
* This should be called by MinimalQuickCaptureModal to activate this suggest
|
||||
*/
|
||||
setMinimalMode(isMinimal: boolean): void {
|
||||
this.isMinimalMode = isMinimal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the trigger regex for the suggestion
|
||||
*/
|
||||
onTrigger(
|
||||
cursor: EditorPosition,
|
||||
editor: Editor,
|
||||
file: TFile
|
||||
): EditorSuggestTriggerInfo | null {
|
||||
// Only trigger in minimal mode
|
||||
if (!this.isMinimalMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we're in a minimal quick capture context
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
if (!editorEl || !editorEl.closest(".quick-capture-modal.minimal")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the current line
|
||||
const line = editor.getLine(cursor.line);
|
||||
const triggerChar =
|
||||
this.plugin.settings.quickCapture.minimalModeSettings
|
||||
?.suggestTrigger || "/";
|
||||
|
||||
// Define all possible trigger characters
|
||||
// Always include "/" for the main menu, plus the configured trigger and special chars
|
||||
const allTriggers = ["/", triggerChar, "~", "!", "*", "#"];
|
||||
|
||||
// Check if the cursor is right after any trigger character
|
||||
if (cursor.ch > 0) {
|
||||
const charBeforeCursor = line.charAt(cursor.ch - 1);
|
||||
if (allTriggers.includes(charBeforeCursor)) {
|
||||
return {
|
||||
start: { line: cursor.line, ch: cursor.ch - 1 },
|
||||
end: cursor,
|
||||
query: charBeforeCursor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions based on the trigger
|
||||
*/
|
||||
getSuggestions(context: EditorSuggestContext): SuggestOption[] {
|
||||
const triggerChar = context.query;
|
||||
|
||||
// If trigger is "/", show all special character options
|
||||
if (triggerChar === "/") {
|
||||
return [
|
||||
{
|
||||
id: "date",
|
||||
label: t("Date"),
|
||||
icon: "calendar",
|
||||
description: t("Add date (triggers ~)"),
|
||||
replacement: "~",
|
||||
trigger: "/",
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
label: t("Priority"),
|
||||
icon: "zap",
|
||||
description: t("Set priority (triggers !)"),
|
||||
replacement: "!",
|
||||
trigger: "/",
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
label: t("Target Location"),
|
||||
icon: "folder",
|
||||
description: t("Set target location (triggers *)"),
|
||||
replacement: "*",
|
||||
trigger: "/",
|
||||
},
|
||||
{
|
||||
id: "tag",
|
||||
label: t("Tag"),
|
||||
icon: "tag",
|
||||
description: t("Add tags (triggers #)"),
|
||||
replacement: "#",
|
||||
trigger: "/",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// For special characters, get their specific suggestions
|
||||
// Map old @ to new * for backward compatibility
|
||||
const mappedTrigger = triggerChar === "@" ? "*" : triggerChar;
|
||||
return getSuggestOptionsByTrigger(mappedTrigger, this.plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render suggestion using Obsidian Menu DOM structure
|
||||
*/
|
||||
renderSuggestion(suggestion: SuggestOption, el: HTMLElement): void {
|
||||
el.addClass("menu-item");
|
||||
el.addClass("tappable");
|
||||
|
||||
// Create icon element
|
||||
const iconEl = el.createDiv("menu-item-icon");
|
||||
setIcon(iconEl, suggestion.icon);
|
||||
|
||||
// Create title element
|
||||
const titleEl = el.createDiv("menu-item-title");
|
||||
titleEl.textContent = suggestion.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle suggestion selection
|
||||
*/
|
||||
selectSuggestion(
|
||||
suggestion: SuggestOption,
|
||||
evt: MouseEvent | KeyboardEvent
|
||||
): void {
|
||||
const editor = this.context?.editor;
|
||||
const cursor = this.context?.end;
|
||||
|
||||
if (!editor || !cursor) return;
|
||||
|
||||
// Get the current trigger character
|
||||
const currentTrigger = this.context?.query || "";
|
||||
|
||||
// Check if this is a specific metadata selection (not the main menu items)
|
||||
const isSpecificMetadataSelection = ["!", "~", "#", "*"].includes(currentTrigger) &&
|
||||
!["date", "priority", "target", "tag"].includes(suggestion.id);
|
||||
|
||||
if (isSpecificMetadataSelection) {
|
||||
// This is a specific metadata selection (e.g., "High Priority" from "!" menu)
|
||||
// Just remove the trigger character, don't insert anything
|
||||
const view = (editor as any).cm as EditorView;
|
||||
if (!view) {
|
||||
// Fallback to old method if view is not available
|
||||
const startPos = { line: cursor.line, ch: cursor.ch - 1 };
|
||||
const endPos = cursor;
|
||||
editor.replaceRange("", startPos, endPos);
|
||||
editor.setCursor(startPos);
|
||||
} else {
|
||||
// Use CodeMirror 6 changes API to remove the trigger character
|
||||
const startOffset = view.state.doc.line(cursor.line + 1).from + cursor.ch - 1;
|
||||
const endOffset = view.state.doc.line(cursor.line + 1).from + cursor.ch;
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: startOffset,
|
||||
to: endOffset,
|
||||
insert: "",
|
||||
},
|
||||
annotations: [Transaction.userEvent.of("input")],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// This is either:
|
||||
// 1. A main menu selection from "/" (replace with special character)
|
||||
// 2. A general category selection that should insert the replacement
|
||||
const view = (editor as any).cm as EditorView;
|
||||
if (!view) {
|
||||
// Fallback to old method if view is not available
|
||||
const startPos = { line: cursor.line, ch: cursor.ch - 1 };
|
||||
const endPos = cursor;
|
||||
editor.replaceRange(suggestion.replacement, startPos, endPos);
|
||||
const newCursor = {
|
||||
line: cursor.line,
|
||||
ch: cursor.ch - 1 + suggestion.replacement.length,
|
||||
};
|
||||
editor.setCursor(newCursor);
|
||||
} else {
|
||||
// Use CodeMirror 6 changes API
|
||||
const startOffset = view.state.doc.line(cursor.line + 1).from + cursor.ch - 1;
|
||||
const endOffset = view.state.doc.line(cursor.line + 1).from + cursor.ch;
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: startOffset,
|
||||
to: endOffset,
|
||||
insert: suggestion.replacement,
|
||||
},
|
||||
annotations: [Transaction.userEvent.of("input")],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get the modal instance to update button states
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
|
||||
// Execute custom action if provided
|
||||
if (suggestion.action) {
|
||||
const newCursor = {
|
||||
line: cursor.line,
|
||||
ch: cursor.ch - 1 + suggestion.replacement.length,
|
||||
};
|
||||
suggestion.action(editor, newCursor);
|
||||
}
|
||||
|
||||
// Update modal state if available
|
||||
if (modal && typeof modal.parseContentAndUpdateButtons === "function") {
|
||||
// Delay to ensure content is updated
|
||||
setTimeout(() => {
|
||||
modal.parseContentAndUpdateButtons();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Close this suggest to allow the next one to trigger
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
ParsedTimeResult,
|
||||
LineParseResult,
|
||||
} from "../utils/TimeParsingService";
|
||||
import { SuggestManager, UniversalEditorSuggest } from "./suggest";
|
||||
|
||||
interface TaskMetadata {
|
||||
startDate?: Date;
|
||||
|
|
@ -106,6 +107,10 @@ export class QuickCaptureModal extends Modal {
|
|||
// Debounce timer for real-time parsing
|
||||
private parseDebounceTimer?: number;
|
||||
|
||||
// Suggest management
|
||||
private suggestManager: SuggestManager;
|
||||
private universalSuggest: UniversalEditorSuggest | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
|
|
@ -115,6 +120,9 @@ export class QuickCaptureModal extends Modal {
|
|||
super(app);
|
||||
this.plugin = plugin;
|
||||
|
||||
// Initialize suggest manager
|
||||
this.suggestManager = new SuggestManager(app, plugin);
|
||||
|
||||
// Initialize target file path based on target type
|
||||
if (this.plugin.settings.quickCapture.targetType === "daily-note") {
|
||||
const dateStr = moment().format(
|
||||
|
|
@ -150,12 +158,26 @@ export class QuickCaptureModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
this.modalEl.toggleClass("quick-capture-modal", true);
|
||||
|
||||
// Start managing suggests with high priority
|
||||
this.suggestManager.startManaging();
|
||||
|
||||
if (this.useFullFeaturedMode) {
|
||||
this.modalEl.toggleClass(["quick-capture-modal", "full"], true);
|
||||
this.createFullFeaturedModal(contentEl);
|
||||
} else {
|
||||
this.createSimpleModal(contentEl);
|
||||
}
|
||||
|
||||
// Enable universal suggest after editor is created
|
||||
setTimeout(() => {
|
||||
if (this.markdownEditor?.editor?.editor) {
|
||||
this.universalSuggest =
|
||||
this.suggestManager.enableForQuickCaptureModal(
|
||||
this.markdownEditor.editor.editor
|
||||
);
|
||||
this.universalSuggest.enable();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
createSimpleModal(contentEl: HTMLElement) {
|
||||
|
|
@ -636,7 +658,9 @@ export class QuickCaptureModal extends Modal {
|
|||
// Don't add metadata to sub-tasks, but still clean time expressions
|
||||
// Preserve the original indentation from the original line
|
||||
const originalIndent = indentMatch[1];
|
||||
const cleanedContent = cleanedLine.trim();
|
||||
const cleanedContent = this.cleanTemporaryMarks(
|
||||
cleanedLine.trim()
|
||||
);
|
||||
processedLines.push(originalIndent + cleanedContent);
|
||||
} else if (isTaskOrList) {
|
||||
// If it's a task, add line-specific metadata
|
||||
|
|
@ -649,10 +673,12 @@ export class QuickCaptureModal extends Modal {
|
|||
const listPrefix = cleanedLine
|
||||
.trim()
|
||||
.match(/^(-|\d+\.|\*|\+)/)?.[0];
|
||||
const restOfLine = cleanedLine
|
||||
.trim()
|
||||
.substring(listPrefix?.length || 0)
|
||||
.trim();
|
||||
const restOfLine = this.cleanTemporaryMarks(
|
||||
cleanedLine
|
||||
.trim()
|
||||
.substring(listPrefix?.length || 0)
|
||||
.trim()
|
||||
);
|
||||
|
||||
// Use the specified status or default to empty checkbox
|
||||
const statusMark = this.taskMetadata.status || " ";
|
||||
|
|
@ -665,7 +691,8 @@ export class QuickCaptureModal extends Modal {
|
|||
// Not a list item or task, convert to task and add line-specific metadata
|
||||
// Use the specified status or default to empty checkbox
|
||||
const statusMark = this.taskMetadata.status || " ";
|
||||
const taskLine = `- [${statusMark}] ${cleanedLine}`;
|
||||
const cleanedContent = this.cleanTemporaryMarks(cleanedLine);
|
||||
const taskLine = `- [${statusMark}] ${cleanedContent}`;
|
||||
processedLines.push(
|
||||
this.addLineMetadataToTask(taskLine, lineParseResult)
|
||||
);
|
||||
|
|
@ -1012,6 +1039,40 @@ export class QuickCaptureModal extends Modal {
|
|||
this.taskMetadata.manuallySet[field] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean temporary marks from user input that might conflict with formal metadata
|
||||
*/
|
||||
private cleanTemporaryMarks(content: string): string {
|
||||
let cleaned = content;
|
||||
|
||||
// Remove standalone exclamation marks that users might type for priority
|
||||
cleaned = cleaned.replace(/\s*!\s*/g, " ");
|
||||
|
||||
// Remove standalone tilde marks that users might type for date
|
||||
cleaned = cleaned.replace(/\s*~\s*/g, " ");
|
||||
|
||||
// Remove standalone priority symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[🔺⏫🔼🔽⏬️]\s*/g, " ");
|
||||
|
||||
// Remove standalone date symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[📅🛫⏳✅➕❌]\s*/g, " ");
|
||||
|
||||
// Remove location/folder symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[📁🏠🏢🏪🏫🏬🏭🏯🏰]\s*/g, " ");
|
||||
|
||||
// Remove other metadata symbols that users might type
|
||||
cleaned = cleaned.replace(/\s*[🆔⛔🏁🔁]\s*/g, " ");
|
||||
|
||||
// Remove target/location prefix patterns (like @location, target:)
|
||||
cleaned = cleaned.replace(/\s*@\w*\s*/g, " ");
|
||||
cleaned = cleaned.replace(/\s*target:\s*/gi, " ");
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform real-time parsing with debouncing
|
||||
*/
|
||||
|
|
@ -1115,6 +1176,15 @@ export class QuickCaptureModal extends Modal {
|
|||
onClose() {
|
||||
const { contentEl } = this;
|
||||
|
||||
// Clean up universal suggest
|
||||
if (this.universalSuggest) {
|
||||
this.universalSuggest.disable();
|
||||
this.universalSuggest = null;
|
||||
}
|
||||
|
||||
// Stop managing suggests and restore original order
|
||||
this.suggestManager.stopManaging();
|
||||
|
||||
// Clear debounce timer
|
||||
if (this.parseDebounceTimer) {
|
||||
clearTimeout(this.parseDebounceTimer);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export function renderQuickCaptureSettingsTab(
|
|||
settingTab.plugin.settings.quickCapture.enableQuickCapture =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -219,4 +223,55 @@ export function renderQuickCaptureSettingsTab(
|
|||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// Minimal mode settings
|
||||
new Setting(containerEl).setName(t("Minimal Mode")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable minimal mode"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enable simplified single-line quick capture with inline suggestions"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture.enableMinimalMode
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.enableMinimalMode =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
// Refresh the settings display to show/hide minimal mode options
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 100);
|
||||
})
|
||||
);
|
||||
|
||||
if (!settingTab.plugin.settings.quickCapture.enableMinimalMode) return;
|
||||
|
||||
if (!settingTab.plugin.settings.quickCapture.minimalModeSettings) {
|
||||
settingTab.plugin.settings.quickCapture.minimalModeSettings = {
|
||||
suggestTrigger: "/",
|
||||
};
|
||||
}
|
||||
|
||||
// Suggest trigger character
|
||||
new Setting(containerEl)
|
||||
.setName(t("Suggest trigger character"))
|
||||
.setDesc(t("Character to trigger the suggestion menu"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture.minimalModeSettings
|
||||
.suggestTrigger
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.minimalModeSettings.suggestTrigger =
|
||||
value || "/";
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
507
src/components/suggest/SpecialCharacterSuggests.ts
Normal file
507
src/components/suggest/SpecialCharacterSuggests.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { Editor, EditorPosition, Notice } from "obsidian";
|
||||
import TaskProgressBarPlugin from "../../index";
|
||||
import { SuggestOption } from "./UniversalEditorSuggest";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
/**
|
||||
* Priority suggest options based on existing priority system
|
||||
*/
|
||||
export function createPrioritySuggestOptions(): SuggestOption[] {
|
||||
return [
|
||||
{
|
||||
id: "priority-highest",
|
||||
label: t("Highest Priority"),
|
||||
icon: "arrow-up",
|
||||
description: t("🔺 Highest priority task"),
|
||||
replacement: "",
|
||||
trigger: "!",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.priority = 5;
|
||||
modal.updateButtonState(modal.priorityButton, true);
|
||||
}
|
||||
new Notice(t("Highest priority set"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority-high",
|
||||
label: t("High Priority"),
|
||||
icon: "arrow-up",
|
||||
description: t("⏫ High priority task"),
|
||||
replacement: "",
|
||||
trigger: "!",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.priority = 4;
|
||||
modal.updateButtonState(modal.priorityButton, true);
|
||||
}
|
||||
new Notice(t("High priority set"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority-medium",
|
||||
label: t("Medium Priority"),
|
||||
icon: "minus",
|
||||
description: t("🔼 Medium priority task"),
|
||||
replacement: "",
|
||||
trigger: "!",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.priority = 3;
|
||||
modal.updateButtonState(modal.priorityButton, true);
|
||||
}
|
||||
new Notice(t("Medium priority set"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority-low",
|
||||
label: t("Low Priority"),
|
||||
icon: "arrow-down",
|
||||
description: t("🔽 Low priority task"),
|
||||
replacement: "",
|
||||
trigger: "!",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.priority = 2;
|
||||
modal.updateButtonState(modal.priorityButton, true);
|
||||
}
|
||||
new Notice(t("Low priority set"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority-lowest",
|
||||
label: t("Lowest Priority"),
|
||||
icon: "arrow-down",
|
||||
description: t("⏬ Lowest priority task"),
|
||||
replacement: "",
|
||||
trigger: "!",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.priority = 1;
|
||||
modal.updateButtonState(modal.priorityButton, true);
|
||||
}
|
||||
new Notice(t("Lowest priority set"));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Date suggest options for common date patterns
|
||||
*/
|
||||
export function createDateSuggestOptions(): SuggestOption[] {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: "date-today",
|
||||
label: t("Today"),
|
||||
icon: "calendar-days",
|
||||
description: t("Set due date to today"),
|
||||
replacement: "",
|
||||
trigger: "~",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.dueDate = today;
|
||||
modal.updateButtonState(modal.dateButton, true);
|
||||
}
|
||||
new Notice(t("Due date set to today"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "date-tomorrow",
|
||||
label: t("Tomorrow"),
|
||||
icon: "calendar-plus",
|
||||
description: t("Set due date to tomorrow"),
|
||||
replacement: "",
|
||||
trigger: "~",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.dueDate = tomorrow;
|
||||
modal.updateButtonState(modal.dateButton, true);
|
||||
}
|
||||
new Notice(t("Due date set to tomorrow"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "date-picker",
|
||||
label: t("Pick Date"),
|
||||
icon: "calendar",
|
||||
description: t("Open date picker"),
|
||||
replacement: "",
|
||||
trigger: "~",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Trigger the date picker modal
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.showDatePicker();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "date-scheduled",
|
||||
label: t("Scheduled Date"),
|
||||
icon: "calendar-clock",
|
||||
description: t("Set scheduled date"),
|
||||
replacement: "",
|
||||
trigger: "~",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata for scheduled date
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.scheduledDate = today;
|
||||
modal.updateButtonState(modal.dateButton, true);
|
||||
}
|
||||
new Notice(t("Scheduled date set"));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Target location suggest options
|
||||
*/
|
||||
export function createTargetSuggestOptions(
|
||||
plugin: TaskProgressBarPlugin
|
||||
): SuggestOption[] {
|
||||
const options: SuggestOption[] = [
|
||||
{
|
||||
id: "target-inbox",
|
||||
label: t("Inbox"),
|
||||
icon: "inbox",
|
||||
description: t("Save to inbox"),
|
||||
replacement: "",
|
||||
trigger: "*",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.location = "fixed";
|
||||
modal.taskMetadata.targetFile = plugin.settings.quickCapture.targetFile;
|
||||
modal.updateButtonState(modal.locationButton, true);
|
||||
}
|
||||
new Notice(t("Target set to Inbox"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target-daily",
|
||||
label: t("Daily Note"),
|
||||
icon: "calendar-days",
|
||||
description: t("Save to today's daily note"),
|
||||
replacement: "",
|
||||
trigger: "*",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.location = "daily";
|
||||
modal.updateButtonState(modal.locationButton, true);
|
||||
}
|
||||
new Notice(t("Target set to Daily Note"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target-current",
|
||||
label: t("Current File"),
|
||||
icon: "file-text",
|
||||
description: t("Save to current file"),
|
||||
replacement: "",
|
||||
trigger: "*",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.location = "current";
|
||||
modal.updateButtonState(modal.locationButton, true);
|
||||
}
|
||||
new Notice(t("Target set to Current File"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target-picker",
|
||||
label: t("Choose File"),
|
||||
icon: "folder-open",
|
||||
description: t("Open file picker"),
|
||||
replacement: "",
|
||||
trigger: "*",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Trigger the location menu
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.showLocationMenu();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add recent files if available
|
||||
const recentFiles = plugin.app.workspace.getLastOpenFiles();
|
||||
recentFiles.slice(0, 3).forEach((filePath, index) => {
|
||||
const fileName =
|
||||
filePath.split("/").pop()?.replace(".md", "") || filePath;
|
||||
options.push({
|
||||
id: `target-recent-${index}`,
|
||||
label: fileName,
|
||||
icon: "file",
|
||||
description: t("Save to recent file"),
|
||||
replacement: "",
|
||||
trigger: "*",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.taskMetadata.location = "fixed";
|
||||
modal.taskMetadata.targetFile = filePath;
|
||||
modal.updateButtonState(modal.locationButton, true);
|
||||
}
|
||||
new Notice(t("Target set to") + ` ${fileName}`);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag suggest options
|
||||
*/
|
||||
export function createTagSuggestOptions(
|
||||
plugin: TaskProgressBarPlugin
|
||||
): SuggestOption[] {
|
||||
const options: SuggestOption[] = [
|
||||
{
|
||||
id: "tag-important",
|
||||
label: t("Important"),
|
||||
icon: "star",
|
||||
description: t("Mark as important"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
if (!modal.taskMetadata.tags) modal.taskMetadata.tags = [];
|
||||
if (!modal.taskMetadata.tags.includes("important")) {
|
||||
modal.taskMetadata.tags.push("important");
|
||||
}
|
||||
modal.updateButtonState(modal.tagButton, true);
|
||||
}
|
||||
new Notice(t("Tagged as important"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tag-urgent",
|
||||
label: t("Urgent"),
|
||||
icon: "zap",
|
||||
description: t("Mark as urgent"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
if (!modal.taskMetadata.tags) modal.taskMetadata.tags = [];
|
||||
if (!modal.taskMetadata.tags.includes("urgent")) {
|
||||
modal.taskMetadata.tags.push("urgent");
|
||||
}
|
||||
modal.updateButtonState(modal.tagButton, true);
|
||||
}
|
||||
new Notice(t("Tagged as urgent"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tag-work",
|
||||
label: t("Work"),
|
||||
icon: "briefcase",
|
||||
description: t("Work related task"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
if (!modal.taskMetadata.tags) modal.taskMetadata.tags = [];
|
||||
if (!modal.taskMetadata.tags.includes("work")) {
|
||||
modal.taskMetadata.tags.push("work");
|
||||
}
|
||||
modal.updateButtonState(modal.tagButton, true);
|
||||
}
|
||||
new Notice(t("Tagged as work"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tag-personal",
|
||||
label: t("Personal"),
|
||||
icon: "user",
|
||||
description: t("Personal task"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
if (!modal.taskMetadata.tags) modal.taskMetadata.tags = [];
|
||||
if (!modal.taskMetadata.tags.includes("personal")) {
|
||||
modal.taskMetadata.tags.push("personal");
|
||||
}
|
||||
modal.updateButtonState(modal.tagButton, true);
|
||||
}
|
||||
new Notice(t("Tagged as personal"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tag-picker",
|
||||
label: t("Choose Tag"),
|
||||
icon: "tag",
|
||||
description: t("Open tag picker"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Trigger the tag selector modal
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
modal.showTagSelector();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add existing tags from vault
|
||||
try {
|
||||
const allTags = plugin.app.metadataCache.getTags();
|
||||
const tagNames = Object.keys(allTags)
|
||||
.map((tag) => tag.replace("#", ""))
|
||||
.filter(
|
||||
(tag) =>
|
||||
!["important", "urgent", "work", "personal"].includes(tag)
|
||||
)
|
||||
.slice(0, 5); // Limit to 5 most common tags
|
||||
|
||||
tagNames.forEach((tagName, index) => {
|
||||
options.push({
|
||||
id: `tag-existing-${index}`,
|
||||
label: `#${tagName}`,
|
||||
icon: "tag",
|
||||
description: t("Existing tag"),
|
||||
replacement: "",
|
||||
trigger: "#",
|
||||
action: (editor: Editor, cursor: EditorPosition) => {
|
||||
// Update modal metadata instead of inserting text
|
||||
const editorEl = (editor as any).cm?.dom as HTMLElement;
|
||||
const modalEl = editorEl?.closest(".quick-capture-modal.minimal");
|
||||
const modal = (modalEl as any)?.__minimalQuickCaptureModal;
|
||||
if (modal) {
|
||||
if (!modal.taskMetadata.tags) modal.taskMetadata.tags = [];
|
||||
if (!modal.taskMetadata.tags.includes(tagName)) {
|
||||
modal.taskMetadata.tags.push(tagName);
|
||||
}
|
||||
modal.updateButtonState(modal.tagButton, true);
|
||||
}
|
||||
new Notice(t("Tagged with") + ` #${tagName}`);
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to load existing tags:", error);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all suggest options for a given plugin instance
|
||||
*/
|
||||
export function createAllSuggestOptions(plugin: TaskProgressBarPlugin): {
|
||||
priority: SuggestOption[];
|
||||
date: SuggestOption[];
|
||||
target: SuggestOption[];
|
||||
tag: SuggestOption[];
|
||||
} {
|
||||
return {
|
||||
priority: createPrioritySuggestOptions(),
|
||||
date: createDateSuggestOptions(),
|
||||
target: createTargetSuggestOptions(plugin),
|
||||
tag: createTagSuggestOptions(plugin),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggest options by trigger character
|
||||
*/
|
||||
export function getSuggestOptionsByTrigger(
|
||||
trigger: string,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): SuggestOption[] {
|
||||
const allOptions = createAllSuggestOptions(plugin);
|
||||
|
||||
switch (trigger) {
|
||||
case "!":
|
||||
return allOptions.priority;
|
||||
case "~":
|
||||
return allOptions.date;
|
||||
case "*":
|
||||
return allOptions.target;
|
||||
case "#":
|
||||
return allOptions.tag;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
237
src/components/suggest/SuggestManager.ts
Normal file
237
src/components/suggest/SuggestManager.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { App, Editor, EditorSuggest, TFile } from "obsidian";
|
||||
import TaskProgressBarPlugin from "../../index";
|
||||
import { UniversalEditorSuggest, UniversalSuggestConfig } from "./UniversalEditorSuggest";
|
||||
|
||||
export interface SuggestManagerConfig {
|
||||
enableDynamicPriority: boolean;
|
||||
defaultTriggerChars: string[];
|
||||
contextFilters: {
|
||||
[key: string]: (editor: Editor, file: TFile) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages dynamic suggest registration and priority in workspace
|
||||
*/
|
||||
export class SuggestManager {
|
||||
private app: App;
|
||||
private plugin: TaskProgressBarPlugin;
|
||||
private config: SuggestManagerConfig;
|
||||
private activeSuggests: Map<string, EditorSuggest<any>> = new Map();
|
||||
private originalSuggestsOrder: EditorSuggest<any>[] = [];
|
||||
private isManaging: boolean = false;
|
||||
|
||||
constructor(app: App, plugin: TaskProgressBarPlugin, config?: Partial<SuggestManagerConfig>) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.config = {
|
||||
enableDynamicPriority: true,
|
||||
defaultTriggerChars: ["!", "~", "*", "#"],
|
||||
contextFilters: {},
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start managing suggests with dynamic priority
|
||||
*/
|
||||
startManaging(): void {
|
||||
if (this.isManaging) return;
|
||||
|
||||
this.isManaging = true;
|
||||
// Store original order for restoration
|
||||
this.originalSuggestsOrder = [...(this.app.workspace as any).editorSuggest.suggests];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop managing and restore original order
|
||||
*/
|
||||
stopManaging(): void {
|
||||
if (!this.isManaging) return;
|
||||
|
||||
// Remove all our managed suggests
|
||||
this.removeAllManagedSuggests();
|
||||
|
||||
// Restore original order if needed
|
||||
if (this.originalSuggestsOrder.length > 0) {
|
||||
(this.app.workspace as any).editorSuggest.suggests = [...this.originalSuggestsOrder];
|
||||
}
|
||||
|
||||
this.isManaging = false;
|
||||
this.originalSuggestsOrder = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a suggest with high priority (insert at beginning)
|
||||
*/
|
||||
addSuggestWithPriority(suggest: EditorSuggest<any>, id: string): void {
|
||||
if (!this.isManaging) {
|
||||
console.warn("SuggestManager: Not managing, call startManaging() first");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove if already exists
|
||||
this.removeManagedSuggest(id);
|
||||
|
||||
// Add to our tracking
|
||||
this.activeSuggests.set(id, suggest);
|
||||
|
||||
// Insert at the beginning for high priority
|
||||
(this.app.workspace as any).editorSuggest.suggests.unshift(suggest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a managed suggest
|
||||
*/
|
||||
removeManagedSuggest(id: string): void {
|
||||
const suggest = this.activeSuggests.get(id);
|
||||
if (!suggest) return;
|
||||
|
||||
// Remove from workspace
|
||||
const index = (this.app.workspace as any).editorSuggest.suggests.indexOf(suggest);
|
||||
if (index !== -1) {
|
||||
(this.app.workspace as any).editorSuggest.suggests.splice(index, 1);
|
||||
}
|
||||
|
||||
// Remove from our tracking
|
||||
this.activeSuggests.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all managed suggests
|
||||
*/
|
||||
removeAllManagedSuggests(): void {
|
||||
for (const [id] of this.activeSuggests) {
|
||||
this.removeManagedSuggest(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and add a universal suggest for specific context
|
||||
*/
|
||||
createUniversalSuggest(
|
||||
contextId: string,
|
||||
config: Partial<UniversalSuggestConfig> = {}
|
||||
): UniversalEditorSuggest {
|
||||
const suggestConfig: UniversalSuggestConfig = {
|
||||
triggerChars: this.config.defaultTriggerChars,
|
||||
contextFilter: this.config.contextFilters[contextId],
|
||||
priority: 1,
|
||||
...config,
|
||||
};
|
||||
|
||||
const suggest = new UniversalEditorSuggest(this.app, this.plugin, suggestConfig);
|
||||
|
||||
// Add with priority
|
||||
this.addSuggestWithPriority(suggest, `universal-${contextId}`);
|
||||
|
||||
return suggest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable suggests for a specific editor context
|
||||
*/
|
||||
enableForEditor(editor: Editor, contextId: string = "default"): UniversalEditorSuggest {
|
||||
const suggest = this.createUniversalSuggest(contextId, {
|
||||
contextFilter: (ed, file) => ed === editor,
|
||||
});
|
||||
|
||||
suggest.enable();
|
||||
return suggest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable suggests for a specific context
|
||||
*/
|
||||
disableForContext(contextId: string): void {
|
||||
this.removeManagedSuggest(`universal-${contextId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable suggests for minimal quick capture modal
|
||||
*/
|
||||
enableForMinimalModal(editor: Editor): UniversalEditorSuggest {
|
||||
return this.createUniversalSuggest("minimal-modal", {
|
||||
contextFilter: (ed, file) => {
|
||||
// Check if we're in a minimal quick capture context
|
||||
const editorEl = (ed as any).cm?.dom as HTMLElement;
|
||||
return editorEl?.closest(".quick-capture-modal.minimal") !== null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable suggests for regular quick capture modal
|
||||
*/
|
||||
enableForQuickCaptureModal(editor: Editor): UniversalEditorSuggest {
|
||||
return this.createUniversalSuggest("quick-capture-modal", {
|
||||
contextFilter: (ed, file) => {
|
||||
// Check if we're in a quick capture context
|
||||
const editorEl = (ed as any).cm?.dom as HTMLElement;
|
||||
return editorEl?.closest(".quick-capture-modal") !== null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom context filter
|
||||
*/
|
||||
addContextFilter(
|
||||
contextId: string,
|
||||
filter: (editor: Editor, file: TFile) => boolean
|
||||
): void {
|
||||
this.config.contextFilters[contextId] = filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a context filter
|
||||
*/
|
||||
removeContextFilter(contextId: string): void {
|
||||
delete this.config.contextFilters[contextId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active suggests
|
||||
*/
|
||||
getActiveSuggests(): Map<string, EditorSuggest<any>> {
|
||||
return new Map(this.activeSuggests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently managing
|
||||
*/
|
||||
isCurrentlyManaging(): boolean {
|
||||
return this.isManaging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): SuggestManagerConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(newConfig: Partial<SuggestManagerConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug: Log current suggest order
|
||||
*/
|
||||
debugLogSuggestOrder(): void {
|
||||
console.log("Current suggest order:", (this.app.workspace as any).editorSuggest.suggests);
|
||||
console.log("Managed suggests:", Array.from(this.activeSuggests.keys()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method for proper disposal
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.stopManaging();
|
||||
this.activeSuggests.clear();
|
||||
this.config.contextFilters = {};
|
||||
}
|
||||
}
|
||||
206
src/components/suggest/UniversalEditorSuggest.ts
Normal file
206
src/components/suggest/UniversalEditorSuggest.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
App,
|
||||
Editor,
|
||||
EditorPosition,
|
||||
EditorSuggest,
|
||||
EditorSuggestContext,
|
||||
EditorSuggestTriggerInfo,
|
||||
TFile,
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
import TaskProgressBarPlugin from "../../index";
|
||||
import { t } from "../../translations/helper";
|
||||
import { getSuggestOptionsByTrigger } from "./SpecialCharacterSuggests";
|
||||
import "../../styles/universal-suggest.css";
|
||||
|
||||
export interface SuggestOption {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
replacement: string;
|
||||
trigger: string;
|
||||
action?: (editor: Editor, cursor: EditorPosition) => void;
|
||||
}
|
||||
|
||||
export interface UniversalSuggestConfig {
|
||||
triggerChars: string[];
|
||||
contextFilter?: (editor: Editor, file: TFile) => boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal EditorSuggest that handles multiple special characters
|
||||
* and provides dynamic priority management
|
||||
*/
|
||||
export class UniversalEditorSuggest extends EditorSuggest<SuggestOption> {
|
||||
plugin: TaskProgressBarPlugin;
|
||||
private config: UniversalSuggestConfig;
|
||||
private suggestOptions: SuggestOption[] = [];
|
||||
private isEnabled: boolean = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
config: UniversalSuggestConfig
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.config = config;
|
||||
this.initializeSuggestOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize suggest options for all supported special characters
|
||||
*/
|
||||
private initializeSuggestOptions(): void {
|
||||
// Initialize with empty array - options will be loaded dynamically
|
||||
this.suggestOptions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable this suggest instance
|
||||
*/
|
||||
enable(): void {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable this suggest instance
|
||||
*/
|
||||
disable(): void {
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if suggestion should be triggered
|
||||
*/
|
||||
onTrigger(
|
||||
cursor: EditorPosition,
|
||||
editor: Editor,
|
||||
file: TFile
|
||||
): EditorSuggestTriggerInfo | null {
|
||||
// Only trigger if enabled
|
||||
if (!this.isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply context filter if provided
|
||||
if (
|
||||
this.config.contextFilter &&
|
||||
!this.config.contextFilter(editor, file)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the current line
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
// Check if cursor is right after any of our trigger characters
|
||||
if (cursor.ch > 0) {
|
||||
const charBefore = line.charAt(cursor.ch - 1);
|
||||
if (this.config.triggerChars.includes(charBefore)) {
|
||||
return {
|
||||
start: { line: cursor.line, ch: cursor.ch - 1 },
|
||||
end: cursor,
|
||||
query: charBefore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions based on the trigger character
|
||||
*/
|
||||
getSuggestions(context: EditorSuggestContext): SuggestOption[] {
|
||||
const triggerChar = context.query;
|
||||
// Get dynamic suggestions based on trigger character
|
||||
return getSuggestOptionsByTrigger(triggerChar, this.plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render suggestion in the popup
|
||||
*/
|
||||
renderSuggestion(suggestion: SuggestOption, el: HTMLElement): void {
|
||||
const container = el.createDiv({ cls: "universal-suggest-item" });
|
||||
|
||||
// Icon
|
||||
container.createDiv({ cls: "universal-suggest-container" },(el)=>{
|
||||
const icon = el.createDiv({ cls: "universal-suggest-icon" });
|
||||
setIcon(icon, suggestion.icon);
|
||||
|
||||
el.createDiv({
|
||||
cls: "universal-suggest-label",
|
||||
text: suggestion.label,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle suggestion selection
|
||||
*/
|
||||
selectSuggestion(
|
||||
suggestion: SuggestOption,
|
||||
evt: MouseEvent | KeyboardEvent
|
||||
): void {
|
||||
const editor = this.context?.editor;
|
||||
const cursor = this.context?.end;
|
||||
|
||||
if (!editor || !cursor) return;
|
||||
|
||||
// Replace the trigger character with the replacement
|
||||
const startPos = { line: cursor.line, ch: cursor.ch - 1 };
|
||||
const endPos = cursor;
|
||||
|
||||
editor.replaceRange(suggestion.replacement, startPos, endPos);
|
||||
|
||||
// Move cursor to after the replacement
|
||||
const newCursor = {
|
||||
line: cursor.line,
|
||||
ch: cursor.ch - 1 + suggestion.replacement.length,
|
||||
};
|
||||
editor.setCursor(newCursor);
|
||||
|
||||
// Execute custom action if provided
|
||||
if (suggestion.action) {
|
||||
suggestion.action(editor, newCursor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom suggest option
|
||||
*/
|
||||
addSuggestOption(option: SuggestOption): void {
|
||||
this.suggestOptions.push(option);
|
||||
if (!this.config.triggerChars.includes(option.trigger)) {
|
||||
this.config.triggerChars.push(option.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a suggest option by id
|
||||
*/
|
||||
removeSuggestOption(id: string): void {
|
||||
this.suggestOptions = this.suggestOptions.filter(
|
||||
(option) => option.id !== id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): UniversalSuggestConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(newConfig: Partial<UniversalSuggestConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
}
|
||||
}
|
||||
14
src/components/suggest/index.ts
Normal file
14
src/components/suggest/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export {
|
||||
UniversalEditorSuggest,
|
||||
type SuggestOption,
|
||||
type UniversalSuggestConfig,
|
||||
} from "./UniversalEditorSuggest";
|
||||
export { SuggestManager, type SuggestManagerConfig } from "./SuggestManager";
|
||||
export {
|
||||
createPrioritySuggestOptions,
|
||||
createDateSuggestOptions,
|
||||
createTargetSuggestOptions,
|
||||
createTagSuggestOptions,
|
||||
createAllSuggestOptions,
|
||||
getSuggestOptionsByTrigger,
|
||||
} from "./SpecialCharacterSuggests";
|
||||
|
|
@ -67,10 +67,10 @@ function isValidTaskMarkerReplacement(
|
|||
|
||||
// Check if user actively selected text before replacement
|
||||
const startSelection = tr.startState.selection.main;
|
||||
const hasUserSelection = !startSelection.empty;
|
||||
const hasUserSelection = startSelection && !startSelection.empty;
|
||||
|
||||
// If user had a selection that covers the replacement range, this is intentional replacement
|
||||
if (hasUserSelection && fromA >= startSelection.from && toA <= startSelection.to) {
|
||||
if (hasUserSelection && startSelection && fromA >= startSelection.from && toA <= startSelection.to) {
|
||||
console.log(
|
||||
`User selection detected (${startSelection.from}-${startSelection.to}) covering replacement range (${fromA}-${toA}). Skipping automatic cycling as this is user-intended replacement.`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ interface MarkdownEditorProps {
|
|||
value?: string;
|
||||
cls?: string;
|
||||
placeholder?: string;
|
||||
singleLine?: boolean; // New option for single line mode
|
||||
|
||||
onEnter: (
|
||||
editor: EmbeddableMarkdownEditor,
|
||||
|
|
@ -77,6 +78,7 @@ interface MarkdownEditorProps {
|
|||
const defaultProperties: MarkdownEditorProps = {
|
||||
cursorLocation: { anchor: 0, head: 0 },
|
||||
value: "",
|
||||
singleLine: false,
|
||||
cls: "",
|
||||
placeholder: "",
|
||||
|
||||
|
|
@ -178,50 +180,73 @@ export class EmbeddableMarkdownEditor {
|
|||
);
|
||||
|
||||
// Add keyboard handlers
|
||||
const keyBindings = [
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
return self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
false
|
||||
);
|
||||
},
|
||||
shift: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
true
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
true,
|
||||
false
|
||||
),
|
||||
shift: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
true,
|
||||
true
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
run: () => {
|
||||
self.options.onEscape(self);
|
||||
return true;
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
];
|
||||
|
||||
// For single line mode, prevent Enter key from creating new lines
|
||||
if (self.options.singleLine) {
|
||||
keyBindings[0] = {
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
// In single line mode, Enter should trigger onEnter
|
||||
return self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
false
|
||||
);
|
||||
},
|
||||
shift: () => {
|
||||
// Even with shift, still call onEnter in single line mode
|
||||
return self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
true
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
extensions.push(
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: () => {
|
||||
return self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
false
|
||||
);
|
||||
},
|
||||
shift: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
false,
|
||||
true
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
true,
|
||||
false
|
||||
),
|
||||
shift: () =>
|
||||
self.options.onEnter(
|
||||
self,
|
||||
true,
|
||||
true
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
run: () => {
|
||||
self.options.onEscape(self);
|
||||
return true;
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
Prec.highest(keymap.of(keyBindings))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
142
src/editor-ext/taskMarkCleanup.ts
Normal file
142
src/editor-ext/taskMarkCleanup.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension, Transaction } from "@codemirror/state";
|
||||
import { clearAllMarks } from "../components/MarkdownRenderer";
|
||||
|
||||
/**
|
||||
* Extension to handle cleanup of task marks when text is selected and deleted
|
||||
* This ensures that when users select text containing task metadata (like priority marks)
|
||||
* and delete it, the marks are properly cleaned up
|
||||
*/
|
||||
export function taskMarkCleanupExtension(): Extension {
|
||||
return EditorView.updateListener.of((update) => {
|
||||
// Only process transactions that have changes
|
||||
if (!update.docChanged) return;
|
||||
|
||||
// Check if this is a user deletion operation
|
||||
const tr = update.transactions[0];
|
||||
if (!tr || !isUserDeletion(tr)) return;
|
||||
|
||||
// Process each change to see if we need to clean up marks
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
// Only handle deletions (where text was removed)
|
||||
if (fromA >= toA) return;
|
||||
|
||||
const deletedText = tr.startState.doc.sliceString(fromA, toA);
|
||||
const insertedText = inserted.toString();
|
||||
|
||||
// Check if the deleted text contains task marks
|
||||
if (containsTaskMarks(deletedText)) {
|
||||
// Get the line containing the change
|
||||
const line = update.state.doc.lineAt(fromB);
|
||||
const lineText = line.text;
|
||||
|
||||
// Check if this is a task line
|
||||
if (isTaskLine(lineText)) {
|
||||
// Clean the line of any orphaned marks
|
||||
const cleanedLine = cleanOrphanedMarks(lineText);
|
||||
|
||||
if (cleanedLine !== lineText) {
|
||||
// Apply the cleanup
|
||||
update.view.dispatch({
|
||||
changes: {
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
insert: cleanedLine
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transaction represents a user deletion operation
|
||||
*/
|
||||
function isUserDeletion(tr: Transaction): boolean {
|
||||
// Check if this is a user input event
|
||||
if (!tr.isUserEvent("input.delete") && !tr.isUserEvent("input.deleteBackward")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there are actual deletions
|
||||
let hasDeletions = false;
|
||||
tr.changes.iterChanges((fromA, toA) => {
|
||||
if (fromA < toA) {
|
||||
hasDeletions = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasDeletions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains task marks that might need cleanup
|
||||
*/
|
||||
function containsTaskMarks(text: string): boolean {
|
||||
// Check for priority marks
|
||||
const priorityRegex = /(?:🔺|⏫|🔼|🔽|⏬️|\[#[A-C]\]|!)/;
|
||||
if (priorityRegex.test(text)) return true;
|
||||
|
||||
// Check for date marks
|
||||
const dateRegex = /(?:📅|🛫|⏳|✅|➕|❌)/;
|
||||
if (dateRegex.test(text)) return true;
|
||||
|
||||
// Check for other metadata marks
|
||||
const metadataRegex = /(?:🆔|⛔|🏁|🔁|@|#)/;
|
||||
if (metadataRegex.test(text)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a task line
|
||||
*/
|
||||
function isTaskLine(line: string): boolean {
|
||||
const taskRegex = /^\s*[-*+]\s*\[[^\]]*\]/;
|
||||
return taskRegex.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean orphaned marks from a task line
|
||||
* This removes marks that are no longer properly associated with content
|
||||
*/
|
||||
function cleanOrphanedMarks(line: string): string {
|
||||
// First, extract the task marker part
|
||||
const taskMarkerMatch = line.match(/^(\s*[-*+]\s*\[[^\]]*\]\s*)/);
|
||||
if (!taskMarkerMatch) return line;
|
||||
|
||||
const taskMarker = taskMarkerMatch[1];
|
||||
const content = line.substring(taskMarker.length);
|
||||
|
||||
// Use the existing clearAllMarks function to clean the content
|
||||
const cleanedContent = clearAllMarks(content);
|
||||
|
||||
// If the content is now empty or just whitespace, remove orphaned marks
|
||||
if (!cleanedContent.trim()) {
|
||||
// Remove any trailing marks that are now orphaned
|
||||
const cleanedLine = taskMarker.trim();
|
||||
return cleanedLine;
|
||||
}
|
||||
|
||||
// Reconstruct the line with cleaned content
|
||||
return taskMarker + cleanedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if marks in the line are orphaned (not properly associated with content)
|
||||
*/
|
||||
function hasOrphanedMarks(line: string): boolean {
|
||||
// Extract content after task marker
|
||||
const taskMarkerMatch = line.match(/^\s*[-*+]\s*\[[^\]]*\]\s*(.*)/);
|
||||
if (!taskMarkerMatch) return false;
|
||||
|
||||
const content = taskMarkerMatch[1];
|
||||
|
||||
// Check if there are marks but no meaningful content
|
||||
const hasMarks = containsTaskMarks(content);
|
||||
const hasContent = content.replace(/[🔺⏫🔼🔽⏬️📅🛫⏳✅➕❌🆔⛔🏁🔁@#!\[\]]/g, '').trim().length > 0;
|
||||
|
||||
return hasMarks && !hasContent;
|
||||
}
|
||||
73
src/index.ts
73
src/index.ts
|
|
@ -65,6 +65,9 @@ import {
|
|||
} from "./editor-ext/filterTasks";
|
||||
import { Task } from "./types/task";
|
||||
import { QuickCaptureModal } from "./components/QuickCaptureModal";
|
||||
import { MinimalQuickCaptureModal } from "./components/MinimalQuickCaptureModal";
|
||||
import { MinimalQuickCaptureSuggest } from "./components/MinimalQuickCaptureSuggest";
|
||||
import { SuggestManager } from "./components/suggest";
|
||||
import { MarkdownView } from "obsidian";
|
||||
import { Notice } from "obsidian";
|
||||
import { t } from "./translations/helper";
|
||||
|
|
@ -90,6 +93,7 @@ import { monitorTaskCompletedExtension } from "./editor-ext/monitorTaskCompleted
|
|||
import { sortTasksInDocument } from "./commands/sortTaskCommands";
|
||||
import { taskGutterExtension } from "./editor-ext/TaskGutterHandler";
|
||||
import { autoDateManagerExtension } from "./editor-ext/autoDateManager";
|
||||
import { taskMarkCleanupExtension } from "./editor-ext/taskMarkCleanup";
|
||||
import { ViewManager } from "./pages/ViewManager";
|
||||
import { IcsManager } from "./utils/ics/IcsManager";
|
||||
import { VersionManager } from "./utils/VersionManager";
|
||||
|
|
@ -183,6 +187,12 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
// ICS manager instance
|
||||
icsManager: IcsManager;
|
||||
|
||||
// Minimal quick capture suggest
|
||||
minimalQuickCaptureSuggest: MinimalQuickCaptureSuggest;
|
||||
|
||||
// Global suggest manager
|
||||
globalSuggestManager: SuggestManager;
|
||||
|
||||
// Version manager instance
|
||||
versionManager: VersionManager;
|
||||
|
||||
|
|
@ -213,6 +223,9 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
this.versionManager = new VersionManager(this.app, this);
|
||||
this.addChild(this.versionManager);
|
||||
|
||||
// Initialize global suggest manager
|
||||
this.globalSuggestManager = new SuggestManager(this.app, this);
|
||||
|
||||
// Initialize rebuild progress manager
|
||||
this.rebuildProgressManager = new RebuildProgressManager();
|
||||
|
||||
|
|
@ -534,6 +547,16 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Add command for minimal quick capture
|
||||
this.addCommand({
|
||||
id: "minimal-quick-capture",
|
||||
name: t("Minimal Quick Capture"),
|
||||
callback: () => {
|
||||
// Create a minimal modal for quick task capture
|
||||
new MinimalQuickCaptureModal(this.app, this).open();
|
||||
},
|
||||
});
|
||||
|
||||
// Add command for toggling task filter
|
||||
this.addCommand({
|
||||
id: "toggle-task-filter",
|
||||
|
|
@ -1011,6 +1034,15 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
]);
|
||||
}
|
||||
|
||||
// Initialize minimal quick capture suggest
|
||||
if (this.settings.quickCapture.enableMinimalMode) {
|
||||
this.minimalQuickCaptureSuggest = new MinimalQuickCaptureSuggest(
|
||||
this.app,
|
||||
this
|
||||
);
|
||||
this.registerEditorSuggest(this.minimalQuickCaptureSuggest);
|
||||
}
|
||||
|
||||
// Add task filter extension
|
||||
if (this.settings.taskFilter.enableTaskFilter) {
|
||||
this.registerEditorExtension([taskFilterExtension(this)]);
|
||||
|
|
@ -1022,9 +1054,17 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
autoDateManagerExtension(this.app, this),
|
||||
]);
|
||||
}
|
||||
|
||||
// Add task mark cleanup extension (always enabled)
|
||||
this.registerEditorExtension([taskMarkCleanupExtension()]);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Clean up global suggest manager
|
||||
if (this.globalSuggestManager) {
|
||||
this.globalSuggestManager.cleanup();
|
||||
}
|
||||
|
||||
// Clean up task manager when plugin is unloaded
|
||||
if (this.taskManager) {
|
||||
this.taskManager.onunload();
|
||||
|
|
@ -1035,36 +1075,37 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
|
||||
async loadSettings() {
|
||||
const savedData = await this.loadData();
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
savedData
|
||||
);
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, savedData);
|
||||
|
||||
// Migrate old inheritance settings to new structure
|
||||
this.migrateInheritanceSettings(savedData);
|
||||
}
|
||||
|
||||
private migrateInheritanceSettings(savedData: any) {
|
||||
// Check if old inheritance settings exist and new ones don't
|
||||
if (savedData?.projectConfig?.metadataConfig &&
|
||||
!savedData?.fileMetadataInheritance) {
|
||||
|
||||
if (
|
||||
savedData?.projectConfig?.metadataConfig &&
|
||||
!savedData?.fileMetadataInheritance
|
||||
) {
|
||||
const oldConfig = savedData.projectConfig.metadataConfig;
|
||||
|
||||
|
||||
// Migrate to new structure
|
||||
this.settings.fileMetadataInheritance = {
|
||||
enabled: true,
|
||||
inheritFromFrontmatter: oldConfig.inheritFromFrontmatter ?? true,
|
||||
inheritFromFrontmatterForSubtasks: oldConfig.inheritFromFrontmatterForSubtasks ?? false
|
||||
inheritFromFrontmatter:
|
||||
oldConfig.inheritFromFrontmatter ?? true,
|
||||
inheritFromFrontmatterForSubtasks:
|
||||
oldConfig.inheritFromFrontmatterForSubtasks ?? false,
|
||||
};
|
||||
|
||||
|
||||
// Remove old inheritance settings from project config
|
||||
if (this.settings.projectConfig?.metadataConfig) {
|
||||
delete (this.settings.projectConfig.metadataConfig as any).inheritFromFrontmatter;
|
||||
delete (this.settings.projectConfig.metadataConfig as any).inheritFromFrontmatterForSubtasks;
|
||||
delete (this.settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatter;
|
||||
delete (this.settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatterForSubtasks;
|
||||
}
|
||||
|
||||
|
||||
// Save the migrated settings
|
||||
this.saveSettings();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,3 +246,5 @@ settings:
|
|||
default-light: '#f1f1f1'
|
||||
default-dark: '#f1f1f1'
|
||||
*/
|
||||
|
||||
@import url("universal-suggest.css");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,133 @@
|
|||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
/* Minimal Quick Capture Modal */
|
||||
.quick-capture-modal.minimal {
|
||||
max-width: 600px;
|
||||
min-width: 500px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.quick-capture-minimal-editor-container {
|
||||
padding: var(--size-4-2);
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.quick-capture-minimal-editor-container .cm-editor {
|
||||
font-size: var(--font-text-size);
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
padding: var(--size-2-1);
|
||||
}
|
||||
|
||||
.quick-capture-minimal-editor-container .cm-editor.cm-focused {
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--interactive-accent-alpha);
|
||||
}
|
||||
|
||||
.quick-capture-minimal-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--size-4-2);
|
||||
}
|
||||
|
||||
.quick-actions-left {
|
||||
display: flex;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.quick-actions-right {
|
||||
display: flex;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.quick-action-button.active {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.quick-action-save {
|
||||
padding: var(--size-2-1) var(--size-4-2);
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.quick-capture-tag-input {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 300px;
|
||||
padding: var(--size-2-1);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-text-size);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Minimal Quick Capture Suggest */
|
||||
.minimal-quick-capture-suggestion {
|
||||
padding: var(--size-2-1) var(--size-4-2);
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.minimal-quick-capture-suggestion:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.minimal-quick-capture-suggestion.is-selected {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.minimal-quick-capture-suggestion.is-selected .suggestion-label {
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.minimal-quick-capture-suggestion.is-selected .suggestion-description {
|
||||
color: var(--text-on-accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.suggestion-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
font-size: 16px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.suggestion-label {
|
||||
font-size: var(--font-text-size);
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.suggestion-description {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.quick-capture-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
92
src/styles/universal-suggest.css
Normal file
92
src/styles/universal-suggest.css
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/* Universal Suggest Styles */
|
||||
|
||||
.universal-suggest-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.universal-suggest-item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.universal-suggest-item.is-selected {
|
||||
background-color: var(--background-modifier-active-hover);
|
||||
}
|
||||
|
||||
.universal-suggest-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.universal-suggest-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.universal-suggest-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.universal-suggest-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.universal-suggest-description {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Special character trigger highlighting */
|
||||
.cm-editor .cm-line .universal-suggest-trigger {
|
||||
background-color: var(--background-modifier-accent);
|
||||
color: var(--text-accent);
|
||||
border-radius: 2px;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
/* Suggest popup container */
|
||||
.suggestion-container .universal-suggest-item {
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.suggestion-container .universal-suggest-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
.theme-dark .universal-suggest-item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.theme-dark .universal-suggest-item.is-selected {
|
||||
background-color: var(--background-modifier-active-hover);
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.universal-suggest-item {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.universal-suggest-item:hover,
|
||||
.universal-suggest-item.is-selected {
|
||||
border-color: var(--text-accent);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -65,7 +65,12 @@ class TranslationManager {
|
|||
private lowercaseKeyMap: Map<string, Map<string, string>> = new Map();
|
||||
|
||||
private constructor() {
|
||||
this.currentLocale = moment.locale();
|
||||
// Handle test environment where moment might not be properly mocked
|
||||
try {
|
||||
this.currentLocale = moment.locale();
|
||||
} catch (error) {
|
||||
this.currentLocale = "en"; // fallback for test environment
|
||||
}
|
||||
|
||||
// Initialize with all supported translations
|
||||
Object.entries(SUPPORTED_LOCALES).forEach(([locale, translations]) => {
|
||||
|
|
|
|||
7
src/types/task.d.ts
vendored
7
src/types/task.d.ts
vendored
|
|
@ -109,7 +109,6 @@ export interface CanvasTaskMetadata extends StandardTaskMetadata {
|
|||
|
||||
/** Source type to distinguish canvas tasks */
|
||||
sourceType?: "canvas" | "markdown";
|
||||
|
||||
}
|
||||
|
||||
/** Task Genius Project interface */
|
||||
|
|
@ -213,6 +212,12 @@ export interface TaskCache {
|
|||
|
||||
/** Priority index: priority -> Set<taskIds> */
|
||||
priority: Map<number, Set<string>>;
|
||||
|
||||
/** File modification times: filePath -> mtime */
|
||||
fileMtimes: Map<string, number>;
|
||||
|
||||
/** File processed times: filePath -> processedTime */
|
||||
fileProcessedTimes: Map<string, number>;
|
||||
}
|
||||
|
||||
/** Task filter interface for querying tasks */
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ export class ProjectConfigManager {
|
|||
private configCache = new Map<string, ProjectConfigData>();
|
||||
private lastModifiedCache = new Map<string, number>();
|
||||
|
||||
// Cache for file metadata (frontmatter)
|
||||
private fileMetadataCache = new Map<string, Record<string, any>>();
|
||||
private fileMetadataTimestampCache = new Map<string, number>();
|
||||
|
||||
// Cache for enhanced metadata (merged frontmatter + config + mappings)
|
||||
private enhancedMetadataCache = new Map<string, Record<string, any>>();
|
||||
private enhancedMetadataTimestampCache = new Map<string, string>(); // Composite key: fileTime_configTime
|
||||
|
||||
constructor(options: ProjectConfigManagerOptions) {
|
||||
this.vault = options.vault;
|
||||
this.metadataCache = options.metadataCache;
|
||||
|
|
@ -159,7 +167,7 @@ export class ProjectConfigManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get file metadata (frontmatter) for a given file
|
||||
* Get file metadata (frontmatter) for a given file with timestamp caching
|
||||
*/
|
||||
getFileMetadata(filePath: string): Record<string, any> | null {
|
||||
// Early return if enhanced project features are disabled
|
||||
|
|
@ -174,8 +182,26 @@ export class ProjectConfigManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
const currentTimestamp = (file as TFile).stat.mtime;
|
||||
const cachedTimestamp = this.fileMetadataTimestampCache.get(filePath);
|
||||
|
||||
// Check if cache is valid (file hasn't been modified)
|
||||
if (
|
||||
cachedTimestamp === currentTimestamp &&
|
||||
this.fileMetadataCache.has(filePath)
|
||||
) {
|
||||
return this.fileMetadataCache.get(filePath) || null;
|
||||
}
|
||||
|
||||
// Cache miss or file modified - get fresh metadata
|
||||
const metadata = this.metadataCache.getFileCache(file as TFile);
|
||||
return metadata?.frontmatter || null;
|
||||
const frontmatter = metadata?.frontmatter || {};
|
||||
|
||||
// Update cache with fresh data
|
||||
this.fileMetadataCache.set(filePath, frontmatter);
|
||||
this.fileMetadataTimestampCache.set(filePath, currentTimestamp);
|
||||
|
||||
return frontmatter;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get file metadata for ${filePath}:`, error);
|
||||
return null;
|
||||
|
|
@ -261,7 +287,7 @@ export class ProjectConfigManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get enhanced metadata for a file (combines frontmatter and config)
|
||||
* Get enhanced metadata for a file (combines frontmatter and config) with composite caching
|
||||
*/
|
||||
async getEnhancedMetadata(filePath: string): Promise<Record<string, any>> {
|
||||
// Early return if enhanced project features are disabled
|
||||
|
|
@ -269,16 +295,50 @@ export class ProjectConfigManager {
|
|||
return {};
|
||||
}
|
||||
|
||||
const fileMetadata = this.getFileMetadata(filePath) || {};
|
||||
const configData = (await this.getProjectConfig(filePath)) || {};
|
||||
try {
|
||||
// Get file timestamp for cache key
|
||||
const file = this.vault.getFileByPath(filePath);
|
||||
if (!file || !("stat" in file)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Merge metadata, with file metadata taking precedence
|
||||
let mergedMetadata = { ...configData, ...fileMetadata };
|
||||
const fileTimestamp = (file as TFile).stat.mtime;
|
||||
|
||||
// Apply metadata mappings
|
||||
mergedMetadata = this.applyMetadataMappings(mergedMetadata);
|
||||
// Get config file timestamp for cache key
|
||||
const configFile = await this.findProjectConfigFile(filePath);
|
||||
const configTimestamp = configFile ? configFile.stat.mtime : 0;
|
||||
|
||||
return mergedMetadata;
|
||||
// Create composite cache key
|
||||
const cacheKey = `${fileTimestamp}_${configTimestamp}`;
|
||||
const cachedCacheKey = this.enhancedMetadataTimestampCache.get(filePath);
|
||||
|
||||
// Check if cache is valid (neither file nor config has been modified)
|
||||
if (
|
||||
cachedCacheKey === cacheKey &&
|
||||
this.enhancedMetadataCache.has(filePath)
|
||||
) {
|
||||
return this.enhancedMetadataCache.get(filePath) || {};
|
||||
}
|
||||
|
||||
// Cache miss or files modified - compute fresh enhanced metadata
|
||||
const fileMetadata = this.getFileMetadata(filePath) || {};
|
||||
const configData = (await this.getProjectConfig(filePath)) || {};
|
||||
|
||||
// Merge metadata, with file metadata taking precedence
|
||||
let mergedMetadata = { ...configData, ...fileMetadata };
|
||||
|
||||
// Apply metadata mappings
|
||||
mergedMetadata = this.applyMetadataMappings(mergedMetadata);
|
||||
|
||||
// Update cache with fresh data
|
||||
this.enhancedMetadataCache.set(filePath, mergedMetadata);
|
||||
this.enhancedMetadataTimestampCache.set(filePath, cacheKey);
|
||||
|
||||
return mergedMetadata;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get enhanced metadata for ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -292,10 +352,20 @@ export class ProjectConfigManager {
|
|||
this.configCache.delete(configFile.path);
|
||||
this.lastModifiedCache.delete(configFile.path);
|
||||
}
|
||||
|
||||
// Clear file-specific metadata caches
|
||||
this.fileMetadataCache.delete(filePath);
|
||||
this.fileMetadataTimestampCache.delete(filePath);
|
||||
this.enhancedMetadataCache.delete(filePath);
|
||||
this.enhancedMetadataTimestampCache.delete(filePath);
|
||||
} else {
|
||||
// Clear all cache
|
||||
// Clear all caches
|
||||
this.configCache.clear();
|
||||
this.lastModifiedCache.clear();
|
||||
this.fileMetadataCache.clear();
|
||||
this.fileMetadataTimestampCache.clear();
|
||||
this.enhancedMetadataCache.clear();
|
||||
this.enhancedMetadataTimestampCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -662,4 +732,77 @@ export class ProjectConfigManager {
|
|||
): Promise<ProjectConfigData | null> {
|
||||
return await this.getProjectConfig(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache performance statistics and monitoring information
|
||||
*/
|
||||
getCacheStats(): {
|
||||
configCache: {
|
||||
size: number;
|
||||
keys: string[];
|
||||
};
|
||||
fileMetadataCache: {
|
||||
size: number;
|
||||
hitRatio?: number;
|
||||
};
|
||||
enhancedMetadataCache: {
|
||||
size: number;
|
||||
hitRatio?: number;
|
||||
};
|
||||
totalMemoryUsage: {
|
||||
estimatedBytes: number;
|
||||
};
|
||||
} {
|
||||
// Calculate estimated memory usage (rough approximation)
|
||||
const configCacheSize = Array.from(this.configCache.values())
|
||||
.map(config => JSON.stringify(config).length)
|
||||
.reduce((sum, size) => sum + size, 0);
|
||||
|
||||
const fileMetadataCacheSize = Array.from(this.fileMetadataCache.values())
|
||||
.map(metadata => JSON.stringify(metadata).length)
|
||||
.reduce((sum, size) => sum + size, 0);
|
||||
|
||||
const enhancedMetadataCacheSize = Array.from(this.enhancedMetadataCache.values())
|
||||
.map(metadata => JSON.stringify(metadata).length)
|
||||
.reduce((sum, size) => sum + size, 0);
|
||||
|
||||
const totalMemoryUsage = configCacheSize + fileMetadataCacheSize + enhancedMetadataCacheSize;
|
||||
|
||||
return {
|
||||
configCache: {
|
||||
size: this.configCache.size,
|
||||
keys: Array.from(this.configCache.keys()),
|
||||
},
|
||||
fileMetadataCache: {
|
||||
size: this.fileMetadataCache.size,
|
||||
},
|
||||
enhancedMetadataCache: {
|
||||
size: this.enhancedMetadataCache.size,
|
||||
},
|
||||
totalMemoryUsage: {
|
||||
estimatedBytes: totalMemoryUsage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stale cache entries based on file modification times
|
||||
*/
|
||||
async clearStaleEntries(): Promise<number> {
|
||||
let clearedCount = 0;
|
||||
|
||||
// Check file metadata cache for stale entries
|
||||
for (const [filePath, timestamp] of this.fileMetadataTimestampCache.entries()) {
|
||||
const file = this.vault.getFileByPath(filePath);
|
||||
if (!file || !("stat" in file) || (file as TFile).stat.mtime !== timestamp) {
|
||||
this.fileMetadataCache.delete(filePath);
|
||||
this.fileMetadataTimestampCache.delete(filePath);
|
||||
this.enhancedMetadataCache.delete(filePath);
|
||||
this.enhancedMetadataTimestampCache.delete(filePath);
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return clearedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -548,6 +548,8 @@ export class ProjectDataWorkerManager {
|
|||
*/
|
||||
clearCache(filePath?: string): void {
|
||||
this.cache.clearCache(filePath);
|
||||
// Also clear ProjectConfigManager cache to ensure consistency
|
||||
this.projectConfigManager.clearCache(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -130,24 +130,6 @@ export class TaskGeniusIconManager extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
css =
|
||||
`
|
||||
input[type=checkbox]:checked {
|
||||
background-color: unset;
|
||||
border: none;
|
||||
}
|
||||
input[type=checkbox] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
input[type=checkbox]:checked:hover {
|
||||
background-color: unset;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
` + css;
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
|
|
@ -282,11 +264,20 @@ input[type=checkbox] {
|
|||
|
||||
if (!isSpace) {
|
||||
return `
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:checked,
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:checked,
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox]:checked {
|
||||
--checkbox-color: ${fillColor};
|
||||
--checkbox-color-hover: ${fillColor};
|
||||
|
||||
background-color: unset;
|
||||
border: none;
|
||||
}
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:checked:after,
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:checked:after,
|
||||
|
|
@ -298,12 +289,20 @@ input[type=checkbox] {
|
|||
`;
|
||||
} else {
|
||||
return `
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox],
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"][type=checkbox] {
|
||||
--checkbox-color: ${fillColor};
|
||||
--checkbox-color-hover: ${fillColor};
|
||||
|
||||
background-color: unset;
|
||||
border: none;
|
||||
}
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > input[type=checkbox]:after,
|
||||
.${this.BODY_CLASS} [data-task="${escapedChar}"] > p > input[type=checkbox]:after,
|
||||
|
|
@ -312,8 +311,8 @@ input[type=checkbox] {
|
|||
top: -1px;
|
||||
inset-inline-start: -1px;
|
||||
position: absolute;
|
||||
width: var(--checkbox-size);
|
||||
height: var(--checkbox-size);
|
||||
width: calc(var(--checkbox-size) * 1.2);
|
||||
height: calc(var(--checkbox-size) * 1.2);
|
||||
display: block;
|
||||
-webkit-mask-position: 52% 52%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ export class TaskManager extends Component {
|
|||
settings: this.plugin.settings,
|
||||
}
|
||||
);
|
||||
// Set task indexer reference for cache checking
|
||||
this.workerManager.setTaskIndexer(this.indexer);
|
||||
this.log("Worker manager initialized");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize worker manager:", error);
|
||||
|
|
@ -663,6 +665,7 @@ export class TaskManager extends Component {
|
|||
this.indexer.updateIndexWithTasks(
|
||||
filePath,
|
||||
cacheItem.data
|
||||
// Note: mtime not available here, will be set when file is processed
|
||||
);
|
||||
this.log(
|
||||
`Preloaded ${cacheItem.data.length} tasks from cache for ${filePath}`
|
||||
|
|
@ -855,7 +858,8 @@ export class TaskManager extends Component {
|
|||
// Update index with cached data
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
cached.data
|
||||
cached.data,
|
||||
file.stat.mtime
|
||||
);
|
||||
this.log(
|
||||
`Loaded ${cached.data.length} tasks from cache for ${file.path}`
|
||||
|
|
@ -988,7 +992,11 @@ export class TaskManager extends Component {
|
|||
const tasks = await this.workerManager.processFile(file);
|
||||
|
||||
// Update the index with the tasks
|
||||
this.indexer.updateIndexWithTasks(file.path, tasks);
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
tasks,
|
||||
file.stat.mtime
|
||||
);
|
||||
|
||||
// Store tasks in cache if there are any
|
||||
if (tasks.length > 0) {
|
||||
|
|
@ -1039,7 +1047,11 @@ export class TaskManager extends Component {
|
|||
|
||||
console.log("tasks", tasks, file.path);
|
||||
// Update the index with the tasks
|
||||
this.indexer.updateIndexWithTasks(file.path, tasks);
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
tasks,
|
||||
file.stat.mtime
|
||||
);
|
||||
|
||||
// Store tasks in cache if there are any
|
||||
if (tasks.length > 0) {
|
||||
|
|
@ -1125,7 +1137,8 @@ export class TaskManager extends Component {
|
|||
// Update index with cached data
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
cached.data
|
||||
cached.data,
|
||||
file.stat.mtime
|
||||
);
|
||||
this.log(
|
||||
`Loaded ${cached.data.length} tasks from cache for ${file.path}`
|
||||
|
|
@ -1157,7 +1170,11 @@ export class TaskManager extends Component {
|
|||
);
|
||||
|
||||
// Update index with parsed tasks
|
||||
this.indexer.updateIndexWithTasks(file.path, tasks);
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
tasks,
|
||||
file.stat.mtime
|
||||
);
|
||||
|
||||
// Store to cache
|
||||
if (tasks.length > 0) {
|
||||
|
|
@ -1189,7 +1206,11 @@ export class TaskManager extends Component {
|
|||
file.path,
|
||||
content
|
||||
);
|
||||
this.indexer.updateIndexWithTasks(file.path, tasks);
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
tasks,
|
||||
file.stat.mtime
|
||||
);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await this.persister.storeFile(file.path, tasks);
|
||||
|
|
@ -1295,7 +1316,11 @@ export class TaskManager extends Component {
|
|||
);
|
||||
|
||||
// Update index with parsed tasks
|
||||
this.indexer.updateIndexWithTasks(file.path, tasks);
|
||||
this.indexer.updateIndexWithTasks(
|
||||
file.path,
|
||||
tasks,
|
||||
file.stat.mtime
|
||||
);
|
||||
|
||||
// Cache the results
|
||||
if (tasks.length > 0) {
|
||||
|
|
@ -1350,7 +1375,7 @@ export class TaskManager extends Component {
|
|||
* Remove a file from the index based on the old path
|
||||
*/
|
||||
private removeFileFromIndexByOldPath(oldPath: string): void {
|
||||
this.indexer.updateIndexWithTasks(oldPath, []);
|
||||
this.indexer.cleanupFileCache(oldPath);
|
||||
try {
|
||||
this.persister.removeFile(oldPath);
|
||||
this.log(`Removed ${oldPath} from cache`);
|
||||
|
|
@ -1370,7 +1395,7 @@ export class TaskManager extends Component {
|
|||
*/
|
||||
private removeFileFromIndex(file: TFile): void {
|
||||
// 使用 indexer 的方法来删除文件
|
||||
this.indexer.updateIndexWithTasks(file.path, []);
|
||||
this.indexer.cleanupFileCache(file.path);
|
||||
|
||||
// 从缓存中删除文件
|
||||
try {
|
||||
|
|
@ -2887,8 +2912,38 @@ export class TaskManager extends Component {
|
|||
/**
|
||||
* Force reindex all tasks by clearing all current indices and rebuilding from scratch
|
||||
*/
|
||||
public async forceReindex(): Promise<void> {
|
||||
this.log("Force reindexing all tasks");
|
||||
/**
|
||||
* Force reindex all tasks with optional cache strategy
|
||||
* @param options - Optional configuration for cache clearing behavior
|
||||
*/
|
||||
public async forceReindex(options?: {
|
||||
clearProjectCaches?: boolean; // Whether to clear project-related caches (default: true)
|
||||
preserveValidCaches?: boolean; // Whether to preserve caches for unchanged files (default: false)
|
||||
logCacheStats?: boolean; // Whether to log cache statistics before/after (default: false)
|
||||
}): Promise<void> {
|
||||
const {
|
||||
clearProjectCaches = true,
|
||||
preserveValidCaches = false,
|
||||
logCacheStats = false,
|
||||
} = options || {};
|
||||
|
||||
this.log(
|
||||
`Force reindexing all tasks (clearProjectCaches: ${clearProjectCaches}, preserveValidCaches: ${preserveValidCaches})`
|
||||
);
|
||||
|
||||
// Log cache statistics before clearing if requested
|
||||
if (logCacheStats && this.taskParsingService) {
|
||||
try {
|
||||
const beforeStats =
|
||||
this.taskParsingService.getDetailedCacheStats();
|
||||
this.log(
|
||||
"Cache statistics before clearing: " +
|
||||
JSON.stringify(beforeStats.summary, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to get cache statistics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset initialization state
|
||||
this.initialized = false;
|
||||
|
|
@ -2896,6 +2951,33 @@ export class TaskManager extends Component {
|
|||
// Clear all caches
|
||||
this.indexer.resetCache();
|
||||
|
||||
// Clear project-related caches based on options
|
||||
if (clearProjectCaches && this.taskParsingService) {
|
||||
try {
|
||||
if (preserveValidCaches) {
|
||||
// Smart cache clearing - only clear stale entries
|
||||
if ((this.taskParsingService as any).projectConfigManager) {
|
||||
const clearedCount = await (
|
||||
this.taskParsingService as any
|
||||
).projectConfigManager.clearStaleEntries();
|
||||
this.log(
|
||||
`Smart cache clearing: removed ${clearedCount} stale entries`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Full cache clearing (default behavior)
|
||||
this.taskParsingService.clearAllCaches();
|
||||
this.log(
|
||||
"Cleared all project-related caches (config, data, metadata)"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error clearing project caches:", error);
|
||||
}
|
||||
} else if (!clearProjectCaches) {
|
||||
this.log("Skipping project cache clearing as requested");
|
||||
}
|
||||
|
||||
// Clear the persister cache
|
||||
try {
|
||||
await this.persister.clear();
|
||||
|
|
@ -2939,6 +3021,20 @@ export class TaskManager extends Component {
|
|||
const finalTaskCount = this.getAllTasks().length;
|
||||
progressManager.completeRebuild(finalTaskCount);
|
||||
|
||||
// Log cache statistics after rebuilding if requested
|
||||
if (logCacheStats && this.taskParsingService) {
|
||||
try {
|
||||
const afterStats =
|
||||
this.taskParsingService.getDetailedCacheStats();
|
||||
this.log(
|
||||
"Cache statistics after rebuilding: " +
|
||||
JSON.stringify(afterStats.summary, null, 2)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to get final cache statistics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger an update event
|
||||
this.app.workspace.trigger(
|
||||
"task-genius:task-cache-updated",
|
||||
|
|
|
|||
|
|
@ -652,10 +652,80 @@ export class TaskParsingService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get cache performance statistics
|
||||
* Clear all caches (project config, project data, and enhanced metadata)
|
||||
* This is designed for scenarios like forceReindex where complete cache clearing is needed
|
||||
*/
|
||||
clearAllCaches(): void {
|
||||
// Clear project configuration caches
|
||||
this.clearProjectConfigCache();
|
||||
|
||||
// Clear project data caches
|
||||
this.clearProjectDataCache();
|
||||
|
||||
// Force clear all ProjectConfigManager caches including our new timestamp caches
|
||||
if (this.projectConfigManager) {
|
||||
// Call clearCache without parameters to clear ALL caches
|
||||
this.projectConfigManager.clearCache();
|
||||
}
|
||||
|
||||
// Force clear all ProjectDataWorkerManager caches
|
||||
if (this.projectDataWorkerManager) {
|
||||
// Call clearCache without parameters to clear ALL caches
|
||||
this.projectDataWorkerManager.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache performance statistics including detailed breakdown
|
||||
*/
|
||||
getProjectDataCacheStats() {
|
||||
return this.projectDataWorkerManager?.getCacheStats();
|
||||
const workerStats = this.projectDataWorkerManager?.getCacheStats();
|
||||
const configStats = this.projectConfigManager?.getCacheStats();
|
||||
|
||||
return {
|
||||
workerManager: workerStats,
|
||||
configManager: configStats,
|
||||
combined: {
|
||||
totalFiles: ((workerStats as any)?.fileCacheSize || 0) + (configStats?.fileMetadataCache.size || 0),
|
||||
totalMemory: (configStats?.totalMemoryUsage.estimatedBytes || 0),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed cache statistics for monitoring and debugging
|
||||
*/
|
||||
getDetailedCacheStats(): {
|
||||
projectConfigManager?: any;
|
||||
projectDataWorkerManager?: any;
|
||||
summary: {
|
||||
totalCachedFiles: number;
|
||||
estimatedMemoryUsage: number;
|
||||
cacheTypes: string[];
|
||||
};
|
||||
} {
|
||||
const configStats = this.projectConfigManager?.getCacheStats();
|
||||
const workerStats = this.projectDataWorkerManager?.getCacheStats();
|
||||
|
||||
const totalFiles = (configStats?.fileMetadataCache.size || 0) +
|
||||
(configStats?.enhancedMetadataCache.size || 0) +
|
||||
((workerStats as any)?.fileCacheSize || 0);
|
||||
|
||||
const cacheTypes = [];
|
||||
if (configStats?.fileMetadataCache.size) cacheTypes.push('fileMetadata');
|
||||
if (configStats?.enhancedMetadataCache.size) cacheTypes.push('enhancedMetadata');
|
||||
if (configStats?.configCache.size) cacheTypes.push('projectConfig');
|
||||
if ((workerStats as any)?.fileCacheSize) cacheTypes.push('projectData');
|
||||
|
||||
return {
|
||||
projectConfigManager: configStats,
|
||||
projectDataWorkerManager: workerStats,
|
||||
summary: {
|
||||
totalCachedFiles: totalFiles,
|
||||
estimatedMemoryUsage: configStats?.totalMemoryUsage.estimatedBytes || 0,
|
||||
cacheTypes,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,6 +2,25 @@ import { App, getFrontMatterInfo, TFile } from "obsidian";
|
|||
import { QuickCaptureOptions } from "../editor-ext/quickCapture";
|
||||
import { moment } from "obsidian";
|
||||
|
||||
/**
|
||||
* Get template file with automatic .md extension detection
|
||||
* @param app - Obsidian app instance
|
||||
* @param templatePath - Template file path (may or may not include .md extension)
|
||||
* @returns TFile instance if found, null otherwise
|
||||
*/
|
||||
function getTemplateFile(app: App, templatePath: string): TFile | null {
|
||||
// First try the original path
|
||||
let templateFile = app.vault.getFileByPath(templatePath);
|
||||
|
||||
if (!templateFile && !templatePath.endsWith(".md")) {
|
||||
// If not found and doesn't end with .md, try adding .md extension
|
||||
const pathWithExtension = `${templatePath}.md`;
|
||||
templateFile = app.vault.getFileByPath(pathWithExtension);
|
||||
}
|
||||
|
||||
return templateFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename by replacing unsafe characters with safe alternatives
|
||||
* This function only sanitizes the filename part, not directory separators
|
||||
|
|
@ -128,17 +147,20 @@ export async function saveCapture(
|
|||
|
||||
// If it's a daily note and has a template, use the template
|
||||
if (targetType === "daily-note" && dailyNoteSettings?.template) {
|
||||
const templateFile = app.vault.getFileByPath(
|
||||
const templateFile = getTemplateFile(
|
||||
app,
|
||||
dailyNoteSettings.template
|
||||
);
|
||||
if (templateFile instanceof TFile) {
|
||||
try {
|
||||
initialContent = await app.vault.read(templateFile);
|
||||
// Process date templates in the template content
|
||||
initialContent = processDateTemplates(initialContent);
|
||||
} catch (e) {
|
||||
console.warn("Failed to read template file:", e);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Template file not found: ${dailyNoteSettings.template} (tried with and without .md extension)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export class TaskIndexer extends Component implements TaskIndexerInterface {
|
|||
onCompletion: new Map<string, Set<string>>(),
|
||||
dependsOn: new Map<string, Set<string>>(),
|
||||
taskId: new Map<string, Set<string>>(),
|
||||
fileMtimes: new Map<string, number>(),
|
||||
fileProcessedTimes: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +203,8 @@ export class TaskIndexer extends Component implements TaskIndexerInterface {
|
|||
* Get the current task cache
|
||||
*/
|
||||
public getCache(): TaskCache {
|
||||
// Ensure cache structure is complete
|
||||
this.ensureCacheStructure(this.taskCache);
|
||||
return this.taskCache;
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +242,11 @@ export class TaskIndexer extends Component implements TaskIndexerInterface {
|
|||
* Update the index with tasks parsed by external components
|
||||
* This is the primary method for updating the index
|
||||
*/
|
||||
public updateIndexWithTasks(filePath: string, tasks: Task[]): void {
|
||||
public updateIndexWithTasks(
|
||||
filePath: string,
|
||||
tasks: Task[],
|
||||
fileMtime?: number
|
||||
): void {
|
||||
// Remove existing tasks for this file first
|
||||
this.removeFileFromIndex(filePath);
|
||||
|
||||
|
|
@ -257,6 +265,11 @@ export class TaskIndexer extends Component implements TaskIndexerInterface {
|
|||
// Update file index
|
||||
this.taskCache.files.set(filePath, fileTaskIds);
|
||||
this.lastIndexTime.set(filePath, Date.now());
|
||||
|
||||
// Update file mtime if provided
|
||||
if (fileMtime !== undefined) {
|
||||
this.updateFileMtime(filePath, fileMtime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1052,10 +1065,105 @@ export class TaskIndexer extends Component implements TaskIndexerInterface {
|
|||
this.taskCache = this.initEmptyCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file has changed since last processing
|
||||
*/
|
||||
public isFileChanged(filePath: string, currentMtime: number): boolean {
|
||||
const lastMtime = this.taskCache.fileMtimes.get(filePath);
|
||||
return lastMtime === undefined || lastMtime < currentMtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last known modification time for a file
|
||||
*/
|
||||
public getFileLastMtime(filePath: string): number | undefined {
|
||||
return this.taskCache.fileMtimes.get(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the modification time for a file
|
||||
*/
|
||||
public updateFileMtime(filePath: string, mtime: number): void {
|
||||
// Ensure Map objects exist before using them
|
||||
if (!this.taskCache.fileMtimes) {
|
||||
this.taskCache.fileMtimes = new Map<string, number>();
|
||||
}
|
||||
if (!this.taskCache.fileProcessedTimes) {
|
||||
this.taskCache.fileProcessedTimes = new Map<string, number>();
|
||||
}
|
||||
|
||||
this.taskCache.fileMtimes.set(filePath, mtime);
|
||||
this.taskCache.fileProcessedTimes.set(filePath, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have valid cache for a file
|
||||
*/
|
||||
public hasValidCache(filePath: string, currentMtime: number): boolean {
|
||||
// Check if file has tasks in cache
|
||||
const hasTasksInCache = this.taskCache.files.has(filePath);
|
||||
|
||||
// Check if file hasn't changed
|
||||
const hasNotChanged = !this.isFileChanged(filePath, currentMtime);
|
||||
|
||||
return hasTasksInCache && hasNotChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up cache for a specific file
|
||||
*/
|
||||
public cleanupFileCache(filePath: string): void {
|
||||
// Remove from file mtime cache
|
||||
this.taskCache.fileMtimes.delete(filePath);
|
||||
this.taskCache.fileProcessedTimes.delete(filePath);
|
||||
|
||||
// Remove from other caches (handled by existing removeFileFromIndex)
|
||||
this.removeFileFromIndex(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cache consistency and fix any issues
|
||||
*/
|
||||
public validateCacheConsistency(): void {
|
||||
// Check for files in mtime cache but not in file index
|
||||
for (const filePath of this.taskCache.fileMtimes.keys()) {
|
||||
if (!this.taskCache.files.has(filePath)) {
|
||||
this.taskCache.fileMtimes.delete(filePath);
|
||||
this.taskCache.fileProcessedTimes.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for files in file index but not in mtime cache
|
||||
for (const filePath of this.taskCache.files.keys()) {
|
||||
if (!this.taskCache.fileMtimes.has(filePath)) {
|
||||
// This is acceptable - mtime might not be set for older cache entries
|
||||
// We don't need to remove the file from index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure cache structure is complete
|
||||
*/
|
||||
private ensureCacheStructure(cache: TaskCache): void {
|
||||
// Ensure fileMtimes exists
|
||||
if (!cache.fileMtimes) {
|
||||
cache.fileMtimes = new Map<string, number>();
|
||||
}
|
||||
|
||||
// Ensure fileProcessedTimes exists
|
||||
if (!cache.fileProcessedTimes) {
|
||||
cache.fileProcessedTimes = new Map<string, number>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cache from an external source (e.g. persisted cache)
|
||||
*/
|
||||
public setCache(cache: TaskCache): void {
|
||||
// Ensure cache structure is complete
|
||||
this.ensureCacheStructure(cache);
|
||||
|
||||
this.taskCache = cache;
|
||||
|
||||
// Update lastIndexTime for all files in the cache
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ export class MarkdownTaskParser {
|
|||
this.config = config;
|
||||
}
|
||||
|
||||
// Public alias for extractMetadataAndTags
|
||||
public extractMetadataAndTags(content: string): [string, Record<string, string>, string[]] {
|
||||
return this.extractMetadataAndTagsInternal(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create parser with predefined status mapping
|
||||
*/
|
||||
|
|
@ -118,7 +123,7 @@ export class MarkdownTaskParser {
|
|||
const completed = rawStatus.toLowerCase() === "x";
|
||||
const status = this.getStatusFromMapping(rawStatus);
|
||||
const [cleanedContent, metadata, tags] =
|
||||
this.extractMetadataAndTags(taskContent);
|
||||
this.extractMetadataAndTagsInternal(taskContent);
|
||||
|
||||
// Inherit metadata from file frontmatter
|
||||
// A task is a subtask if it has a parent
|
||||
|
|
@ -332,7 +337,7 @@ export class MarkdownTaskParser {
|
|||
return [content, " "];
|
||||
}
|
||||
|
||||
private extractMetadataAndTags(
|
||||
private extractMetadataAndTagsInternal(
|
||||
content: string
|
||||
): [string, Record<string, string>, string[]] {
|
||||
const metadata: Record<string, string> = {};
|
||||
|
|
@ -1550,3 +1555,52 @@ export class MarkdownTaskParser {
|
|||
return inherited;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurableTaskParser extends MarkdownTaskParser {
|
||||
constructor(config?: Partial<TaskParserConfig>) {
|
||||
// Default configuration
|
||||
const defaultConfig: TaskParserConfig = {
|
||||
parseMetadata: true,
|
||||
parseTags: true,
|
||||
parseComments: true,
|
||||
parseHeadings: true,
|
||||
maxIndentSize: 100,
|
||||
maxParseIterations: 100,
|
||||
maxMetadataIterations: 50,
|
||||
maxTagLength: 50,
|
||||
maxEmojiValueLength: 50,
|
||||
maxStackOperations: 1000,
|
||||
maxStackSize: 50,
|
||||
statusMapping: {
|
||||
"TODO": " ",
|
||||
"IN_PROGRESS": "/",
|
||||
"DONE": "x",
|
||||
"CANCELLED": "-"
|
||||
},
|
||||
emojiMapping: {
|
||||
"📅": "dueDate",
|
||||
"🛫": "startDate",
|
||||
"⏳": "scheduledDate",
|
||||
"✅": "completedDate",
|
||||
"➕": "createdDate",
|
||||
"❌": "cancelledDate",
|
||||
"🆔": "id",
|
||||
"⛔": "dependsOn",
|
||||
"🏁": "onCompletion",
|
||||
"🔁": "repeat",
|
||||
"🔺": "priority",
|
||||
"⏫": "priority",
|
||||
"🔼": "priority",
|
||||
"🔽": "priority",
|
||||
"⏬": "priority"
|
||||
},
|
||||
metadataParseMode: MetadataParseMode.Both,
|
||||
specialTagPrefixes: {
|
||||
"project": "project",
|
||||
"@": "context"
|
||||
}
|
||||
};
|
||||
|
||||
super({ ...defaultConfig, ...config });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,11 +320,13 @@ function processFile(
|
|||
|
||||
// Add file metadata tasks if file parsing is enabled and file type supports it
|
||||
// Only apply file metadata parsing to Markdown files, not Canvas files
|
||||
// Also check if fileMetadataInheritance is enabled for task metadata inheritance
|
||||
if (
|
||||
fileExtension === SupportedFileType.MARKDOWN &&
|
||||
settings.fileParsingConfig &&
|
||||
(settings.fileParsingConfig.enableFileMetadataParsing ||
|
||||
settings.fileParsingConfig.enableTagBasedTaskParsing)
|
||||
settings.fileParsingConfig.enableTagBasedTaskParsing ||
|
||||
settings.fileMetadataInheritance?.enabled)
|
||||
) {
|
||||
try {
|
||||
const fileMetadataParser = new FileMetadataTaskParser(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { CachedMetadata, FileStats, ListItemCache } from "obsidian";
|
||||
import { Task } from "../../types/task";
|
||||
import { MetadataFormat } from "../taskUtil";
|
||||
import { FileParsingConfiguration } from "../../common/setting-definition";
|
||||
import { FileParsingConfiguration, FileMetadataInheritanceConfig } from "../../common/setting-definition";
|
||||
|
||||
/**
|
||||
* Command to parse tasks from a file
|
||||
|
|
@ -217,6 +217,9 @@ export type TaskWorkerSettings = {
|
|||
|
||||
// File parsing configuration for metadata and tag-based task extraction
|
||||
fileParsingConfig?: FileParsingConfiguration;
|
||||
|
||||
// File metadata inheritance configuration
|
||||
fileMetadataInheritance?: FileMetadataInheritanceConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ import {
|
|||
TaskParseResult,
|
||||
} from "./TaskIndexWorkerMessage";
|
||||
import { FileMetadataTaskParser } from "./FileMetadataTaskParser";
|
||||
import { FileParsingConfiguration } from "../../common/setting-definition";
|
||||
import {
|
||||
FileParsingConfiguration,
|
||||
FileMetadataInheritanceConfig,
|
||||
} from "../../common/setting-definition";
|
||||
|
||||
// Import worker and utilities
|
||||
// @ts-ignore Ignore type error for worker import
|
||||
|
|
@ -48,6 +51,7 @@ export interface WorkerPoolOptions {
|
|||
ignoreHeading: string;
|
||||
focusHeading: string;
|
||||
fileParsingConfig?: FileParsingConfiguration;
|
||||
fileMetadataInheritance?: FileMetadataInheritanceConfig;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +70,8 @@ export const DEFAULT_WORKER_OPTIONS: WorkerPoolOptions = {
|
|||
dailyNotePath: "",
|
||||
ignoreHeading: "",
|
||||
focusHeading: "",
|
||||
fileParsingConfig: undefined,
|
||||
fileMetadataInheritance: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -106,6 +112,8 @@ interface TaskMetadata {
|
|||
mtime: number;
|
||||
size: number;
|
||||
};
|
||||
/** Whether this metadata came from cache */
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,6 +160,14 @@ export class TaskWorkerManager extends Component {
|
|||
private fileMetadataParser?: FileMetadataTaskParser;
|
||||
/** Whether workers have been initialized to prevent multiple initialization */
|
||||
private initialized: boolean = false;
|
||||
/** Reference to task indexer for cache checking */
|
||||
private taskIndexer?: any;
|
||||
/** Performance statistics */
|
||||
private stats = {
|
||||
filesSkipped: 0,
|
||||
filesProcessed: 0,
|
||||
cacheHitRatio: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new worker pool
|
||||
|
|
@ -196,6 +212,7 @@ export class TaskWorkerManager extends Component {
|
|||
ignoreHeading: "",
|
||||
focusHeading: "",
|
||||
fileParsingConfig: config,
|
||||
fileMetadataInheritance: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -300,6 +317,73 @@ export class TaskWorkerManager extends Component {
|
|||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the task indexer reference for cache checking
|
||||
*/
|
||||
public setTaskIndexer(taskIndexer: any): void {
|
||||
this.taskIndexer = taskIndexer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache hit ratio statistics
|
||||
*/
|
||||
private updateCacheHitRatio(): void {
|
||||
const totalFiles = this.stats.filesSkipped + this.stats.filesProcessed;
|
||||
this.stats.cacheHitRatio =
|
||||
totalFiles > 0 ? this.stats.filesSkipped / totalFiles : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
public getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be processed (not in valid cache)
|
||||
*/
|
||||
private shouldProcessFile(file: TFile): boolean {
|
||||
if (!this.taskIndexer) {
|
||||
return true; // No indexer, always process
|
||||
}
|
||||
|
||||
// Check if mtime optimization is enabled
|
||||
if (
|
||||
!this.options.settings?.fileParsingConfig?.enableMtimeOptimization
|
||||
) {
|
||||
return true; // Optimization disabled, always process
|
||||
}
|
||||
|
||||
return !this.taskIndexer.hasValidCache(file.path, file.stat.mtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached tasks for a file if available
|
||||
*/
|
||||
private getCachedTasksForFile(filePath: string): Task[] | null {
|
||||
if (!this.taskIndexer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskIds = this.taskIndexer.getCache().files.get(filePath);
|
||||
if (!taskIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tasks: Task[] = [];
|
||||
const taskCache = this.taskIndexer.getCache().tasks;
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const task = taskCache.get(taskId);
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.length > 0 ? tasks : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single file for tasks
|
||||
*/
|
||||
|
|
@ -311,6 +395,19 @@ export class TaskWorkerManager extends Component {
|
|||
let existing = this.outstanding.get(file.path);
|
||||
if (existing) return existing;
|
||||
|
||||
// Check if we can use cached results
|
||||
if (!this.shouldProcessFile(file)) {
|
||||
const cachedTasks = this.getCachedTasksForFile(file.path);
|
||||
if (cachedTasks) {
|
||||
this.stats.filesSkipped++;
|
||||
this.updateCacheHitRatio();
|
||||
this.log(
|
||||
`Using cached tasks for ${file.path} (${cachedTasks.length} tasks)`
|
||||
);
|
||||
return Promise.resolve(cachedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
let promise = deferred<Task[]>();
|
||||
this.outstanding.set(file.path, promise);
|
||||
|
||||
|
|
@ -350,14 +447,44 @@ export class TaskWorkerManager extends Component {
|
|||
return new Map<string, Task[]>();
|
||||
}
|
||||
|
||||
// Pre-filter files: separate cached from uncached
|
||||
const filesToProcess: TFile[] = [];
|
||||
const resultMap = new Map<string, Task[]>();
|
||||
let cachedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (!this.shouldProcessFile(file)) {
|
||||
const cachedTasks = this.getCachedTasksForFile(file.path);
|
||||
if (cachedTasks) {
|
||||
resultMap.set(file.path, cachedTasks);
|
||||
cachedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filesToProcess.push(file);
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Batch processing: ${cachedCount} files from cache, ${
|
||||
filesToProcess.length
|
||||
} files to process (cache hit ratio: ${
|
||||
cachedCount > 0
|
||||
? ((cachedCount / files.length) * 100).toFixed(1)
|
||||
: 0
|
||||
}%)`
|
||||
);
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
return resultMap; // All files were cached
|
||||
}
|
||||
|
||||
this.isProcessingBatch = true;
|
||||
this.processedFiles = 0;
|
||||
this.totalFilesToProcess = files.length;
|
||||
this.totalFilesToProcess = filesToProcess.length;
|
||||
|
||||
this.log(`Processing batch of ${files.length} files`);
|
||||
|
||||
// 创建一个结果映射
|
||||
const resultMap = new Map<string, Task[]>();
|
||||
this.log(
|
||||
`Processing batch of ${filesToProcess.length} files (${cachedCount} cached)`
|
||||
);
|
||||
|
||||
try {
|
||||
// 将文件分成更小的批次,避免一次性提交太多任务
|
||||
|
|
@ -393,8 +520,8 @@ export class TaskWorkerManager extends Component {
|
|||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const subBatch = files.slice(i, i + batchSize);
|
||||
for (let i = 0; i < filesToProcess.length; i += batchSize) {
|
||||
const subBatch = filesToProcess.slice(i, i + batchSize);
|
||||
|
||||
// 为子批次创建处理任务并添加到队列
|
||||
processingQueue.push(async () => {
|
||||
|
|
@ -446,7 +573,9 @@ export class TaskWorkerManager extends Component {
|
|||
console.error("Error during batch processing:", error);
|
||||
} finally {
|
||||
this.isProcessingBatch = false;
|
||||
this.log(`Completed batch processing of ${files.length} files`);
|
||||
this.log(
|
||||
`Completed batch processing of ${files.length} files (${cachedCount} from cache, ${filesToProcess.length} processed)`
|
||||
);
|
||||
}
|
||||
|
||||
return resultMap;
|
||||
|
|
@ -572,6 +701,7 @@ export class TaskWorkerManager extends Component {
|
|||
ignoreHeading: "",
|
||||
focusHeading: "",
|
||||
fileParsingConfig: undefined,
|
||||
fileMetadataInheritance: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -671,6 +801,10 @@ export class TaskWorkerManager extends Component {
|
|||
|
||||
promise.resolve(allTasks);
|
||||
this.outstanding.delete(file.path);
|
||||
|
||||
// Update statistics
|
||||
this.stats.filesProcessed++;
|
||||
this.updateCacheHitRatio();
|
||||
} else if (data.type === "batchResult") {
|
||||
// For batch results, we handle differently as we don't have tasks directly
|
||||
promise.reject(
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -93,5 +93,6 @@
|
|||
"9.1.0-beta.6": "0.15.2",
|
||||
"9.1.0-beta.7": "0.15.2",
|
||||
"9.1.0-beta.8": "0.15.2",
|
||||
"9.1.0-beta.9": "0.15.2"
|
||||
"9.1.0-beta.9": "0.15.2",
|
||||
"9.1.0-beta.10": "0.15.2"
|
||||
}
|
||||
Loading…
Reference in a new issue