feat: support recurrence

This commit is contained in:
Quorafind 2025-04-23 10:57:21 +08:00
parent c17ab4f220
commit e46f5cce5f
3 changed files with 347 additions and 4 deletions

View file

@ -48,6 +48,7 @@
"obsidian": "^1.8.7",
"obsidian-typings": "^2.39.0",
"regexp-match-indices": "^1.0.2",
"rrule": "^2.8.1",
"tslib": "2.4.0",
"typescript": "4.7.3"
},

View file

@ -81,6 +81,9 @@ importers:
regexp-match-indices:
specifier: ^1.0.2
version: 1.0.2
rrule:
specifier: ^2.8.1
version: 2.8.1
tslib:
specifier: 2.4.0
version: 2.4.0
@ -1312,6 +1315,9 @@ packages:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
rrule@2.8.1:
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -2820,6 +2826,10 @@ snapshots:
sprintf-js: 1.1.3
optional: true
rrule@2.8.1:
dependencies:
tslib: 2.4.0
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3

View file

@ -16,6 +16,7 @@ import { TaskIndexer } from "./import/TaskIndexer";
import { TaskWorkerManager } from "./workers/TaskWorkerManager";
import { LocalStorageCache } from "./persister";
import TaskProgressBarPlugin from "src";
import { RRule, RRuleSet, rrulestr } from "rrule";
/**
* TaskManager options
@ -907,6 +908,12 @@ export class TaskManager extends Component {
throw new Error(`Task with ID ${updatedTask.id} not found`);
}
// Check if this is a completion of a recurring task
const isCompletingRecurringTask =
!originalTask.completed &&
updatedTask.completed &&
updatedTask.recurrence;
// Determine the metadata format from plugin settings
const useDataviewFormat =
this.plugin.settings.preferMetadataFormat === "dataview";
@ -1258,16 +1265,12 @@ export class TaskManager extends Component {
(tag) => !existingTagsInMetadata.has(tag)
);
console.log("uniqueTagsToAdd", uniqueTagsToAdd);
// add tags to metadata
if (uniqueTagsToAdd.length > 0) {
metadata.push(...uniqueTagsToAdd);
}
}
console.log("metadata", metadata);
// Append all metadata to the line
if (metadata.length > 0) {
updatedLine = updatedLine.trim(); // Trim first to remove trailing space before adding metadata
@ -1282,6 +1285,25 @@ export class TaskManager extends Component {
// Update the line in the file content
if (updatedLine !== taskLine) {
lines[updatedTask.line] = updatedLine;
// If this is a completed recurring task, create a new task with updated dates
if (isCompletingRecurringTask) {
try {
const newTaskLine = this.createRecurringTask(
updatedTask,
indentation
);
// Insert the new task line after the current task
lines.splice(updatedTask.line + 1, 0, newTaskLine);
this.log(
`Created new recurring task after line ${updatedTask.line}`
);
} catch (error) {
console.error("Error creating recurring task:", error);
}
}
await this.vault.modify(file, lines.join("\n"));
await this.indexFile(file); // Re-index the modified file
this.log(
@ -1298,6 +1320,316 @@ export class TaskManager extends Component {
}
}
/**
* Creates a new task line based on a completed recurring task
*/
private createRecurringTask(
completedTask: Task,
indentation: string
): string {
// Calculate the next due date based on the recurrence pattern
const nextDueDate = this.calculateNextDueDate(completedTask);
// Create a new task with the same content but updated dates
const newTask = { ...completedTask };
// Reset completion status and date
newTask.completed = false;
newTask.completedDate = undefined;
// Update due date
newTask.dueDate = nextDueDate;
// Format dates for task markdown
const formattedDueDate = nextDueDate
? this.formatDateForDisplay(nextDueDate)
: undefined;
// For other dates, copy the original ones if they exist
const formattedStartDate = completedTask.startDate
? this.formatDateForDisplay(completedTask.startDate)
: undefined;
const formattedScheduledDate = completedTask.scheduledDate
? this.formatDateForDisplay(completedTask.scheduledDate)
: undefined;
// Extract the original list marker (-, *, 1., etc.) from the original markdown
let listMarker = "- ";
if (completedTask.originalMarkdown) {
// Match the list marker pattern: could be "- ", "* ", "1. ", etc.
const listMarkerMatch = completedTask.originalMarkdown.match(
/^(\s*)([*\-+]|\d+\.)\s+\[/
);
if (listMarkerMatch && listMarkerMatch[2]) {
listMarker = listMarkerMatch[2] + " ";
// If it's a numbered list, increment the number
if (/^\d+\.$/.test(listMarkerMatch[2])) {
const numberStr = listMarkerMatch[2].replace(/\.$/, "");
const number = parseInt(numberStr);
listMarker = number + 1 + ". ";
}
}
}
// Create the task markdown with the correct list marker
const useDataviewFormat =
this.plugin.settings.preferMetadataFormat === "dataview";
// Start with the basic task using the extracted list marker
let newTaskLine = `${indentation}${listMarker}[ ] ${completedTask.content}`;
// Add metadata based on format preference
const metadata = [];
// Add dates
if (formattedDueDate) {
metadata.push(
useDataviewFormat
? `[due:: ${formattedDueDate}]`
: `📅 ${formattedDueDate}`
);
}
if (formattedStartDate) {
metadata.push(
useDataviewFormat
? `[start:: ${formattedStartDate}]`
: `🛫 ${formattedStartDate}`
);
}
if (formattedScheduledDate) {
metadata.push(
useDataviewFormat
? `[scheduled:: ${formattedScheduledDate}]`
: `${formattedScheduledDate}`
);
}
// Add recurrence
if (completedTask.recurrence) {
metadata.push(
useDataviewFormat
? `[repeat:: ${completedTask.recurrence}]`
: `🔁 ${completedTask.recurrence}`
);
}
// Add priority
if (completedTask.priority) {
if (useDataviewFormat) {
let priorityValue: string | number;
switch (completedTask.priority) {
case 5:
priorityValue = "highest";
break;
case 4:
priorityValue = "high";
break;
case 3:
priorityValue = "medium";
break;
case 2:
priorityValue = "low";
break;
case 1:
priorityValue = "lowest";
break;
default:
priorityValue = completedTask.priority;
}
metadata.push(`[priority:: ${priorityValue}]`);
} else {
let priorityMarker = "";
switch (completedTask.priority) {
case 5:
priorityMarker = "🔺";
break;
case 4:
priorityMarker = "⏫";
break;
case 3:
priorityMarker = "🔼";
break;
case 2:
priorityMarker = "🔽";
break;
case 1:
priorityMarker = "⏬";
break;
}
if (priorityMarker) metadata.push(priorityMarker);
}
}
// Add project
if (completedTask.project) {
if (useDataviewFormat) {
metadata.push(`[project:: ${completedTask.project}]`);
} else {
metadata.push(`#project/${completedTask.project}`);
}
}
// Add context
if (completedTask.context) {
if (useDataviewFormat) {
metadata.push(`[context:: ${completedTask.context}]`);
} else {
metadata.push(`@${completedTask.context}`);
}
}
// Add tags (excluding project/context tags that are handled separately)
if (completedTask.tags && completedTask.tags.length > 0) {
const tagsToAdd = completedTask.tags.filter((tag) => {
// Skip project tags (already added above)
if (tag.startsWith("#project/")) return false;
// Skip context tags (already added above)
if (
tag.startsWith("@") &&
completedTask.context &&
tag === `@${completedTask.context}`
)
return false;
return true;
});
if (tagsToAdd.length > 0) {
metadata.push(...tagsToAdd);
}
}
// Append all metadata to the line
if (metadata.length > 0) {
newTaskLine = `${newTaskLine} ${metadata.join(" ")}`;
}
return newTaskLine;
}
/**
* Calculates the next due date based on recurrence pattern
*/
private calculateNextDueDate(task: Task): number | undefined {
if (!task.recurrence) return undefined;
// Start with current due date or today if no due date
const baseDate = task.dueDate ? new Date(task.dueDate) : new Date();
try {
// Parse different recurrence formats
// Tasks plugin style: "every day", "every 2 weeks", etc.
const recurrence = task.recurrence.trim().toLowerCase();
// Default to adding 1 day if we can't parse the recurrence
let nextDate = new Date(baseDate);
nextDate.setDate(nextDate.getDate() + 1);
// Parse "every X days/weeks/months/years" format
if (recurrence.startsWith("every")) {
const parts = recurrence.split(" ");
// Handle "every day/week/month/year"
if (parts.length >= 2) {
let interval = 1;
let unit = parts[1];
// Check if there's a number after "every"
if (parts.length >= 3 && !isNaN(parseInt(parts[1]))) {
interval = parseInt(parts[1]);
unit = parts[2];
}
// Make unit singular for matching
if (unit.endsWith("s")) {
unit = unit.substring(0, unit.length - 1);
}
// Calculate next date based on unit
switch (unit) {
case "day":
nextDate.setDate(baseDate.getDate() + interval);
break;
case "week":
nextDate.setDate(baseDate.getDate() + interval * 7);
break;
case "month":
nextDate.setMonth(baseDate.getMonth() + interval);
break;
case "year":
nextDate.setFullYear(
baseDate.getFullYear() + interval
);
break;
default:
// For unknown units, default to days
nextDate.setDate(baseDate.getDate() + interval);
}
}
}
// Handle specific weekday recurrences like "every Monday"
else if (
recurrence.includes("monday") ||
recurrence.includes("tuesday") ||
recurrence.includes("wednesday") ||
recurrence.includes("thursday") ||
recurrence.includes("friday") ||
recurrence.includes("saturday") ||
recurrence.includes("sunday")
) {
const weekdays = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
// Find which weekday is mentioned
let targetDay = -1;
for (const [day, value] of Object.entries(weekdays)) {
if (recurrence.includes(day)) {
targetDay = value;
break;
}
}
if (targetDay >= 0) {
// Start from tomorrow to avoid repeating on the same day
nextDate.setDate(baseDate.getDate() + 1);
// Find the next occurrence of the target day
while (nextDate.getDay() !== targetDay) {
nextDate.setDate(nextDate.getDate() + 1);
}
}
}
return nextDate.getTime();
} catch (error) {
console.error("Error calculating next due date:", error);
// Default fallback: add one day
const tomorrow = new Date(baseDate);
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.getTime();
}
}
/**
* Format a date for display in task metadata
*/
private formatDateForDisplay(timestamp: number): string {
const date = new Date(timestamp);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}-${String(date.getDate()).padStart(2, "0")}`;
}
/**
* Force reindex all tasks by clearing all current indices and rebuilding from scratch
*/