refactor(dataflow): optimize single task updates and cache invalidation

- Make WriteAPI getTaskById async-compatible for better integration
- Add direct single task update path to avoid full file re-parsing
- Implement Repository.updateSingleTask() for efficient inline edits
- Force cache invalidation for WriteAPI operations to ensure consistency
- Emit TASK_UPDATED events for immediate dataflow updates
- Improve task update performance by bypassing unnecessary file parsing
- Fix tag formatting to prevent duplicate # prefixes in WriteAPI

This change significantly improves performance for inline task editing
by updating only the modified task instead of re-parsing entire files.
This commit is contained in:
Quorafind 2025-08-21 15:22:04 +08:00
parent 771d9f73b1
commit 0c6db2537b
12 changed files with 407 additions and 168 deletions

View file

@ -1060,6 +1060,7 @@ export class InlineEditor extends Component {
this.isSaving = true;
try {
console.log("[InlineEditor] Calling onTaskUpdate:", this.originalTask.content, "->", this.task.content);
await this.options.onTaskUpdate(this.originalTask, this.task);
this.originalTask = {
...this.task,

View file

@ -54,6 +54,7 @@ export class ContentComponent extends Component {
private focusFilter: string | null = null; // Keep focus filter if needed
private isTreeView: boolean = false;
private isRendering: boolean = false; // Guard against concurrent renders
private pendingForceRefresh: boolean = false; // Track if a force refresh is pending
constructor(
private parentEl: HTMLElement,
@ -176,18 +177,44 @@ export class ContentComponent extends Component {
this.refreshTaskList(); // Refresh list completely on view mode change
}
public setTasks(tasks: Task[], notFilteredTasks: Task[]) {
public setTasks(tasks: Task[], notFilteredTasks: Task[], forceRefresh: boolean = false) {
// Allow forced refresh for cases where we know the data has changed
if (forceRefresh) {
console.log("ContentComponent: Forced refresh requested");
// Cancel any ongoing rendering if force refresh is requested
this.isRendering = false;
this.pendingForceRefresh = true;
this.allTasks = tasks;
this.notFilteredTasks = notFilteredTasks;
this.applyFilters();
this.refreshTaskList();
return;
}
// If a force refresh is pending, skip non-forced updates
if (this.pendingForceRefresh) {
console.log("ContentComponent: Skipping non-forced update, force refresh is pending");
return;
}
// Prevent unnecessary refreshes if data hasn't actually changed
if (this.allTasks.length === tasks.length &&
// Check if the array reference has changed (which indicates an update)
if (this.allTasks === tasks &&
this.notFilteredTasks === notFilteredTasks) {
console.log("ContentComponent: Same array references, skipping refresh");
return;
}
// Additional check for actual content changes
if (this.allTasks.length === tasks.length &&
this.notFilteredTasks.length === notFilteredTasks.length &&
tasks.length > 0) {
// Quick check - if same length and not empty, check if first few tasks are identical
const sampleSize = Math.min(3, tasks.length);
const sampleSize = Math.min(5, tasks.length);
let unchanged = true;
for (let i = 0; i < sampleSize; i++) {
if (this.allTasks[i]?.id !== tasks[i]?.id ||
this.allTasks[i]?.content !== tasks[i]?.content ||
this.allTasks[i]?.status !== tasks[i]?.status) {
if (this.allTasks[i]?.id !== tasks[i]?.id ||
this.allTasks[i]?.originalMarkdown !== tasks[i]?.originalMarkdown) {
unchanged = false;
break;
}
@ -288,7 +315,13 @@ export class ContentComponent extends Component {
}
private refreshTaskList() {
// Prevent concurrent renders
// Allow force refresh to override concurrent rendering
if (this.pendingForceRefresh) {
console.log("ContentComponent: Processing pending force refresh");
this.isRendering = false; // Cancel any ongoing render
}
// Prevent concurrent renders (unless force refresh)
if (this.isRendering) {
console.log("ContentComponent: Already rendering, skipping refresh");
return;
@ -326,6 +359,7 @@ export class ContentComponent extends Component {
// Reset rendering flag after completion
setTimeout(() => {
this.isRendering = false;
this.pendingForceRefresh = false; // Reset force refresh flag
}, 50); // Small delay to prevent immediate re-entry
}
}

View file

@ -386,8 +386,13 @@ export class TaskDetailsComponent extends Component {
const tagsField = this.createFormField(this.editFormEl, t("Tags"));
const tagsInput = new TextComponent(tagsField);
console.log("tagsInput", tagsInput, task.metadata.tags);
// Remove # prefix from tags when displaying them
tagsInput.setValue(
task.metadata.tags ? task.metadata.tags.join(", ") : ""
task.metadata.tags
? task.metadata.tags
.map(tag => tag.startsWith("#") ? tag.slice(1) : tag)
.join(", ")
: ""
);
tagsField
.createSpan({ cls: "field-description" })
@ -526,12 +531,13 @@ export class TaskDetailsComponent extends Component {
metadata.project = task.metadata.project;
}
// Parse and update tags
// Parse and update tags (remove # prefix if present)
const tagsValue = tagsInput.getValue();
metadata.tags = tagsValue
? tagsValue
.split(",")
.map((tag) => tag.trim())
.map((tag) => tag.startsWith("#") ? tag.slice(1) : tag) // Remove # prefix if present
.filter((tag) => tag)
: [];
@ -667,6 +673,11 @@ export class TaskDetailsComponent extends Component {
// Update the current task reference but don't redraw the UI
this.currentTask = updatedTask;
// Reset editing state after successful update to allow view refreshes
// Use setTimeout to ensure the reset happens after any immediate WriteAPI events
setTimeout(() => {
this.isEditing = false;
}, 50);
console.log("updatedTask", updatedTask);
} catch (error) {
console.error("Failed to update task:", error);

View file

@ -230,14 +230,31 @@ export class DataflowOrchestrator {
// Listen for WriteAPI completion events to trigger re-processing
this.eventRefs.push(
on(this.app, Events.WRITE_OPERATION_COMPLETE, async (payload: any) => {
const { path } = payload;
console.log(`[DataflowOrchestrator] WRITE_OPERATION_COMPLETE: ${path}`);
const { path, taskId } = payload;
console.log(`[DataflowOrchestrator] WRITE_OPERATION_COMPLETE: ${path}, taskId: ${taskId}`);
// Process the file after WriteAPI completes
const file = this.vault.getAbstractFileByPath(path) as TFile;
if (file) {
// Process immediately without debounce for WriteAPI operations
await this.processFileImmediate(file);
// If we have a taskId, it means a specific task was updated
// We'll handle this through TASK_UPDATED event instead
if (!taskId) {
// No specific task, process the entire file
const file = this.vault.getAbstractFileByPath(path) as TFile;
if (file) {
// Process immediately without debounce for WriteAPI operations
// Pass true to force cache invalidation
await this.processFileImmediate(file, true);
}
}
})
);
// Listen for direct task updates (from inline editing)
this.eventRefs.push(
on(this.app, Events.TASK_UPDATED, async (payload: any) => {
const { task } = payload;
if (task) {
console.log(`[DataflowOrchestrator] TASK_UPDATED: ${task.id} in ${task.filePath}`);
// Update the single task directly in the repository
await this.repository.updateSingleTask(task);
}
})
);
@ -280,7 +297,7 @@ export class DataflowOrchestrator {
const timeoutId = setTimeout(async () => {
this.processingQueue.delete(filePath);
await this.processFileImmediate(file);
await this.processFileImmediate(file, false);
}, this.DEBOUNCE_DELAY);
this.processingQueue.set(filePath, timeoutId);
@ -288,8 +305,10 @@ export class DataflowOrchestrator {
/**
* Process a file immediately without debouncing
* @param file The file to process
* @param forceInvalidate Force cache invalidation (for WriteAPI operations)
*/
private async processFileImmediate(file: TFile): Promise<void> {
private async processFileImmediate(file: TFile, forceInvalidate: boolean = false): Promise<void> {
const filePath = file.path;
try {
@ -306,7 +325,8 @@ export class DataflowOrchestrator {
let needsProcessing = false;
// Check if we can use fully cached augmented tasks
if (rawCached && augmentedCached &&
// Force invalidation for WriteAPI operations to ensure fresh parsing
if (!forceInvalidate && rawCached && augmentedCached &&
this.storage.isRawValid(filePath, rawCached, fileContent, mtime)) {
// Use cached augmented tasks - file hasn't changed and we have augmented data
console.log(`[DataflowOrchestrator] Using cached augmented tasks for ${filePath} (mtime match)`);
@ -318,14 +338,18 @@ export class DataflowOrchestrator {
let rawTasks: Task[];
let projectData: any; // Type will be inferred from projectResolver.get
if (rawCached && this.storage.isRawValid(filePath, rawCached, fileContent, mtime)) {
if (!forceInvalidate && rawCached && this.storage.isRawValid(filePath, rawCached, fileContent, mtime)) {
// Use cached raw tasks but re-augment (project data might have changed)
console.log(`[DataflowOrchestrator] Re-augmenting cached raw tasks for ${filePath}`);
rawTasks = rawCached.data;
projectData = await this.projectResolver.get(filePath);
} else {
// Parse the file from scratch
console.log(`[DataflowOrchestrator] Parsing ${filePath} (cache miss or mtime mismatch)`);
if (forceInvalidate) {
console.log(`[DataflowOrchestrator] Parsing ${filePath} (forced invalidation from WriteAPI)`);
} else {
console.log(`[DataflowOrchestrator] Parsing ${filePath} (cache miss or mtime mismatch)`);
}
// Get project data first for parsing
projectData = await this.projectResolver.get(filePath);
@ -736,6 +760,13 @@ export class DataflowOrchestrator {
return this.queryAPI;
}
/**
* Get the repository for direct access
*/
getRepository(): Repository {
return this.repository;
}
/**
* Get statistics about the dataflow system
*/

View file

@ -78,7 +78,7 @@ export class WriteAPI {
private vault: Vault,
private metadataCache: MetadataCache,
private plugin: TaskProgressBarPlugin,
private getTaskById: (id: string) => Task | null
private getTaskById: (id: string) => Promise<Task | null> | Task | null
) {}
/**
@ -90,7 +90,7 @@ export class WriteAPI {
completed?: boolean;
}): Promise<{ success: boolean; task?: Task; error?: string }> {
try {
const task = this.getTaskById(args.taskId);
const task = await Promise.resolve(this.getTaskById(args.taskId));
if (!task) {
return { success: false, error: "Task not found" };
}
@ -152,7 +152,7 @@ export class WriteAPI {
*/
async updateTask(args: UpdateTaskArgs): Promise<{ success: boolean; task?: Task; error?: string }> {
try {
const originalTask = this.getTaskById(args.taskId);
const originalTask = await Promise.resolve(this.getTaskById(args.taskId));
if (!originalTask) {
return { success: false, error: "Task not found" };
}
@ -213,9 +213,21 @@ export class WriteAPI {
// Notify about write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path, taskId: args.taskId });
await this.vault.modify(file, lines.join("\n"));
// Create the updated task object with the new content
const updatedTaskObj: Task = {
...originalTask,
...args.updates,
originalMarkdown: taskLine.replace(/^\s*[-*+]\s*\[[^\]]*\]\s*/, ""), // Remove checkbox prefix
};
// Emit task updated event for direct update in dataflow
emit(this.app, Events.TASK_UPDATED, { task: updatedTaskObj });
// Still emit write operation complete for compatibility
emit(this.app, Events.WRITE_OPERATION_COMPLETE, { path: file.path, taskId: args.taskId });
return { success: true };
return { success: true, task: updatedTaskObj };
} catch (error) {
console.error("WriteAPI: Error updating task:", error);
return { success: false, error: String(error) };
@ -295,7 +307,7 @@ export class WriteAPI {
*/
async deleteTask(args: DeleteTaskArgs): Promise<{ success: boolean; error?: string }> {
try {
const task = this.getTaskById(args.taskId);
const task = await Promise.resolve(this.getTaskById(args.taskId));
if (!task) {
return { success: false, error: "Task not found" };
}
@ -398,7 +410,7 @@ export class WriteAPI {
const updatedTasks: Task[] = [];
for (const taskId of args.taskIds) {
const task = this.getTaskById(taskId);
const task = await Promise.resolve(this.getTaskById(taskId));
if (!task) continue;
const newContent = task.content.replace(args.findText, args.replaceText);
@ -408,7 +420,7 @@ export class WriteAPI {
});
if (result.success) {
const updatedTask = this.getTaskById(taskId);
const updatedTask = await Promise.resolve(this.getTaskById(taskId));
if (updatedTask) {
updatedTasks.push(updatedTask);
}
@ -422,7 +434,7 @@ export class WriteAPI {
* Batch create subtasks
*/
async batchCreateSubtasks(args: BatchCreateSubtasksArgs): Promise<{ tasks: Task[] }> {
const parentTask = this.getTaskById(args.parentTaskId);
const parentTask = await Promise.resolve(this.getTaskById(args.parentTaskId));
if (!parentTask) {
return { tasks: [] };
}
@ -635,7 +647,10 @@ export class WriteAPI {
if (useDataviewFormat) {
metadata.push(`[tags:: ${args.tags.join(", ")}]`);
} else {
metadata.push(...args.tags.map(tag => `#${tag}`));
// Ensure tags don't already have # prefix before adding one
metadata.push(...args.tags.map(tag =>
tag.startsWith("#") ? tag : `#${tag}`
));
}
}

View file

@ -81,6 +81,17 @@ export class CanvasParser {
canvasData: CanvasData,
filePath: string
): ParsedCanvasContent {
// Check if nodes exist
if (!canvasData || !canvasData.nodes || !Array.isArray(canvasData.nodes)) {
console.warn(`Canvas file ${filePath} has no nodes or invalid nodes structure`);
return {
canvasData,
textContent: "",
textNodes: [],
filePath,
};
}
// Filter text nodes
const textNodes = canvasData.nodes.filter(
(node): node is CanvasTextData => node.type === "text"

View file

@ -38,7 +38,9 @@ export class MarkdownTaskParser {
}
// Public alias for extractMetadataAndTags
public extractMetadataAndTags(content: string): [string, Record<string, string>, string[]] {
public extractMetadataAndTags(
content: string,
): [string, Record<string, string>, string[]] {
return this.extractMetadataAndTagsInternal(content);
}
@ -47,7 +49,7 @@ export class MarkdownTaskParser {
*/
static createWithStatusMapping(
config: TaskParserConfig,
statusMapping: Record<string, string>
statusMapping: Record<string, string>,
): MarkdownTaskParser {
const newConfig = { ...config, statusMapping };
return new MarkdownTaskParser(newConfig);
@ -61,7 +63,7 @@ export class MarkdownTaskParser {
filePath: string = "",
fileMetadata?: Record<string, any>,
projectConfigData?: Record<string, any>,
tgProject?: TgProject
tgProject?: TgProject,
): EnhancedTask[] {
this.reset();
this.fileMetadata = fileMetadata;
@ -80,7 +82,7 @@ export class MarkdownTaskParser {
parseIteration++;
if (parseIteration > this.config.maxParseIterations) {
console.warn(
"Warning: Maximum parse iterations reached, stopping to prevent infinite loop"
"Warning: Maximum parse iterations reached, stopping to prevent infinite loop",
);
break;
}
@ -132,7 +134,7 @@ export class MarkdownTaskParser {
const isSubtask = parentId !== undefined;
const inheritedMetadata = this.inheritFileMetadata(
metadata,
isSubtask
isSubtask,
);
// Process inherited tags and merge with task's own tags
@ -140,7 +142,7 @@ export class MarkdownTaskParser {
if (inheritedMetadata.tags) {
try {
const inheritedTags = JSON.parse(
inheritedMetadata.tags
inheritedMetadata.tags,
);
if (Array.isArray(inheritedTags)) {
finalTags = this.mergeTags(tags, inheritedTags);
@ -163,23 +165,15 @@ export class MarkdownTaskParser {
? this.extractMultilineComment(
lines,
i + 1,
actualSpaces
)
actualSpaces,
)
: [undefined, 0];
i += linesToSkip;
// Debug: Log priority extraction for each task
const extractedPriority = this.extractLegacyPriority(inheritedMetadata);
if (process.env.NODE_ENV === 'development' || true) { // Always log for debugging
console.log('[Parser] Task priority extraction:', {
lineNumber: i + 1,
content: cleanedContent.substring(0, 50),
metadataPriority: inheritedMetadata.priority,
extractedPriority,
filePath
});
}
const extractedPriority =
this.extractLegacyPriority(inheritedMetadata);
const enhancedTask: EnhancedTask = {
id: taskId,
@ -208,23 +202,23 @@ export class MarkdownTaskParser {
priority: extractedPriority,
startDate: this.extractLegacyDate(
inheritedMetadata,
"startDate"
"startDate",
),
dueDate: this.extractLegacyDate(
inheritedMetadata,
"dueDate"
"dueDate",
),
scheduledDate: this.extractLegacyDate(
inheritedMetadata,
"scheduledDate"
"scheduledDate",
),
completedDate: this.extractLegacyDate(
inheritedMetadata,
"completedDate"
"completedDate",
),
createdDate: this.extractLegacyDate(
inheritedMetadata,
"createdDate"
"createdDate",
),
recurrence: inheritedMetadata.recurrence,
project: inheritedMetadata.project,
@ -233,7 +227,7 @@ export class MarkdownTaskParser {
if (parentId && this.tasks.length > 0) {
const parentTask = this.tasks.find(
(t) => t.id === parentId
(t) => t.id === parentId,
);
if (parentTask) {
parentTask.childrenIds.push(taskId);
@ -259,14 +253,14 @@ export class MarkdownTaskParser {
filePath: string = "",
fileMetadata?: Record<string, any>,
projectConfigData?: Record<string, any>,
tgProject?: TgProject
tgProject?: TgProject,
): Task[] {
const enhancedTasks = this.parse(
input,
filePath,
fileMetadata,
projectConfigData,
tgProject
tgProject,
);
return enhancedTasks.map((task) => this.convertToLegacyTask(task));
}
@ -291,7 +285,7 @@ export class MarkdownTaskParser {
}
private extractTaskLine(
line: string
line: string,
): [number, number, string, string] | null {
const trimmed = line.trim();
const actualSpaces = line.length - trimmed.length;
@ -352,7 +346,7 @@ export class MarkdownTaskParser {
}
private extractMetadataAndTagsInternal(
content: string
content: string,
): [string, Record<string, string>, string[]] {
const metadata: Record<string, string> = {};
const tags: string[] = [];
@ -375,16 +369,20 @@ export class MarkdownTaskParser {
if (bracketMatch) {
const [key, value, newRemaining] = bracketMatch;
metadata[key] = value;
// Debug: Log dataview metadata extraction, especially priority
if ((process.env.NODE_ENV === 'development' || true) && key === 'priority') { // Always log for debugging
console.log('[Parser] Dataview priority found:', {
if (
(process.env.NODE_ENV === "development" || true) &&
key === "priority"
) {
// Always log for debugging
console.log("[Parser] Dataview priority found:", {
key,
value,
remaining: remaining.substring(0, 50)
remaining: remaining.substring(0, 50),
});
}
remaining = newRemaining;
foundMatch = true;
continue;
@ -486,7 +484,7 @@ export class MarkdownTaskParser {
}
private extractDataviewMetadata(
content: string
content: string,
): [string, string, string] | null {
const start = content.indexOf("[");
if (start === -1) return null;
@ -532,7 +530,7 @@ export class MarkdownTaskParser {
}
private extractEmojiMetadata(
content: string
content: string,
): [string, string, string, string] | null {
// Find the earliest emoji
let earliestEmoji: { pos: number; emoji: string; key: string } | null =
@ -551,7 +549,7 @@ export class MarkdownTaskParser {
const beforeEmoji = content.substring(0, earliestEmoji.pos);
const afterEmoji = content.substring(
earliestEmoji.pos + earliestEmoji.emoji.length
earliestEmoji.pos + earliestEmoji.emoji.length,
);
// Extract value after emoji
@ -565,7 +563,7 @@ export class MarkdownTaskParser {
// Check if we encounter other emojis or special characters
if (
Object.keys(this.config.emojiMapping).some((e) =>
valuePart.substring(i).startsWith(e)
valuePart.substring(i).startsWith(e),
) ||
char === "["
) {
@ -611,14 +609,15 @@ export class MarkdownTaskParser {
// For priority emojis, use the emoji itself or the provided value
// This ensures we can distinguish between different priority levels
metadataValue = value || earliestEmoji.emoji;
// Debug: Log priority emoji extraction
if (process.env.NODE_ENV === 'development' || true) { // Always log for debugging
console.log('[Parser] Priority emoji found:', {
if (process.env.NODE_ENV === "development" || true) {
// Always log for debugging
console.log("[Parser] Priority emoji found:", {
emoji: earliestEmoji.emoji,
value,
metadataValue,
position: earliestEmoji.pos
position: earliestEmoji.pos,
});
}
} else {
@ -629,9 +628,15 @@ export class MarkdownTaskParser {
// Sanitize date-like emoji values to avoid trailing context (e.g., "2025-08-15 @work")
if (
["dueDate", "startDate", "scheduledDate", "completedDate", "createdDate", "cancelledDate"].includes(
earliestEmoji.key as string
) && typeof metadataValue === "string"
[
"dueDate",
"startDate",
"scheduledDate",
"completedDate",
"createdDate",
"cancelledDate",
].includes(earliestEmoji.key as string) &&
typeof metadataValue === "string"
) {
const m = metadataValue.match(/\d{4}-\d{2}-\d{2}/);
if (m) {
@ -881,7 +886,7 @@ export class MarkdownTaskParser {
}
private extractTagsOnly(
content: string
content: string,
): [string, Record<string, string>, string[]] {
const metadata: Record<string, string> = {};
const tags: string[] = [];
@ -983,7 +988,7 @@ export class MarkdownTaskParser {
}
private findParentAndLevel(
actualSpaces: number
actualSpaces: number,
): [string | undefined, number] {
if (this.indentStack.length === 0 || actualSpaces === 0) {
return [undefined, 0];
@ -1006,7 +1011,7 @@ export class MarkdownTaskParser {
private updateIndentStack(
taskId: string,
indentLevel: number,
actualSpaces: number
actualSpaces: number,
): void {
let stackOperations = 0;
@ -1014,7 +1019,7 @@ export class MarkdownTaskParser {
stackOperations++;
if (stackOperations > this.config.maxStackOperations) {
console.warn(
"Warning: Maximum stack operations reached, clearing stack"
"Warning: Maximum stack operations reached, clearing stack",
);
this.indentStack = [];
break;
@ -1031,7 +1036,7 @@ export class MarkdownTaskParser {
if (this.indentStack.length >= this.config.maxStackSize) {
this.indentStack.splice(
0,
this.indentStack.length - this.config.maxStackSize + 1
this.indentStack.length - this.config.maxStackSize + 1,
);
}
@ -1041,7 +1046,7 @@ export class MarkdownTaskParser {
private getStatusFromMapping(rawStatus: string): string | undefined {
// Find status name corresponding to raw character
for (const [statusName, mappedChar] of Object.entries(
this.config.statusMapping
this.config.statusMapping,
)) {
if (mappedChar === rawStatus) {
return statusName;
@ -1078,7 +1083,7 @@ export class MarkdownTaskParser {
private extractMultilineComment(
lines: string[],
startIndex: number,
actualSpaces: number
actualSpaces: number,
): [string | undefined, number] {
const commentLines: string[] = [];
let i = startIndex;
@ -1110,7 +1115,7 @@ export class MarkdownTaskParser {
// Legacy compatibility methods
private extractLegacyPriority(
metadata: Record<string, string>
metadata: Record<string, string>,
): number | undefined {
if (!metadata.priority) return undefined;
@ -1144,19 +1149,21 @@ export class MarkdownTaskParser {
}
// Then try to map string values (including emojis)
const mappedPriority = priorityMap[metadata.priority.toLowerCase()] || priorityMap[metadata.priority];
const mappedPriority =
priorityMap[metadata.priority.toLowerCase()] ||
priorityMap[metadata.priority];
return mappedPriority;
}
private extractLegacyDate(
metadata: Record<string, string>,
key: string
key: string,
): number | undefined {
const dateStr = metadata[key];
if (!dateStr) return undefined;
// Check cache first to avoid repeated date parsing
const cacheKey = `${dateStr}_${(this.customDateFormats || []).join(',')}`;
const cacheKey = `${dateStr}_${(this.customDateFormats || []).join(",")}`;
const cachedDate = MarkdownTaskParser.dateCache.get(cacheKey);
if (cachedDate !== undefined) {
return cachedDate;
@ -1241,8 +1248,8 @@ export class MarkdownTaskParser {
heading: Array.isArray(enhancedTask.heading)
? enhancedTask.heading
: enhancedTask.heading
? [enhancedTask.heading]
: [],
? [enhancedTask.heading]
: [],
parent: enhancedTask.parentId,
tgProject: enhancedTask.tgProject,
},
@ -1377,18 +1384,18 @@ export class MarkdownTaskParser {
* @returns Normalized tag with # prefix
*/
private normalizeTag(tag: string): string {
if (typeof tag !== 'string') {
if (typeof tag !== "string") {
return tag;
}
// Trim whitespace
const trimmed = tag.trim();
// If empty or already starts with #, return as is
if (!trimmed || trimmed.startsWith('#')) {
if (!trimmed || trimmed.startsWith("#")) {
return trimmed;
}
// Add # prefix
return `#${trimmed}`;
}
@ -1401,9 +1408,13 @@ export class MarkdownTaskParser {
*/
private mergeTags(baseTags: string[], inheritedTags: string[]): string[] {
// Normalize all tags before merging
const normalizedBaseTags = baseTags.map(tag => this.normalizeTag(tag));
const normalizedInheritedTags = inheritedTags.map(tag => this.normalizeTag(tag));
const normalizedBaseTags = baseTags.map((tag) =>
this.normalizeTag(tag),
);
const normalizedInheritedTags = inheritedTags.map((tag) =>
this.normalizeTag(tag),
);
const merged = [...normalizedBaseTags];
for (const tag of normalizedInheritedTags) {
@ -1420,7 +1431,7 @@ export class MarkdownTaskParser {
*/
private inheritFileMetadata(
taskMetadata: Record<string, string>,
isSubtask: boolean = false
isSubtask: boolean = false,
): Record<string, string> {
// Helper function to convert priority values to numbers
const convertPriorityValue = (value: any): string => {
@ -1465,7 +1476,8 @@ export class MarkdownTaskParser {
}
// Try priority mapping (including emojis)
const mappedPriority = priorityMap[strValue.toLowerCase()] || priorityMap[strValue];
const mappedPriority =
priorityMap[strValue.toLowerCase()] || priorityMap[strValue];
if (mappedPriority !== undefined) {
return String(mappedPriority);
}
@ -1535,7 +1547,7 @@ export class MarkdownTaskParser {
// Merge extracted metadata from tags
for (const [tagKey, tagValue] of Object.entries(
tagMetadata
tagMetadata,
)) {
if (
!nonInheritableFields.has(tagKey) &&
@ -1563,7 +1575,9 @@ export class MarkdownTaskParser {
inherited["tags"] === "")
) {
// Normalize tags before storing
const normalizedTags = value.map(tag => this.normalizeTag(tag));
const normalizedTags = value.map((tag) =>
this.normalizeTag(tag),
);
inherited["tags"] = JSON.stringify(normalizedTags);
}
} else {
@ -1593,7 +1607,7 @@ export class MarkdownTaskParser {
// Inherit from project configuration data if available
if (this.projectConfigCache) {
for (const [key, value] of Object.entries(
this.projectConfigCache
this.projectConfigCache,
)) {
// Only inherit if:
// 1. The field is not in the non-inheritable list
@ -1642,10 +1656,10 @@ export class ConfigurableTaskParser extends MarkdownTaskParser {
maxStackOperations: 1000,
maxStackSize: 50,
statusMapping: {
"TODO": " ",
"IN_PROGRESS": "/",
"DONE": "x",
"CANCELLED": "-"
TODO: " ",
IN_PROGRESS: "/",
DONE: "x",
CANCELLED: "-",
},
emojiMapping: {
"📅": "dueDate",
@ -1662,15 +1676,15 @@ export class ConfigurableTaskParser extends MarkdownTaskParser {
"⏫": "priority",
"🔼": "priority",
"🔽": "priority",
"⏬": "priority"
"⏬": "priority",
},
metadataParseMode: MetadataParseMode.Both,
specialTagPrefixes: {
"project": "project",
"@": "context"
}
project: "project",
"@": "context",
},
};
super({ ...defaultConfig, ...config });
}
}

View file

@ -484,4 +484,69 @@ export class Repository {
seq: this.lastSequence
});
}
/**
* Get a task by its ID
*/
async getTaskById(taskId: string): Promise<Task | undefined> {
// Get all tasks from the repository
const allTasks = await this.all();
// Find the task by ID
const task = allTasks.find(t => t.id === taskId);
return task;
}
/**
* Update a single task directly (for inline editing)
* This avoids re-parsing the entire file
*/
async updateSingleTask(updatedTask: Task): Promise<void> {
const filePath = updatedTask.filePath;
if (!filePath) return;
console.log(`[Repository] Updating single task: ${updatedTask.id} in ${filePath}`);
// Load existing augmented tasks for the file
const existingAugmented = await this.storage.loadAugmented(filePath);
if (!existingAugmented) {
console.warn(`[Repository] No existing tasks found for ${filePath}, cannot update single task`);
return;
}
// Find and replace the task in the array
const tasks = existingAugmented.data;
const taskIndex = tasks.findIndex(t => t.id === updatedTask.id);
if (taskIndex === -1) {
console.warn(`[Repository] Task ${updatedTask.id} not found in ${filePath}`);
return;
}
// Update the task
tasks[taskIndex] = updatedTask;
// Update the index and storage
await this.indexer.updateIndexWithTasks(filePath, tasks);
await this.storage.storeAugmented(filePath, tasks);
// Schedule persist operation
this.schedulePersist(filePath);
// Emit update event
this.lastSequence = Seq.next();
emit(this.app, Events.TASK_CACHE_UPDATED, {
changedFiles: [filePath],
stats: {
total: await this.getTotalTaskCount(),
changed: 1
},
timestamp: Date.now(),
seq: this.lastSequence,
sourceSeq: undefined
});
console.log(`[Repository] Single task ${updatedTask.id} updated successfully`);
}
}

View file

@ -56,6 +56,16 @@ export class ObsidianSource {
})
);
// Clean up skip flag when write operation completes
this.eventRefs.push(
on(this.app, Events.WRITE_OPERATION_COMPLETE, ({ path }) => {
// Delay cleanup slightly to ensure metadata changes are also skipped
setTimeout(() => {
this.skipNextModify.delete(path);
}, 100);
})
);
// File system events
this.eventRefs.push(
this.vault.on('create', this.onFileCreate.bind(this)),
@ -186,6 +196,13 @@ export class ObsidianSource {
return;
}
// Skip if this metadata change is from WriteAPI
// WriteAPI operations can trigger metadata changes, but we handle them via TASK_UPDATED
if (this.skipNextModify.has(file.path)) {
console.log(`ObsidianSource: Skipping metadata change for ${file.path} (handled by WriteAPI)`);
return;
}
// Clear existing timeout for this file
const existingTimeout = this.pendingMetadataChanges.get(file.path);
if (existingTimeout) {

View file

@ -319,18 +319,21 @@ export default class TaskProgressBarPlugin extends Plugin {
// Initialize WriteAPI if dataflow is enabled
if (this.settings?.experimental?.dataflowEnabled) {
const getTaskById = (id: string) => {
const getTaskById = async (id: string): Promise<Task | null> => {
// Try dataflow first, fallback to taskManager
if (this.dataflowOrchestrator) {
try {
const QueryAPI = require("./dataflow/api/QueryAPI").QueryAPI;
const queryAPI = new QueryAPI(this.app, this.app.vault, this.app.metadataCache);
return queryAPI.getRepository().getTaskById(id);
const repository = this.dataflowOrchestrator.getRepository();
const task = await repository.getTaskById(id);
if (task) {
return task;
}
} catch (e) {
console.warn("Failed to get task from dataflow, falling back to taskManager", e);
}
}
return this.taskManager.getTaskById(id);
const taskManagerResult = this.taskManager.getTaskById(id);
return taskManagerResult || null;
};
this.writeAPI = new WriteAPI(

View file

@ -49,6 +49,7 @@ import {
FilterGroup,
RootFilterState,
} from "../components/task-filter/ViewTaskFilter";
import { isDataflowEnabled } from "../dataflow/createDataflow";
export const TASK_SPECIFIC_VIEW_TYPE = "task-genius-specific-view";
@ -175,17 +176,34 @@ export class TaskSpecificView extends ItemView {
cls: "task-genius-container no-sidebar",
});
// Add debounced view update to prevent rapid successive refreshes
const debouncedViewUpdate = debounce(async () => {
// Don't skip view updates - the detailsComponent will handle edit state properly
await this.loadTasks(false, false);
}, 150); // 150ms debounce delay
// 1. 首先注册事件监听器,确保不会错过任何更新
this.registerEvent(
this.app.workspace.on(
"task-genius:task-cache-updated",
async () => {
// Skip view update if currently editing in details panel
const skipViewUpdate = this.detailsComponent?.isCurrentlyEditing() || false;
await this.loadTasks(false, skipViewUpdate);
}
)
);
if (isDataflowEnabled(this.plugin) && this.plugin.dataflowOrchestrator) {
// Dataflow: 订阅统一事件
const { on, Events } = await import("../dataflow/events/Events");
this.registerEvent(
on(this.app, Events.CACHE_READY, async () => {
// 冷启动就绪,从快照加载
await this.loadTasksFast(true);
})
);
this.registerEvent(
on(this.app, Events.TASK_CACHE_UPDATED, debouncedViewUpdate)
);
} else {
// Legacy: 兼容旧事件
this.registerEvent(
this.app.workspace.on(
"task-genius:task-cache-updated",
debouncedViewUpdate
)
);
}
this.registerEvent(
this.app.workspace.on(
@ -1242,17 +1260,10 @@ export class TaskSpecificView extends ItemView {
}
}
// 直接更新当前视图
// Only switch view if not currently editing in details panel
if (this.currentViewId && !this.detailsComponent.isCurrentlyEditing()) {
// Always refresh the view after a successful update
// The update operation itself means editing is complete
if (this.currentViewId) {
this.switchView(this.currentViewId, this.currentProject);
} else if (this.currentViewId) {
// Update task data in the current view without full re-render
// Find active component and update its task list
const activeComponent = this.getActiveComponent();
if (activeComponent && typeof activeComponent.setTasks === "function") {
activeComponent.setTasks(this.tasks, this.tasks);
}
}
return updatedTask;

View file

@ -144,10 +144,8 @@ export class TaskView extends ItemView {
// Add debounced view update to prevent rapid successive refreshes
const debouncedViewUpdate = debounce(async () => {
const skipViewUpdate = this.detailsComponent?.isCurrentlyEditing() || false;
if (!skipViewUpdate) {
await this.loadTasks(false, false);
}
// Don't skip view updates - the detailsComponent will handle edit state properly
await this.loadTasks(false, false);
}, 150); // 150ms debounce delay
// 1. 首先注册事件监听器,确保不会错过任何更新
@ -854,9 +852,9 @@ export class TaskView extends ItemView {
};
}
private switchView(viewId: ViewMode, project?: string | null) {
private switchView(viewId: ViewMode, project?: string | null, forceRefresh: boolean = false) {
this.currentViewId = viewId;
console.log("Switching view to:", viewId, "Project:", project);
console.log("[TaskView] Switching view to:", viewId, "Project:", project, "ForceRefresh:", forceRefresh);
// Update sidebar to reflect current view
this.sidebarComponent.setViewMode(viewId);
@ -977,10 +975,13 @@ export class TaskView extends ItemView {
}
console.log("tasks", this.tasks);
const filteredTasks = filterTasks(this.tasks, viewId, this.plugin, filterOptions);
console.log("[TaskView] Calling setTasks with", filteredTasks.length, "filtered tasks, forceRefresh:", forceRefresh);
targetComponent.setTasks(
filterTasks(this.tasks, viewId, this.plugin, filterOptions),
this.tasks
filteredTasks,
this.tasks,
forceRefresh
);
}
@ -1385,17 +1386,51 @@ export class TaskView extends ItemView {
try {
// Use WriteAPI if dataflow is enabled
let writeResult: { success: boolean; task?: Task; error?: string } | undefined;
if (this.plugin.settings?.experimental?.dataflowEnabled && this.plugin.writeAPI) {
const result = await this.plugin.writeAPI.updateTask({
writeResult = await this.plugin.writeAPI.updateTask({
taskId: updatedTask.id,
updates: updatedTask
});
if (!result.success) {
throw new Error(result.error || "Failed to update task");
if (!writeResult.success) {
throw new Error(writeResult.error || "Failed to update task");
}
// Prefer the authoritative task returned by WriteAPI (includes updated originalMarkdown)
if (writeResult.task) {
updatedTask = writeResult.task;
}
} else {
await taskManager.updateTask(updatedTask);
}
console.log(`Task ${updatedTask.id} updated successfully via handleTaskUpdate.`);
// Update local task list immediately
const index = this.tasks.findIndex((t) => t.id === originalTask.id);
if (index !== -1) {
// Create a new array to ensure ContentComponent detects the change
this.tasks = [...this.tasks];
this.tasks[index] = updatedTask;
} else {
console.warn(
"Updated task not found in local list, might reload."
);
}
// Always refresh the view after a successful update
// The update operation itself means editing is complete
// Force refresh since we know the task has been updated
this.switchView(this.currentViewId, undefined, true);
// Update details component if the updated task is currently selected
if (this.currentSelectedTaskId === updatedTask.id) {
if (this.detailsComponent.isCurrentlyEditing()) {
// Update the current task reference without re-rendering UI
this.detailsComponent.currentTask = updatedTask;
} else {
this.detailsComponent.showTaskDetails(updatedTask);
}
}
} catch (error) {
console.error("Failed to update task:", error);
// Re-throw the error so that the InlineEditor can handle it properly
@ -1414,13 +1449,17 @@ export class TaskView extends ItemView {
}
try {
// Use WriteAPI if dataflow is enabled
let writeResult: { success: boolean; task?: Task; error?: string } | undefined;
if (this.plugin.settings?.experimental?.dataflowEnabled && this.plugin.writeAPI) {
const result = await this.plugin.writeAPI.updateTask({
writeResult = await this.plugin.writeAPI.updateTask({
taskId: updatedTask.id,
updates: updatedTask
});
if (!result.success) {
throw new Error(result.error || "Failed to update task");
if (!writeResult.success) {
throw new Error(writeResult.error || "Failed to update task");
}
if (writeResult.task) {
updatedTask = writeResult.task;
}
} else {
await taskManager.updateTask(updatedTask);
@ -1430,6 +1469,8 @@ export class TaskView extends ItemView {
// 立即更新本地任务列表
const index = this.tasks.findIndex((t) => t.id === originalTask.id);
if (index !== -1) {
// Create a new array to ensure ContentComponent detects the change
this.tasks = [...this.tasks];
this.tasks[index] = updatedTask;
} else {
console.warn(
@ -1437,25 +1478,10 @@ export class TaskView extends ItemView {
);
}
// 如果任务在当前视图中,立即更新视图
// Only skip view update if currently editing in details panel AND it's not a status change
const isStatusChange = originalTask.status !== updatedTask.status ||
originalTask.completed !== updatedTask.completed;
if (!this.detailsComponent.isCurrentlyEditing() || isStatusChange) {
// Always refresh view for status changes or when not editing
this.switchView(this.currentViewId);
} else {
// Update the task in the current view without re-rendering (only for content edits)
// Use setTasks to update the components with the modified task list
if (this.currentViewId === "inbox" || this.currentViewId === "projects") {
this.contentComponent.setTasks(this.tasks, this.tasks);
} else if (this.currentViewId === "forecast") {
this.forecastComponent.setTasks(this.tasks);
} else if (this.currentViewId === "tags") {
this.tagsComponent.setTasks(this.tasks);
}
}
// Always refresh the view after a successful update
// The update operation itself means editing is complete
// Force refresh since we know the task has been updated
this.switchView(this.currentViewId, undefined, true);
if (this.currentSelectedTaskId === updatedTask.id) {
if (this.detailsComponent.isCurrentlyEditing()) {