mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Extract core logic and add conformance waivers
This commit is contained in:
parent
6c3c7ba4bb
commit
594ee77f67
15 changed files with 2059 additions and 910 deletions
33
conformance/README.md
Normal file
33
conformance/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Conformance Waivers
|
||||
|
||||
`npm run conformance:test` in `tasknotes` can skip specific `tasknotes-spec` fixture IDs without editing the shared spec repo.
|
||||
|
||||
Configure waivers in [waivers.json](/home/calluma/projects/tasknotes/conformance/waivers.json).
|
||||
|
||||
Each waiver must include:
|
||||
|
||||
- `id`: fixture id such as `link.0028`
|
||||
- `scope`: short category such as `host-controlled`, `known-deviation`, or `spec-gap`
|
||||
- `reason`: short human-readable summary
|
||||
- `justification`: why the fixture is not currently actionable for TaskNotes
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"waivers": [
|
||||
{
|
||||
"id": "link.0028",
|
||||
"scope": "host-controlled",
|
||||
"reason": "Ambiguous simple-name wikilink resolution is delegated to Obsidian",
|
||||
"justification": "TaskNotes passes link paths to metadataCache.getFirstLinkpathDest and does not own the final candidate tie-break policy."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- The local runner filters waived fixtures after generation and restores the spec fixtures afterward.
|
||||
- The waiver summary is printed before the test run.
|
||||
- Waiver ids must match generated fixture ids, and every waiver must include a justification.
|
||||
10
conformance/waivers.json
Normal file
10
conformance/waivers.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"waivers": [
|
||||
{
|
||||
"id": "link.0028",
|
||||
"scope": "host-controlled",
|
||||
"reason": "Ambiguous simple-name wikilink resolution is delegated to Obsidian",
|
||||
"justification": "TaskNotes parses link text and passes the target to metadataCache.getFirstLinkpathDest, but it does not own Obsidian's final candidate tie-break behavior for ambiguous basename matches."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -7,6 +7,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|||
const tasknotesRoot = resolve(scriptDir, "..");
|
||||
const specRoot = resolve(tasknotesRoot, "../tasknotes-spec");
|
||||
const adapterPath = resolve(specRoot, "conformance/adapters/tasknotes.adapter.mjs");
|
||||
const defaultWaiverPath = resolve(tasknotesRoot, "conformance/waivers.json");
|
||||
|
||||
function requirePath(path, label) {
|
||||
if (!existsSync(path)) {
|
||||
|
|
@ -16,22 +17,135 @@ function requirePath(path, label) {
|
|||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
return spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ? { ...process.env, ...options.env } : process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function loadWaivers(path) {
|
||||
if (!existsSync(path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
const waivers = Array.isArray(parsed?.waivers) ? parsed.waivers : [];
|
||||
|
||||
return waivers.map((entry, index) => {
|
||||
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
|
||||
const reason = typeof entry?.reason === "string" ? entry.reason.trim() : "";
|
||||
const justification = typeof entry?.justification === "string" ? entry.justification.trim() : "";
|
||||
const scope = typeof entry?.scope === "string" ? entry.scope.trim() : "unspecified";
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`Invalid waiver at index ${index}: missing id`);
|
||||
}
|
||||
if (!reason) {
|
||||
throw new Error(`Invalid waiver ${id}: missing reason`);
|
||||
}
|
||||
if (!justification) {
|
||||
throw new Error(`Invalid waiver ${id}: missing justification`);
|
||||
}
|
||||
|
||||
return { id, reason, justification, scope };
|
||||
});
|
||||
}
|
||||
|
||||
function applyFixtureWaivers(root, waivers) {
|
||||
if (waivers.length === 0) {
|
||||
return { restore() {}, removedCount: 0 };
|
||||
}
|
||||
|
||||
const fixturesDir = resolve(root, "conformance/fixtures");
|
||||
const waiverIds = new Map(waivers.map((waiver) => [waiver.id, waiver]));
|
||||
const unmatched = new Set(waiverIds.keys());
|
||||
const backups = [];
|
||||
let removedCount = 0;
|
||||
|
||||
for (const file of readdirSync(fixturesDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const path = resolve(fixturesDir, file);
|
||||
const original = readFileSync(path, "utf8");
|
||||
backups.push({ path, original });
|
||||
|
||||
const fixtures = JSON.parse(original);
|
||||
if (!Array.isArray(fixtures)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = fixtures.filter((fixture) => {
|
||||
const id = typeof fixture?.id === "string" ? fixture.id : "";
|
||||
const shouldKeep = !waiverIds.has(id);
|
||||
if (!shouldKeep) {
|
||||
unmatched.delete(id);
|
||||
removedCount += 1;
|
||||
}
|
||||
return shouldKeep;
|
||||
});
|
||||
|
||||
if (filtered.length !== fixtures.length) {
|
||||
writeFileSync(path, `${JSON.stringify(filtered, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatched.size > 0) {
|
||||
for (const backup of backups) {
|
||||
writeFileSync(backup.path, backup.original);
|
||||
}
|
||||
throw new Error(
|
||||
`Waiver ids not found in generated fixtures: ${[...unmatched].sort().join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
removedCount,
|
||||
restore() {
|
||||
for (const backup of backups) {
|
||||
writeFileSync(backup.path, backup.original);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
requirePath(specRoot, "tasknotes-spec repo");
|
||||
requirePath(resolve(specRoot, "package.json"), "tasknotes-spec package.json");
|
||||
|
||||
run("npm", ["run", "conformance:generate"], { cwd: specRoot });
|
||||
run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
|
||||
run("npm", ["run", "conformance:test"], {
|
||||
cwd: specRoot,
|
||||
env: { TASKNOTES_ADAPTER: adapterPath },
|
||||
});
|
||||
const waiverPath = process.env.TASKNOTES_CONFORMANCE_WAIVERS
|
||||
? resolve(tasknotesRoot, process.env.TASKNOTES_CONFORMANCE_WAIVERS)
|
||||
: defaultWaiverPath;
|
||||
const waivers = loadWaivers(waiverPath);
|
||||
|
||||
let result = run("npm", ["run", "conformance:generate"], { cwd: specRoot });
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
result = run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
const waiverSession = applyFixtureWaivers(specRoot, waivers);
|
||||
|
||||
if (waivers.length > 0) {
|
||||
console.warn(
|
||||
`Applying ${waivers.length} TaskNotes conformance waiver(s) from ${waiverPath} (${waiverSession.removedCount} fixture(s) skipped):`,
|
||||
);
|
||||
for (const waiver of waivers) {
|
||||
console.warn(`- ${waiver.id} [${waiver.scope}] ${waiver.reason}`);
|
||||
console.warn(` justification: ${waiver.justification}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
result = run("npm", ["run", "conformance:test"], {
|
||||
cwd: specRoot,
|
||||
env: { TASKNOTES_ADAPTER: adapterPath },
|
||||
});
|
||||
} finally {
|
||||
waiverSession.restore();
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
|
|
|||
383
src/core/createCompat.ts
Normal file
383
src/core/createCompat.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { format } from "date-fns";
|
||||
import type { SpecFieldMapping } from "./specFieldMapping";
|
||||
import { denormalizeFrontmatter, resolveField } from "./specFieldMapping";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
interface TaskTypeDefLike {
|
||||
path_pattern?: string;
|
||||
match?: {
|
||||
where?: Record<string, unknown>;
|
||||
};
|
||||
fields?: Record<string, { type?: string; default?: unknown }>;
|
||||
}
|
||||
|
||||
interface CreateInputLike {
|
||||
type: string;
|
||||
frontmatter: UnknownRecord;
|
||||
body?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface CreateResultLike {
|
||||
path?: string;
|
||||
frontmatter?: UnknownRecord;
|
||||
error?: {
|
||||
code?: string;
|
||||
message: string;
|
||||
};
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
type CreateCollectionLike = {
|
||||
create: (input: CreateInputLike) => Promise<CreateResultLike>;
|
||||
typeDefs?: Map<string, TaskTypeDefLike>;
|
||||
};
|
||||
|
||||
export async function createTaskWithCompat(
|
||||
collection: CreateCollectionLike,
|
||||
mapping: SpecFieldMapping,
|
||||
roleFrontmatter: UnknownRecord,
|
||||
body?: string,
|
||||
now?: Date
|
||||
): Promise<CreateResultLike> {
|
||||
const taskType = getTaskTypeDef(collection);
|
||||
const denormalized = denormalizeFrontmatter(roleFrontmatter, mapping);
|
||||
const effectiveNow = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
||||
|
||||
applyFieldDefaults(denormalized, taskType);
|
||||
applyTimestampDefaults(denormalized, mapping, taskType, effectiveNow);
|
||||
applyMatchDefaults(denormalized, taskType);
|
||||
|
||||
const input: CreateInputLike = {
|
||||
type: "task",
|
||||
frontmatter: denormalized,
|
||||
body,
|
||||
};
|
||||
|
||||
const firstAttempt = await collection.create(input);
|
||||
if (!firstAttempt.error || firstAttempt.error.code !== "path_required") {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
const pathResolution = derivePathFromType(taskType, denormalized, mapping, effectiveNow);
|
||||
if (!pathResolution.path) {
|
||||
if (pathResolution.missingKeys && pathResolution.missingKeys.length > 0) {
|
||||
const missing = pathResolution.missingKeys.join(", ");
|
||||
return {
|
||||
...firstAttempt,
|
||||
warnings: [
|
||||
`Cannot resolve path_pattern "${pathResolution.template}": missing template values for ${missing}.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
return await collection.create({
|
||||
...input,
|
||||
path: pathResolution.path,
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskTypeDef(collection: CreateCollectionLike): TaskTypeDefLike | undefined {
|
||||
const maybeCollection = collection as unknown as { typeDefs?: Map<string, TaskTypeDefLike> };
|
||||
if (!maybeCollection.typeDefs || typeof maybeCollection.typeDefs.get !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return maybeCollection.typeDefs.get("task");
|
||||
}
|
||||
|
||||
function applyTimestampDefaults(
|
||||
frontmatter: UnknownRecord,
|
||||
mapping: SpecFieldMapping,
|
||||
taskType: TaskTypeDefLike | undefined,
|
||||
now: Date
|
||||
): void {
|
||||
const fields = taskType?.fields;
|
||||
if (!fields) return;
|
||||
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
const createdField = resolveField(mapping, "dateCreated");
|
||||
if (fields[createdField] && !hasValue(frontmatter[createdField])) {
|
||||
frontmatter[createdField] = nowIso;
|
||||
}
|
||||
|
||||
const modifiedField = resolveField(mapping, "dateModified");
|
||||
if (fields[modifiedField] && !hasValue(frontmatter[modifiedField])) {
|
||||
frontmatter[modifiedField] = nowIso;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFieldDefaults(frontmatter: UnknownRecord, taskType: TaskTypeDefLike | undefined): void {
|
||||
const fields = taskType?.fields;
|
||||
if (!fields) return;
|
||||
|
||||
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
||||
if (fieldDef.default !== undefined && !hasValue(frontmatter[fieldName])) {
|
||||
frontmatter[fieldName] = fieldDef.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyMatchDefaults(frontmatter: UnknownRecord, taskType: TaskTypeDefLike | undefined): void {
|
||||
const where = taskType?.match?.where;
|
||||
if (!where || typeof where !== "object") return;
|
||||
|
||||
for (const [field, condition] of Object.entries(where)) {
|
||||
if (condition === null || condition === undefined) continue;
|
||||
|
||||
if (typeof condition !== "object" || Array.isArray(condition)) {
|
||||
if (!hasValue(frontmatter[field])) {
|
||||
frontmatter[field] = condition;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const ops = condition as Record<string, unknown>;
|
||||
if ("eq" in ops && !hasValue(frontmatter[field])) {
|
||||
frontmatter[field] = ops.eq;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("contains" in ops) {
|
||||
const expected = ops.contains;
|
||||
const current = frontmatter[field];
|
||||
if (Array.isArray(current)) {
|
||||
if (!current.some((v) => String(v) === String(expected))) {
|
||||
current.push(expected);
|
||||
frontmatter[field] = current;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (typeof current === "string") {
|
||||
if (!current.includes(String(expected))) {
|
||||
frontmatter[field] = `${current} ${String(expected)}`.trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!hasValue(current)) {
|
||||
frontmatter[field] = [expected];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("exists" in ops && ops.exists === true && !hasValue(frontmatter[field])) {
|
||||
frontmatter[field] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function derivePathFromType(
|
||||
taskType: TaskTypeDefLike | undefined,
|
||||
frontmatter: UnknownRecord,
|
||||
mapping: SpecFieldMapping,
|
||||
now: Date
|
||||
): { path?: string; missingKeys?: string[]; template?: string } {
|
||||
if (!taskType || typeof taskType.path_pattern !== "string" || taskType.path_pattern.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values = buildTemplateValues(frontmatter, mapping, now);
|
||||
const renderedPattern = renderTemplate(taskType.path_pattern, values);
|
||||
if (renderedPattern.path) {
|
||||
return { path: ensureMarkdownExt(renderedPattern.path), template: taskType.path_pattern };
|
||||
}
|
||||
return {
|
||||
template: taskType.path_pattern,
|
||||
missingKeys: renderedPattern.missingKeys,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
values: Record<string, string>
|
||||
): { path?: string; missingKeys: string[] } {
|
||||
const missingKeys = new Set<string>();
|
||||
|
||||
const rendered = template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, a: string, b: string) => {
|
||||
const key = a ?? b;
|
||||
const value = values[key];
|
||||
if (value === undefined || value === null || String(value).trim().length === 0) {
|
||||
missingKeys.add(key);
|
||||
return "";
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
|
||||
if (missingKeys.size > 0) {
|
||||
return { missingKeys: Array.from(missingKeys).sort() };
|
||||
}
|
||||
|
||||
const normalized = normalizeRelativePath(rendered);
|
||||
if (!normalized || normalized.includes("..") || normalized.includes("\0")) {
|
||||
return { missingKeys: [] };
|
||||
}
|
||||
return { path: normalized, missingKeys: [] };
|
||||
}
|
||||
|
||||
function buildTemplateValues(
|
||||
frontmatter: UnknownRecord,
|
||||
mapping: SpecFieldMapping,
|
||||
now: Date
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
const titleField = resolveField(mapping, "title");
|
||||
const priorityField = resolveField(mapping, "priority");
|
||||
const statusField = resolveField(mapping, "status");
|
||||
const dueField = resolveField(mapping, "due");
|
||||
const scheduledField = resolveField(mapping, "scheduled");
|
||||
const contextsField = resolveField(mapping, "contexts");
|
||||
const projectsField = resolveField(mapping, "projects");
|
||||
const tagsField = resolveField(mapping, "tags");
|
||||
const estimateField = resolveField(mapping, "timeEstimate");
|
||||
|
||||
const rawTitle = readString(frontmatter[titleField]) || readString(frontmatter.title) || "task";
|
||||
const title = sanitizeForPathSegment(rawTitle);
|
||||
const priority = sanitizeForPathSegment(
|
||||
readString(frontmatter[priorityField]) || readString(frontmatter.priority) || "normal"
|
||||
);
|
||||
const status = sanitizeForPathSegment(
|
||||
readString(frontmatter[statusField]) || readString(frontmatter.status) || "open"
|
||||
);
|
||||
|
||||
const dueDate = readString(frontmatter[dueField]) || readString(frontmatter.due) || "";
|
||||
const scheduledDate =
|
||||
readString(frontmatter[scheduledField]) || readString(frontmatter.scheduled) || "";
|
||||
const titleWords = splitWords(rawTitle);
|
||||
|
||||
const contexts = readStringList(frontmatter[contextsField] ?? frontmatter.contexts)
|
||||
.map(sanitizeForPathSegment)
|
||||
.join("-");
|
||||
const projects = readStringList(frontmatter[projectsField] ?? frontmatter.projects)
|
||||
.map(sanitizeForPathSegment)
|
||||
.join("-");
|
||||
const tags = readStringList(frontmatter[tagsField] ?? frontmatter.tags)
|
||||
.map(sanitizeForPathSegment)
|
||||
.join("-");
|
||||
const timeEstimate = readString(frontmatter[estimateField] ?? frontmatter.timeEstimate) || "";
|
||||
|
||||
values.title = title;
|
||||
values.priority = priority;
|
||||
values.priorityShort = priority ? priority.slice(0, 3).toLowerCase() : "";
|
||||
values.status = status;
|
||||
values.statusShort = status ? status.slice(0, 3).toLowerCase() : "";
|
||||
values.due = dueDate;
|
||||
values.dueDate = dueDate;
|
||||
values.scheduled = scheduledDate;
|
||||
values.scheduledDate = scheduledDate;
|
||||
values.titleKebab = toKebabCase(titleWords);
|
||||
values.titleSnake = toSnakeCase(titleWords);
|
||||
values.titleCamel = toCamelCase(titleWords);
|
||||
values.titlePascal = toPascalCase(titleWords);
|
||||
values.titleUpper = rawTitle.toUpperCase();
|
||||
values.titleLower = rawTitle.toLowerCase();
|
||||
values.contexts = contexts;
|
||||
values.projects = projects;
|
||||
values.tags = tags;
|
||||
values.timeEstimate = timeEstimate;
|
||||
values.year = format(now, "yyyy");
|
||||
values.month = format(now, "MM");
|
||||
values.monthName = sanitizeForPathSegment(format(now, "MMMM"));
|
||||
values.monthNameShort = sanitizeForPathSegment(format(now, "MMM"));
|
||||
values.day = format(now, "dd");
|
||||
values.date = format(now, "yyyy-MM-dd");
|
||||
values.shortDate = format(now, "yyyyMMdd");
|
||||
values.time = format(now, "HHmmss");
|
||||
values.timestamp = format(now, "yyyyMMddHHmmss");
|
||||
values.week = format(now, "II");
|
||||
values.zettel = format(now, "yyyyMMddHHmmss");
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (values[key] !== undefined) continue;
|
||||
if (typeof value === "string") {
|
||||
values[key] = sanitizeForPathSegment(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => readString(entry))
|
||||
.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
const single = readString(value);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
function sanitizeForPathSegment(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[<>:"|?*\u0000-\u001f]/g, "")
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0 && part !== ".")
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function splitWords(value: string): string[] {
|
||||
return value
|
||||
.trim()
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
function capitalize(word: string): string {
|
||||
if (!word) return "";
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function toKebabCase(words: string[]): string {
|
||||
return words.map((word) => word.toLowerCase()).join("-");
|
||||
}
|
||||
|
||||
function toSnakeCase(words: string[]): string {
|
||||
return words.map((word) => word.toLowerCase()).join("_");
|
||||
}
|
||||
|
||||
function toCamelCase(words: string[]): string {
|
||||
if (words.length === 0) return "";
|
||||
return [
|
||||
words[0].toLowerCase(),
|
||||
...words.slice(1).map((word) => capitalize(word)),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function toPascalCase(words: string[]): string {
|
||||
return words.map((word) => capitalize(word)).join("");
|
||||
}
|
||||
|
||||
function ensureMarkdownExt(path: string): string {
|
||||
return path.endsWith(".md") ? path : `${path}.md`;
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
}
|
||||
88
src/core/date.ts
Normal file
88
src/core/date.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
formatDateForStorage as formatDateForStorageFromUtils,
|
||||
getCurrentDateString as getCurrentDateStringFromUtils,
|
||||
getDatePart as getDatePartFromUtils,
|
||||
hasTimeComponent as hasTimeComponentFromUtils,
|
||||
isBeforeDateSafe as isBeforeDateSafeFromUtils,
|
||||
isSameDateSafe as isSameDateSafeFromUtils,
|
||||
parseDateToLocal as parseDateToLocalFromUtils,
|
||||
parseDateToUTC as parseDateToUTCFromUtils,
|
||||
} from "../utils/dateUtils";
|
||||
|
||||
export function parseDateToUTC(dateString: string): Date {
|
||||
return parseDateToUTCFromUtils(dateString);
|
||||
}
|
||||
|
||||
export function parseDateToLocal(dateString: string): Date {
|
||||
return parseDateToLocalFromUtils(dateString);
|
||||
}
|
||||
|
||||
export function formatDateForStorage(date: Date): string {
|
||||
return formatDateForStorageFromUtils(date);
|
||||
}
|
||||
|
||||
export function getCurrentDateString(): string {
|
||||
return getCurrentDateStringFromUtils();
|
||||
}
|
||||
|
||||
export function validateDateString(date: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error(`Invalid date "${date}". Expected YYYY-MM-DD.`);
|
||||
}
|
||||
|
||||
parseDateToUTC(date);
|
||||
return date;
|
||||
}
|
||||
|
||||
export function hasTimeComponent(dateString: string | undefined): boolean {
|
||||
if (!dateString) return false;
|
||||
return hasTimeComponentFromUtils(dateString);
|
||||
}
|
||||
|
||||
export function getDatePart(dateString: string): string {
|
||||
if (!dateString) return "";
|
||||
return getDatePartFromUtils(dateString);
|
||||
}
|
||||
|
||||
function extractValidDatePartOrUndefined(dateString: string | undefined): string | undefined {
|
||||
if (!dateString || dateString.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const datePart = getDatePart(dateString.trim());
|
||||
return validateDateString(datePart);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOperationTargetDate(
|
||||
explicitDate: string | undefined,
|
||||
scheduled: string | undefined,
|
||||
due: string | undefined
|
||||
): string {
|
||||
if (explicitDate) {
|
||||
return validateDateString(explicitDate);
|
||||
}
|
||||
|
||||
const scheduledDatePart = extractValidDatePartOrUndefined(scheduled);
|
||||
if (scheduledDatePart) {
|
||||
return scheduledDatePart;
|
||||
}
|
||||
|
||||
const dueDatePart = extractValidDatePartOrUndefined(due);
|
||||
if (dueDatePart) {
|
||||
return dueDatePart;
|
||||
}
|
||||
|
||||
return getCurrentDateString();
|
||||
}
|
||||
|
||||
export function isSameDateSafe(date1: string, date2: string): boolean {
|
||||
return isSameDateSafeFromUtils(date1, date2);
|
||||
}
|
||||
|
||||
export function isBeforeDateSafe(date1: string, date2: string): boolean {
|
||||
return isBeforeDateSafeFromUtils(date1, date2);
|
||||
}
|
||||
335
src/core/fieldMapping.ts
Normal file
335
src/core/fieldMapping.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/* eslint-disable no-console */
|
||||
import { FieldMapping, TaskInfo } from "../types";
|
||||
import {
|
||||
normalizeDependencyEntry,
|
||||
normalizeDependencyList,
|
||||
serializeDependencies,
|
||||
} from "../utils/dependencyUtils";
|
||||
import { validateCompleteInstances } from "../utils/dateUtils";
|
||||
|
||||
export function toUserField(mapping: FieldMapping, internalName: keyof FieldMapping): string {
|
||||
return mapping[internalName];
|
||||
}
|
||||
|
||||
export function normalizeTitleValue(val: unknown): string | undefined {
|
||||
if (typeof val === "string") return val;
|
||||
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
|
||||
if (val === null || val === undefined) return undefined;
|
||||
if (typeof val === "object") return "";
|
||||
return String(val);
|
||||
}
|
||||
|
||||
export function mapTaskFromFrontmatter(
|
||||
mapping: FieldMapping,
|
||||
frontmatter: Record<string, any> | undefined | null,
|
||||
filePath: string,
|
||||
storeTitleInFilename?: boolean
|
||||
): Partial<TaskInfo> {
|
||||
if (!frontmatter) return {};
|
||||
|
||||
const mapped: Partial<TaskInfo> = {
|
||||
path: filePath,
|
||||
};
|
||||
|
||||
if (frontmatter[mapping.title] !== undefined) {
|
||||
const normalized = normalizeTitleValue(frontmatter[mapping.title]);
|
||||
if (normalized !== undefined) {
|
||||
mapped.title = normalized;
|
||||
}
|
||||
} else if (storeTitleInFilename) {
|
||||
const filename = filePath.split("/").pop()?.replace(".md", "");
|
||||
if (filename) {
|
||||
mapped.title = filename;
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.status] !== undefined) {
|
||||
const statusValue = frontmatter[mapping.status];
|
||||
mapped.status = typeof statusValue === "boolean" ? (statusValue ? "true" : "false") : statusValue;
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.priority] !== undefined) {
|
||||
mapped.priority = frontmatter[mapping.priority];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.due] !== undefined) {
|
||||
mapped.due = frontmatter[mapping.due];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.scheduled] !== undefined) {
|
||||
mapped.scheduled = frontmatter[mapping.scheduled];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.contexts] !== undefined) {
|
||||
const contexts = frontmatter[mapping.contexts];
|
||||
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.projects] !== undefined) {
|
||||
const projects = frontmatter[mapping.projects];
|
||||
mapped.projects = Array.isArray(projects) ? projects : [projects];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.timeEstimate] !== undefined) {
|
||||
mapped.timeEstimate = frontmatter[mapping.timeEstimate];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.completedDate] !== undefined) {
|
||||
mapped.completedDate = frontmatter[mapping.completedDate];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.recurrence] !== undefined) {
|
||||
mapped.recurrence = frontmatter[mapping.recurrence];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.recurrenceAnchor] !== undefined) {
|
||||
const anchorValue = frontmatter[mapping.recurrenceAnchor];
|
||||
if (anchorValue === "scheduled" || anchorValue === "completion") {
|
||||
mapped.recurrence_anchor = anchorValue;
|
||||
} else {
|
||||
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
|
||||
mapped.recurrence_anchor = "scheduled";
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.dateCreated] !== undefined) {
|
||||
mapped.dateCreated = frontmatter[mapping.dateCreated];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.dateModified] !== undefined) {
|
||||
mapped.dateModified = frontmatter[mapping.dateModified];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.timeEntries] !== undefined) {
|
||||
const timeEntriesValue = frontmatter[mapping.timeEntries];
|
||||
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.completeInstances] !== undefined) {
|
||||
mapped.complete_instances = validateCompleteInstances(frontmatter[mapping.completeInstances]);
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.skippedInstances] !== undefined) {
|
||||
mapped.skipped_instances = validateCompleteInstances(frontmatter[mapping.skippedInstances]);
|
||||
}
|
||||
|
||||
if (mapping.blockedBy && frontmatter[mapping.blockedBy] !== undefined) {
|
||||
const dependencies = normalizeDependencyList(frontmatter[mapping.blockedBy]);
|
||||
if (dependencies) {
|
||||
mapped.blockedBy = dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.icsEventId] !== undefined) {
|
||||
const icsEventId = frontmatter[mapping.icsEventId];
|
||||
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.googleCalendarEventId] !== undefined) {
|
||||
mapped.googleCalendarEventId = frontmatter[mapping.googleCalendarEventId];
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.reminders] !== undefined) {
|
||||
const reminders = frontmatter[mapping.reminders];
|
||||
if (Array.isArray(reminders)) {
|
||||
const filteredReminders = reminders.filter((r) => r != null);
|
||||
if (filteredReminders.length > 0) {
|
||||
mapped.reminders = filteredReminders;
|
||||
}
|
||||
} else if (reminders != null) {
|
||||
mapped.reminders = [reminders];
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[mapping.sortOrder] !== undefined) {
|
||||
const val = frontmatter[mapping.sortOrder];
|
||||
mapped.sortOrder = typeof val === "string" ? val : String(val);
|
||||
}
|
||||
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
mapped.tags = frontmatter.tags;
|
||||
mapped.archived = frontmatter.tags.includes(mapping.archiveTag);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function mapTaskToFrontmatter(
|
||||
mapping: FieldMapping,
|
||||
taskData: Partial<TaskInfo>,
|
||||
taskTag?: string,
|
||||
storeTitleInFilename?: boolean
|
||||
): Record<string, any> {
|
||||
const frontmatter: Record<string, any> = {};
|
||||
|
||||
if (taskData.title !== undefined) {
|
||||
frontmatter[mapping.title] = taskData.title;
|
||||
}
|
||||
|
||||
if (taskData.status !== undefined) {
|
||||
const lower = taskData.status.toLowerCase();
|
||||
const coercedValue =
|
||||
lower === "true" || lower === "false" ? lower === "true" : taskData.status;
|
||||
frontmatter[mapping.status] = coercedValue;
|
||||
}
|
||||
|
||||
if (taskData.priority !== undefined) {
|
||||
frontmatter[mapping.priority] = taskData.priority;
|
||||
}
|
||||
|
||||
if (taskData.due !== undefined) {
|
||||
frontmatter[mapping.due] = taskData.due;
|
||||
}
|
||||
|
||||
if (taskData.scheduled !== undefined) {
|
||||
frontmatter[mapping.scheduled] = taskData.scheduled;
|
||||
}
|
||||
|
||||
if (
|
||||
taskData.contexts !== undefined &&
|
||||
(!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)
|
||||
) {
|
||||
frontmatter[mapping.contexts] = taskData.contexts;
|
||||
}
|
||||
|
||||
if (
|
||||
taskData.projects !== undefined &&
|
||||
(!Array.isArray(taskData.projects) || taskData.projects.length > 0)
|
||||
) {
|
||||
frontmatter[mapping.projects] = taskData.projects;
|
||||
}
|
||||
|
||||
if (taskData.timeEstimate !== undefined) {
|
||||
frontmatter[mapping.timeEstimate] = taskData.timeEstimate;
|
||||
}
|
||||
|
||||
if (taskData.completedDate !== undefined) {
|
||||
frontmatter[mapping.completedDate] = taskData.completedDate;
|
||||
}
|
||||
|
||||
if (taskData.recurrence !== undefined) {
|
||||
frontmatter[mapping.recurrence] = taskData.recurrence;
|
||||
}
|
||||
|
||||
if (taskData.recurrence_anchor !== undefined) {
|
||||
frontmatter[mapping.recurrenceAnchor] = taskData.recurrence_anchor;
|
||||
}
|
||||
|
||||
if (taskData.dateCreated !== undefined) {
|
||||
frontmatter[mapping.dateCreated] = taskData.dateCreated;
|
||||
}
|
||||
|
||||
if (taskData.dateModified !== undefined) {
|
||||
frontmatter[mapping.dateModified] = taskData.dateModified;
|
||||
}
|
||||
|
||||
if (taskData.sortOrder !== undefined) {
|
||||
frontmatter[mapping.sortOrder] = taskData.sortOrder;
|
||||
}
|
||||
|
||||
if (taskData.timeEntries !== undefined) {
|
||||
frontmatter[mapping.timeEntries] = taskData.timeEntries;
|
||||
}
|
||||
|
||||
if (taskData.complete_instances !== undefined) {
|
||||
frontmatter[mapping.completeInstances] = taskData.complete_instances;
|
||||
}
|
||||
|
||||
if (taskData.skipped_instances !== undefined && taskData.skipped_instances.length > 0) {
|
||||
frontmatter[mapping.skippedInstances] = taskData.skipped_instances;
|
||||
}
|
||||
|
||||
if (taskData.blockedBy !== undefined) {
|
||||
if (Array.isArray(taskData.blockedBy)) {
|
||||
const normalized = taskData.blockedBy
|
||||
.map((item) => normalizeDependencyEntry(item))
|
||||
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
|
||||
if (normalized.length > 0) {
|
||||
frontmatter[mapping.blockedBy] = serializeDependencies(normalized);
|
||||
}
|
||||
} else {
|
||||
frontmatter[mapping.blockedBy] = taskData.blockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
|
||||
frontmatter[mapping.icsEventId] = taskData.icsEventId;
|
||||
}
|
||||
|
||||
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
|
||||
frontmatter[mapping.reminders] = taskData.reminders;
|
||||
}
|
||||
|
||||
let tags = taskData.tags ? [...taskData.tags] : [];
|
||||
|
||||
if (taskTag && !tags.includes(taskTag)) {
|
||||
tags.push(taskTag);
|
||||
}
|
||||
|
||||
if (taskData.archived === true && !tags.includes(mapping.archiveTag)) {
|
||||
tags.push(mapping.archiveTag);
|
||||
} else if (taskData.archived === false) {
|
||||
tags = tags.filter((tag) => tag !== mapping.archiveTag);
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
frontmatter.tags = tags;
|
||||
}
|
||||
|
||||
void storeTitleInFilename;
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
export function lookupMappingKey(
|
||||
mapping: FieldMapping,
|
||||
frontmatterPropertyName: string
|
||||
): keyof FieldMapping | null {
|
||||
for (const [mappingKey, propertyName] of Object.entries(mapping)) {
|
||||
if (propertyName === frontmatterPropertyName) {
|
||||
return mappingKey as keyof FieldMapping;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRecognizedProperty(mapping: FieldMapping, frontmatterPropertyName: string): boolean {
|
||||
return lookupMappingKey(mapping, frontmatterPropertyName) !== null;
|
||||
}
|
||||
|
||||
export function isPropertyForField(
|
||||
mapping: FieldMapping,
|
||||
propertyName: string,
|
||||
internalField: keyof FieldMapping
|
||||
): boolean {
|
||||
return mapping[internalField] === propertyName;
|
||||
}
|
||||
|
||||
export function toUserFields(
|
||||
mapping: FieldMapping,
|
||||
internalFields: (keyof FieldMapping)[]
|
||||
): string[] {
|
||||
return internalFields.map((field) => mapping[field]);
|
||||
}
|
||||
|
||||
export function validateFieldMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
const fields = Object.keys(mapping) as (keyof FieldMapping)[];
|
||||
for (const field of fields) {
|
||||
if (!mapping[field] || mapping[field].trim() === "") {
|
||||
errors.push(`Field "${field}" cannot be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Object.values(mapping);
|
||||
const uniqueValues = new Set(values);
|
||||
if (values.length !== uniqueValues.size) {
|
||||
errors.push("Field mappings must have unique property names");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
706
src/core/recurrence.ts
Normal file
706
src/core/recurrence.ts
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
import { RRule } from "rrule";
|
||||
import { TaskInfo } from "../types";
|
||||
import {
|
||||
createUTCDateForRRule,
|
||||
formatDateAsUTCString,
|
||||
formatDateForStorage,
|
||||
getTodayString,
|
||||
hasTimeComponent,
|
||||
parseDateToLocal,
|
||||
parseDateToUTC,
|
||||
} from "../utils/dateUtils";
|
||||
|
||||
export type RecurrenceAnchor = "scheduled" | "completion";
|
||||
|
||||
export interface RecurringTaskLike {
|
||||
title?: string;
|
||||
recurrence?: string;
|
||||
scheduled?: string;
|
||||
due?: string;
|
||||
dateCreated?: string;
|
||||
recurrence_anchor?: RecurrenceAnchor;
|
||||
complete_instances?: string[];
|
||||
skipped_instances?: string[];
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface RecurrenceCompletionInput {
|
||||
recurrence: string;
|
||||
recurrenceAnchor?: string;
|
||||
scheduled?: string;
|
||||
due?: string;
|
||||
dateCreated?: string;
|
||||
completionDate: string;
|
||||
completeInstances?: string[];
|
||||
skippedInstances?: string[];
|
||||
}
|
||||
|
||||
export interface RecurrenceCompletionResult {
|
||||
updatedRecurrence: string;
|
||||
nextScheduled: string | null;
|
||||
nextDue: string | null;
|
||||
completeInstances: string[];
|
||||
skippedInstances: string[];
|
||||
}
|
||||
|
||||
export interface RecurrenceScheduleInput {
|
||||
recurrence: string;
|
||||
recurrenceAnchor?: string;
|
||||
scheduled?: string;
|
||||
due?: string;
|
||||
dateCreated?: string;
|
||||
completeInstances?: string[];
|
||||
skippedInstances?: string[];
|
||||
referenceDate: string;
|
||||
}
|
||||
|
||||
export interface RecurrenceScheduleResult {
|
||||
updatedRecurrence: string;
|
||||
nextScheduled: string | null;
|
||||
nextDue: string | null;
|
||||
}
|
||||
|
||||
function parseDtstartFromRecurrence(recurrence: string): Date | null {
|
||||
const dtstartMatch = recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
|
||||
if (!dtstartMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dtstartStr = dtstartMatch[1];
|
||||
if (dtstartStr.length === 8) {
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
|
||||
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
|
||||
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
|
||||
return new Date(Date.UTC(year, month, day, hour, minute, second, 0));
|
||||
}
|
||||
|
||||
function getRRuleDtstart(task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">): Date | null {
|
||||
if (!task.recurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDtstart = parseDtstartFromRecurrence(task.recurrence);
|
||||
if (parsedDtstart) {
|
||||
return parsedDtstart;
|
||||
}
|
||||
|
||||
if (task.scheduled) {
|
||||
return createUTCDateForRRule(task.scheduled);
|
||||
}
|
||||
|
||||
if (task.dateCreated) {
|
||||
return createUTCDateForRRule(task.dateCreated);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createRRule(task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">): RRule | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dtstart = getRRuleDtstart(task);
|
||||
if (!dtstart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
|
||||
const rruleOptions = RRule.parseString(rruleString);
|
||||
rruleOptions.dtstart = dtstart;
|
||||
return new RRule(rruleOptions);
|
||||
}
|
||||
|
||||
function parseIntervalFromRecurrence(recurrence: string): number {
|
||||
const match = recurrence.match(/INTERVAL=(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 1;
|
||||
}
|
||||
|
||||
function getLookAheadDays(recurrence: string): number {
|
||||
const interval = parseIntervalFromRecurrence(recurrence);
|
||||
if (recurrence.includes("FREQ=DAILY")) {
|
||||
return Math.max(30, interval * 2);
|
||||
}
|
||||
if (recurrence.includes("FREQ=WEEKLY")) {
|
||||
return Math.max(90, interval * 7 * 2);
|
||||
}
|
||||
if (recurrence.includes("FREQ=MONTHLY")) {
|
||||
return Math.max(400, interval * 31 * 2);
|
||||
}
|
||||
if (recurrence.includes("FREQ=YEARLY")) {
|
||||
return Math.max(800, interval * 366 * 2);
|
||||
}
|
||||
return 365;
|
||||
}
|
||||
|
||||
function formatDtstartValue(dateStr: string): string {
|
||||
if (hasTimeComponent(dateStr)) {
|
||||
const dateTime = parseDateToLocal(dateStr);
|
||||
const year = dateTime.getFullYear();
|
||||
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(dateTime.getDate()).padStart(2, "0");
|
||||
const hours = String(dateTime.getHours()).padStart(2, "0");
|
||||
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
const date = parseDateToUTC(dateStr);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
export function isDueByRRule(task: RecurringTaskLike, date: Date): boolean {
|
||||
if (!task.recurrence) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof task.recurrence === "string") {
|
||||
try {
|
||||
const rrule = createRRule(task);
|
||||
if (!rrule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDateStart = createUTCDateForRRule(formatDateAsUTCString(date));
|
||||
const occurrences = rrule.between(
|
||||
targetDateStart,
|
||||
new Date(targetDateStart.getTime() + 24 * 60 * 60 * 1000 - 1),
|
||||
true
|
||||
);
|
||||
|
||||
return occurrences.length > 0;
|
||||
} catch (error) {
|
||||
console.error("Error evaluating rrule:", error, {
|
||||
task: task.title,
|
||||
recurrence: task.recurrence,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getEffectiveTaskStatus(
|
||||
task: Pick<RecurringTaskLike, "recurrence" | "complete_instances" | "status">,
|
||||
date: Date,
|
||||
completedStatus?: string
|
||||
): string {
|
||||
if (!task.recurrence) {
|
||||
return task.status || "open";
|
||||
}
|
||||
|
||||
const dateStr = formatDateForStorage(date);
|
||||
const completedDates = Array.isArray(task.complete_instances) ? task.complete_instances : [];
|
||||
return completedDates.includes(dateStr) ? (completedStatus || "done") : (task.status || "open");
|
||||
}
|
||||
|
||||
export function shouldShowRecurringTaskOnDate(task: RecurringTaskLike, targetDate: Date): boolean {
|
||||
if (!task.recurrence) return true;
|
||||
return isDueByRRule(task, targetDate);
|
||||
}
|
||||
|
||||
export function getRecurringTaskCompletionText(
|
||||
task: Pick<RecurringTaskLike, "recurrence" | "complete_instances">,
|
||||
targetDate: Date
|
||||
): string {
|
||||
if (!task.recurrence) return "";
|
||||
|
||||
const dateStr = formatDateForStorage(targetDate);
|
||||
const isCompleted = task.complete_instances?.includes(dateStr) || false;
|
||||
return isCompleted ? "Completed for this date" : "Not completed for this date";
|
||||
}
|
||||
|
||||
export function shouldUseRecurringTaskUI(task: Pick<RecurringTaskLike, "recurrence">): boolean {
|
||||
return !!task.recurrence;
|
||||
}
|
||||
|
||||
export function generateRecurringInstances(
|
||||
task: Pick<RecurringTaskLike, "title" | "recurrence" | "scheduled" | "dateCreated">,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Date[] {
|
||||
if (!task.recurrence) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof task.recurrence === "string") {
|
||||
try {
|
||||
const rrule = createRRule(task);
|
||||
if (!rrule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const utcStartDate = new Date(
|
||||
Date.UTC(
|
||||
startDate.getUTCFullYear(),
|
||||
startDate.getUTCMonth(),
|
||||
startDate.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
);
|
||||
const utcEndDate = new Date(
|
||||
Date.UTC(
|
||||
endDate.getUTCFullYear(),
|
||||
endDate.getUTCMonth(),
|
||||
endDate.getUTCDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
)
|
||||
);
|
||||
|
||||
return rrule.between(utcStartDate, utcEndDate, true);
|
||||
} catch (error) {
|
||||
console.error("Error generating recurring instances:", error, {
|
||||
task: task.title,
|
||||
recurrence: task.recurrence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const instances: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
while (current <= endDate) {
|
||||
if (isDueByRRule(task, current)) {
|
||||
instances.push(new Date(current));
|
||||
}
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
function getNextScheduledBasedOccurrence(task: RecurringTaskLike): Date | null {
|
||||
if (!task.recurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = parseDateToUTC(getTodayString());
|
||||
const lookAheadDays = getLookAheadDays(task.recurrence);
|
||||
|
||||
let startDate = today;
|
||||
const dtstart = parseDtstartFromRecurrence(task.recurrence);
|
||||
if (dtstart && dtstart < today) {
|
||||
startDate = dtstart;
|
||||
}
|
||||
|
||||
const endDate = new Date(today.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
|
||||
const occurrences = generateRecurringInstances(task, startDate, endDate);
|
||||
const processedInstances = new Set([
|
||||
...(task.complete_instances || []),
|
||||
...(task.skipped_instances || []),
|
||||
]);
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
const occurrenceStr = formatDateForStorage(occurrence);
|
||||
if (!processedInstances.has(occurrenceStr) && occurrence >= today) {
|
||||
return occurrence;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error calculating next scheduled-based occurrence:", error, {
|
||||
task: task.title,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextCompletionBasedOccurrence(task: RecurringTaskLike): Date | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = parseDateToUTC(getTodayString());
|
||||
const lookAheadDays = getLookAheadDays(task.recurrence);
|
||||
const dtstartDate = parseDtstartFromRecurrence(task.recurrence);
|
||||
const startDate = dtstartDate || today;
|
||||
const endDate = new Date(startDate.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
|
||||
const occurrences = generateRecurringInstances(task, startDate, endDate);
|
||||
const skippedInstances = new Set(task.skipped_instances || []);
|
||||
const dtstartTime = dtstartDate ? dtstartDate.getTime() : 0;
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
const occurrenceStr = formatDateForStorage(occurrence);
|
||||
if (
|
||||
occurrence.getTime() > dtstartTime &&
|
||||
occurrence >= today &&
|
||||
!skippedInstances.has(occurrenceStr)
|
||||
) {
|
||||
return occurrence;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error calculating completion-based recurrence:", error, {
|
||||
task: task.title,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextUncompletedOccurrence(task: RecurringTaskLike): Date | null {
|
||||
if (!task.recurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = task.recurrence_anchor || "scheduled";
|
||||
return anchor === "completion"
|
||||
? getNextCompletionBasedOccurrence(task)
|
||||
: getNextScheduledBasedOccurrence(task);
|
||||
}
|
||||
|
||||
export function updateToNextScheduledOccurrence(
|
||||
task: Pick<
|
||||
RecurringTaskLike,
|
||||
"title" | "recurrence" | "scheduled" | "due" | "dateCreated" | "recurrence_anchor" | "complete_instances" | "skipped_instances"
|
||||
>,
|
||||
maintainDueOffset = true
|
||||
): { scheduled: string | null; due: string | null } {
|
||||
const nextOccurrence = getNextUncompletedOccurrence(task);
|
||||
let nextScheduleStr: string | null = null;
|
||||
let nextDueStr: string | null = null;
|
||||
let nextDueDate: Date | null = null;
|
||||
|
||||
if (nextOccurrence) {
|
||||
if (maintainDueOffset) {
|
||||
try {
|
||||
const originalScheduled = task.scheduled ? parseDateToUTC(task.scheduled) : null;
|
||||
const originalDue = task.due ? parseDateToUTC(task.due) : null;
|
||||
|
||||
if (originalScheduled && originalDue) {
|
||||
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
|
||||
nextDueDate = new Date(nextOccurrence.getTime() + offsetMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating next due date with offset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (task.scheduled && task.scheduled.includes("T")) {
|
||||
const timePart = task.scheduled.split("T")[1];
|
||||
nextScheduleStr = `${formatDateForStorage(nextOccurrence)}T${timePart}`;
|
||||
} else {
|
||||
nextScheduleStr = formatDateForStorage(nextOccurrence);
|
||||
}
|
||||
|
||||
if (nextDueDate && task.due && task.due.includes("T")) {
|
||||
const timePart = task.due.split("T")[1];
|
||||
nextDueStr = `${formatDateForStorage(nextDueDate)}T${timePart}`;
|
||||
} else if (nextDueDate) {
|
||||
nextDueStr = formatDateForStorage(nextDueDate);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduled: nextScheduleStr,
|
||||
due: nextDueStr,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecurrenceDisplayText(recurrence: string): string {
|
||||
if (!recurrence) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
if (recurrence.includes("FREQ=")) {
|
||||
const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "");
|
||||
const rrule = RRule.fromString(rruleString);
|
||||
return rrule.toText();
|
||||
}
|
||||
|
||||
return "rrule";
|
||||
} catch (error) {
|
||||
console.error("Error converting recurrence to display text:", error, { recurrence });
|
||||
return "rrule";
|
||||
}
|
||||
}
|
||||
|
||||
export function addDTSTARTToRecurrenceRule(
|
||||
task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">
|
||||
): string | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
return task.recurrence;
|
||||
}
|
||||
|
||||
const sourceDateString = task.scheduled || task.dateCreated;
|
||||
if (!sourceDateString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return `DTSTART:${formatDtstartValue(sourceDateString)};${task.recurrence}`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date for DTSTART:", error, { sourceDateString });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDTSTARTInRecurrenceRule(recurrence: string, dateStr: string): string | null {
|
||||
if (!recurrence || typeof recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const dtstartValue = formatDtstartValue(dateStr);
|
||||
if (recurrence.includes("DTSTART:")) {
|
||||
return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`);
|
||||
}
|
||||
return `DTSTART:${dtstartValue};${recurrence}`;
|
||||
} catch (error) {
|
||||
console.error("Error updating DTSTART in recurrence rule:", error, { dateStr });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function addDTSTARTToRecurrenceRuleWithDraggedTime(
|
||||
task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">,
|
||||
draggedStart: Date,
|
||||
allDay: boolean
|
||||
): string | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
return task.recurrence;
|
||||
}
|
||||
|
||||
const sourceDateString = task.scheduled || task.dateCreated;
|
||||
if (!sourceDateString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let dtstartValue: string;
|
||||
|
||||
if (allDay) {
|
||||
const sourceDate = parseDateToUTC(sourceDateString);
|
||||
const year = sourceDate.getUTCFullYear();
|
||||
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}`;
|
||||
} else {
|
||||
const sourceDate = parseDateToUTC(sourceDateString);
|
||||
const year = sourceDate.getUTCFullYear();
|
||||
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
|
||||
const hours = String(draggedStart.getHours()).padStart(2, "0");
|
||||
const minutes = String(draggedStart.getMinutes()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}T${hours}${minutes}00Z`;
|
||||
}
|
||||
|
||||
return `DTSTART:${dtstartValue};${task.recurrence}`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date for DTSTART with dragged time:", error, {
|
||||
sourceDateString,
|
||||
draggedStart,
|
||||
allDay,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type { TaskInfo };
|
||||
|
||||
interface RecurrenceScheduleInternalInput extends RecurrenceScheduleInput {
|
||||
completionDateForAnchor?: string;
|
||||
}
|
||||
|
||||
function parseDateString(dateStr: string | undefined): Date | null {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
return parseDateToUTC(dateStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLikeExisting(existingValue: string | undefined, date: Date): string {
|
||||
const datePart = formatDateForStorage(date);
|
||||
if (existingValue && existingValue.includes("T")) {
|
||||
return `${datePart}T${existingValue.split("T")[1]}`;
|
||||
}
|
||||
return datePart;
|
||||
}
|
||||
|
||||
function computeNextDue(
|
||||
input: { due?: string; scheduled?: string },
|
||||
nextScheduledDate: Date
|
||||
): string | null {
|
||||
if (!input.due || !input.scheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalDue = parseDateString(input.due);
|
||||
const originalScheduled = parseDateString(input.scheduled);
|
||||
if (!originalDue || !originalScheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
|
||||
const nextDueDate = new Date(nextScheduledDate.getTime() + offsetMs);
|
||||
return formatLikeExisting(input.due, nextDueDate);
|
||||
}
|
||||
|
||||
function buildRRuleFromRecurrence(
|
||||
recurrence: string,
|
||||
sourceDate: string
|
||||
): InstanceType<typeof RRule> | null {
|
||||
try {
|
||||
const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "").replace(/^;/, "").trim();
|
||||
if (!rruleString.includes("FREQ=")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = RRule.parseString(rruleString);
|
||||
const dtstart = parseDtstartFromRecurrence(recurrence) || parseDateString(sourceDate);
|
||||
if (dtstart) {
|
||||
options.dtstart = dtstart;
|
||||
}
|
||||
|
||||
return new RRule(options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextOccurrenceDate(
|
||||
recurrence: string,
|
||||
sourceDate: string,
|
||||
afterDate: Date,
|
||||
inclusive: boolean
|
||||
): Date | null {
|
||||
const rule = buildRRuleFromRecurrence(recurrence, sourceDate);
|
||||
if (!rule) return null;
|
||||
return rule.after(afterDate, inclusive);
|
||||
}
|
||||
|
||||
function recalculateRecurringScheduleInternal(
|
||||
input: RecurrenceScheduleInternalInput
|
||||
): RecurrenceScheduleResult {
|
||||
const anchor = input.recurrenceAnchor === "completion" ? "completion" : "scheduled";
|
||||
const sourceDate = input.scheduled || input.dateCreated || input.referenceDate;
|
||||
let updatedRecurrence = input.recurrence;
|
||||
|
||||
if (anchor === "completion") {
|
||||
if (input.completionDateForAnchor) {
|
||||
updatedRecurrence =
|
||||
updateDTSTARTInRecurrenceRule(updatedRecurrence, input.completionDateForAnchor) ||
|
||||
updatedRecurrence;
|
||||
}
|
||||
} else {
|
||||
updatedRecurrence =
|
||||
addDTSTARTToRecurrenceRule(
|
||||
{
|
||||
recurrence: updatedRecurrence,
|
||||
scheduled: sourceDate,
|
||||
}
|
||||
) || updatedRecurrence;
|
||||
}
|
||||
|
||||
const referenceDate =
|
||||
(anchor === "scheduled" ? parseDateString(input.scheduled) : null) ||
|
||||
parseDateString(input.referenceDate);
|
||||
if (!referenceDate) {
|
||||
return { updatedRecurrence, nextScheduled: null, nextDue: null };
|
||||
}
|
||||
|
||||
const completionDay = parseDateString(input.referenceDate);
|
||||
const completeInstances = Array.isArray(input.completeInstances) ? input.completeInstances : [];
|
||||
const skippedInstances = Array.isArray(input.skippedInstances) ? input.skippedInstances : [];
|
||||
const processedDates = new Set<string>(
|
||||
anchor === "completion" ? skippedInstances : [...completeInstances, ...skippedInstances]
|
||||
);
|
||||
let nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, referenceDate, true);
|
||||
|
||||
if (completionDay) {
|
||||
let guard = 0;
|
||||
while (nextOccurrence && nextOccurrence.getTime() < completionDay.getTime() && guard < 1000) {
|
||||
nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, nextOccurrence, false);
|
||||
guard++;
|
||||
}
|
||||
}
|
||||
|
||||
let processedGuard = 0;
|
||||
while (nextOccurrence && processedGuard < 1000) {
|
||||
const dateStr = formatDateForStorage(nextOccurrence);
|
||||
if (!processedDates.has(dateStr)) break;
|
||||
nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, nextOccurrence, false);
|
||||
processedGuard++;
|
||||
}
|
||||
|
||||
if (!nextOccurrence) {
|
||||
return { updatedRecurrence, nextScheduled: null, nextDue: null };
|
||||
}
|
||||
|
||||
const nextScheduled = formatLikeExisting(input.scheduled, nextOccurrence);
|
||||
const nextDue = computeNextDue(input, nextOccurrence);
|
||||
|
||||
return { updatedRecurrence, nextScheduled, nextDue };
|
||||
}
|
||||
|
||||
export function completeRecurringTask(
|
||||
input: RecurrenceCompletionInput
|
||||
): RecurrenceCompletionResult {
|
||||
const completionDate = input.completionDate;
|
||||
const completeInstances = Array.isArray(input.completeInstances) ? [...input.completeInstances] : [];
|
||||
const skippedInstances = Array.isArray(input.skippedInstances) ? [...input.skippedInstances] : [];
|
||||
|
||||
if (!completeInstances.includes(completionDate)) {
|
||||
completeInstances.push(completionDate);
|
||||
}
|
||||
const nextSkippedInstances = skippedInstances.filter((d) => d !== completionDate);
|
||||
|
||||
const schedule = recalculateRecurringScheduleInternal({
|
||||
recurrence: input.recurrence,
|
||||
recurrenceAnchor: input.recurrenceAnchor,
|
||||
scheduled: input.scheduled,
|
||||
due: input.due,
|
||||
dateCreated: input.dateCreated,
|
||||
completeInstances,
|
||||
skippedInstances: nextSkippedInstances,
|
||||
referenceDate: completionDate,
|
||||
completionDateForAnchor: completionDate,
|
||||
});
|
||||
|
||||
return {
|
||||
updatedRecurrence: schedule.updatedRecurrence,
|
||||
nextScheduled: schedule.nextScheduled,
|
||||
nextDue: schedule.nextDue,
|
||||
completeInstances,
|
||||
skippedInstances: nextSkippedInstances,
|
||||
};
|
||||
}
|
||||
|
||||
export function recalculateRecurringSchedule(
|
||||
input: RecurrenceScheduleInput
|
||||
): RecurrenceScheduleResult {
|
||||
return recalculateRecurringScheduleInternal(input);
|
||||
}
|
||||
205
src/core/specFieldMapping.ts
Normal file
205
src/core/specFieldMapping.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { basename } from "node:path";
|
||||
|
||||
export type FieldRole =
|
||||
| "title"
|
||||
| "status"
|
||||
| "priority"
|
||||
| "due"
|
||||
| "scheduled"
|
||||
| "completedDate"
|
||||
| "tags"
|
||||
| "contexts"
|
||||
| "projects"
|
||||
| "timeEstimate"
|
||||
| "dateCreated"
|
||||
| "dateModified"
|
||||
| "recurrence"
|
||||
| "recurrenceAnchor"
|
||||
| "completeInstances"
|
||||
| "skippedInstances"
|
||||
| "timeEntries";
|
||||
|
||||
const ALL_ROLES: FieldRole[] = [
|
||||
"title",
|
||||
"status",
|
||||
"priority",
|
||||
"due",
|
||||
"scheduled",
|
||||
"completedDate",
|
||||
"tags",
|
||||
"contexts",
|
||||
"projects",
|
||||
"timeEstimate",
|
||||
"dateCreated",
|
||||
"dateModified",
|
||||
"recurrence",
|
||||
"recurrenceAnchor",
|
||||
"completeInstances",
|
||||
"skippedInstances",
|
||||
"timeEntries",
|
||||
];
|
||||
|
||||
export interface SpecFieldMapping {
|
||||
roleToField: Record<FieldRole, string>;
|
||||
fieldToRole: Record<string, FieldRole>;
|
||||
displayNameKey: string;
|
||||
completedStatuses: string[];
|
||||
}
|
||||
|
||||
export function defaultFieldMapping(): SpecFieldMapping {
|
||||
const roleToField = {} as Record<FieldRole, string>;
|
||||
const fieldToRole = {} as Record<string, FieldRole>;
|
||||
|
||||
for (const role of ALL_ROLES) {
|
||||
roleToField[role] = role;
|
||||
fieldToRole[role] = role;
|
||||
}
|
||||
|
||||
return {
|
||||
roleToField,
|
||||
fieldToRole,
|
||||
displayNameKey: "title",
|
||||
completedStatuses: ["done", "cancelled"],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFieldMapping(
|
||||
fields: Record<string, any>,
|
||||
displayNameKey?: string
|
||||
): SpecFieldMapping {
|
||||
const roleToField = {} as Record<FieldRole, string>;
|
||||
const fieldToRole = {} as Record<string, FieldRole>;
|
||||
const rolesSet = new Set<string>(ALL_ROLES);
|
||||
|
||||
for (const [fieldName, def] of Object.entries(fields)) {
|
||||
if (def && typeof def === "object" && typeof def.tn_role === "string") {
|
||||
const role = def.tn_role as FieldRole;
|
||||
if (!rolesSet.has(role)) continue;
|
||||
if (roleToField[role] !== undefined) {
|
||||
console.warn(`[mtn] Duplicate tn_role "${role}" on field "${fieldName}", ignoring.`);
|
||||
continue;
|
||||
}
|
||||
roleToField[role] = fieldName;
|
||||
fieldToRole[fieldName] = role;
|
||||
}
|
||||
}
|
||||
|
||||
for (const role of ALL_ROLES) {
|
||||
if (roleToField[role] === undefined) {
|
||||
if (fields[role] !== undefined) {
|
||||
roleToField[role] = role;
|
||||
if (fieldToRole[role] === undefined) {
|
||||
fieldToRole[role] = role;
|
||||
}
|
||||
} else {
|
||||
roleToField[role] = role;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const completedStatuses = inferCompletedStatuses(fields, roleToField.status);
|
||||
|
||||
return {
|
||||
roleToField,
|
||||
fieldToRole,
|
||||
displayNameKey:
|
||||
displayNameKey && typeof displayNameKey === "string" && displayNameKey.trim().length > 0
|
||||
? displayNameKey
|
||||
: roleToField.title,
|
||||
completedStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
function inferCompletedStatuses(fields: Record<string, any>, statusFieldName: string): string[] {
|
||||
const statusDef = fields[statusFieldName];
|
||||
if (!statusDef || typeof statusDef !== "object") {
|
||||
return ["done", "cancelled"];
|
||||
}
|
||||
|
||||
if (Array.isArray(statusDef.tn_completed_values)) {
|
||||
const explicit = statusDef.tn_completed_values
|
||||
.filter((v: unknown): v is string => typeof v === "string")
|
||||
.map((v: string) => v.trim())
|
||||
.filter((v: string) => v.length > 0);
|
||||
if (explicit.length > 0) return explicit;
|
||||
}
|
||||
|
||||
if (Array.isArray(statusDef.values)) {
|
||||
const inferred = statusDef.values
|
||||
.filter((v: unknown): v is string => typeof v === "string")
|
||||
.filter((v: string) => {
|
||||
const lower = v.toLowerCase();
|
||||
return lower.includes("done") || lower.includes("complete") || lower.includes("cancel");
|
||||
});
|
||||
if (inferred.length > 0) return inferred;
|
||||
}
|
||||
|
||||
return ["done", "cancelled"];
|
||||
}
|
||||
|
||||
export function isCompletedStatus(mapping: SpecFieldMapping, status: string | undefined): boolean {
|
||||
if (!status) return false;
|
||||
return mapping.completedStatuses.includes(status);
|
||||
}
|
||||
|
||||
export function getDefaultCompletedStatus(mapping: SpecFieldMapping): string {
|
||||
return mapping.completedStatuses[0] || "done";
|
||||
}
|
||||
|
||||
export function normalizeFrontmatter(
|
||||
raw: Record<string, unknown>,
|
||||
mapping: SpecFieldMapping
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const role = mapping.fieldToRole[key];
|
||||
result[role ?? key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function denormalizeFrontmatter(
|
||||
roleData: Record<string, unknown>,
|
||||
mapping: SpecFieldMapping
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const rolesSet = new Set<string>(ALL_ROLES);
|
||||
for (const [key, value] of Object.entries(roleData)) {
|
||||
if (rolesSet.has(key)) {
|
||||
result[mapping.roleToField[key as FieldRole]] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveField(mapping: SpecFieldMapping, role: FieldRole): string {
|
||||
return mapping.roleToField[role];
|
||||
}
|
||||
|
||||
export function resolveDisplayTitle(
|
||||
frontmatter: Record<string, unknown>,
|
||||
mapping: SpecFieldMapping,
|
||||
taskPath?: string
|
||||
): string | undefined {
|
||||
const candidates = [mapping.displayNameKey, "title"];
|
||||
const seen = new Set<string>();
|
||||
for (const key of candidates) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const value = frontmatter[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof taskPath === "string" && taskPath.trim().length > 0) {
|
||||
const fromPath = basename(taskPath, ".md").trim();
|
||||
if (fromPath.length > 0) {
|
||||
return fromPath;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
/* eslint-disable no-console */
|
||||
import { FieldMapping, TaskInfo } from "../types";
|
||||
import {
|
||||
normalizeDependencyEntry,
|
||||
normalizeDependencyList,
|
||||
serializeDependencies,
|
||||
} from "../utils/dependencyUtils";
|
||||
import { validateCompleteInstances } from "../utils/dateUtils";
|
||||
isPropertyForField,
|
||||
isRecognizedProperty,
|
||||
lookupMappingKey,
|
||||
mapTaskFromFrontmatter,
|
||||
mapTaskToFrontmatter,
|
||||
toUserField,
|
||||
toUserFields,
|
||||
validateFieldMapping,
|
||||
} from "../core/fieldMapping";
|
||||
|
||||
/**
|
||||
* Service for mapping between internal field names and user-configured property names
|
||||
|
|
@ -17,21 +21,7 @@ export class FieldMapper {
|
|||
* Convert internal field name to user's property name
|
||||
*/
|
||||
toUserField(internalName: keyof FieldMapping): string {
|
||||
return this.mapping[internalName];
|
||||
}
|
||||
/**
|
||||
* Normalize arbitrary title-like values to a string.
|
||||
* - string: return as-is
|
||||
* - number/boolean: String(value)
|
||||
* - array: join elements stringified with ', '
|
||||
* - object: return empty string (unsupported edge case)
|
||||
*/
|
||||
private normalizeTitle(val: unknown): string | undefined {
|
||||
if (typeof val === "string") return val;
|
||||
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
|
||||
if (val === null || val === undefined) return undefined;
|
||||
if (typeof val === "object") return "";
|
||||
return String(val);
|
||||
return toUserField(this.mapping, internalName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,153 +32,7 @@ export class FieldMapper {
|
|||
filePath: string,
|
||||
storeTitleInFilename?: boolean
|
||||
): Partial<TaskInfo> {
|
||||
if (!frontmatter) return {};
|
||||
|
||||
const mapped: Partial<TaskInfo> = {
|
||||
path: filePath,
|
||||
};
|
||||
|
||||
// Map each field if it exists in frontmatter
|
||||
if (frontmatter[this.mapping.title] !== undefined) {
|
||||
const rawTitle = frontmatter[this.mapping.title];
|
||||
const normalized = this.normalizeTitle(rawTitle);
|
||||
if (normalized !== undefined) {
|
||||
mapped.title = normalized;
|
||||
}
|
||||
} else if (storeTitleInFilename) {
|
||||
const filename = filePath.split("/").pop()?.replace(".md", "");
|
||||
if (filename) {
|
||||
mapped.title = filename;
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.status] !== undefined) {
|
||||
const statusValue = frontmatter[this.mapping.status];
|
||||
// Handle boolean status values (convert back to string for internal use)
|
||||
if (typeof statusValue === "boolean") {
|
||||
mapped.status = statusValue ? "true" : "false";
|
||||
} else {
|
||||
mapped.status = statusValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.priority] !== undefined) {
|
||||
mapped.priority = frontmatter[this.mapping.priority];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.due] !== undefined) {
|
||||
mapped.due = frontmatter[this.mapping.due];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.scheduled] !== undefined) {
|
||||
mapped.scheduled = frontmatter[this.mapping.scheduled];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.contexts] !== undefined) {
|
||||
const contexts = frontmatter[this.mapping.contexts];
|
||||
// Ensure contexts is always an array
|
||||
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.projects] !== undefined) {
|
||||
const projects = frontmatter[this.mapping.projects];
|
||||
// Ensure projects is always an array
|
||||
mapped.projects = Array.isArray(projects) ? projects : [projects];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.timeEstimate] !== undefined) {
|
||||
mapped.timeEstimate = frontmatter[this.mapping.timeEstimate];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.completedDate] !== undefined) {
|
||||
mapped.completedDate = frontmatter[this.mapping.completedDate];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.recurrence] !== undefined) {
|
||||
mapped.recurrence = frontmatter[this.mapping.recurrence];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.recurrenceAnchor] !== undefined) {
|
||||
const anchorValue = frontmatter[this.mapping.recurrenceAnchor];
|
||||
// Validate value
|
||||
if (anchorValue === 'scheduled' || anchorValue === 'completion') {
|
||||
mapped.recurrence_anchor = anchorValue;
|
||||
} else {
|
||||
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
|
||||
mapped.recurrence_anchor = 'scheduled';
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.dateCreated] !== undefined) {
|
||||
mapped.dateCreated = frontmatter[this.mapping.dateCreated];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.dateModified] !== undefined) {
|
||||
mapped.dateModified = frontmatter[this.mapping.dateModified];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.timeEntries] !== undefined) {
|
||||
// Ensure timeEntries is always an array
|
||||
const timeEntriesValue = frontmatter[this.mapping.timeEntries];
|
||||
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.completeInstances] !== undefined) {
|
||||
// Validate and clean the complete_instances array
|
||||
mapped.complete_instances = validateCompleteInstances(
|
||||
frontmatter[this.mapping.completeInstances]
|
||||
);
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.skippedInstances] !== undefined) {
|
||||
// Validate and clean the skipped_instances array
|
||||
mapped.skipped_instances = validateCompleteInstances(
|
||||
frontmatter[this.mapping.skippedInstances]
|
||||
);
|
||||
}
|
||||
|
||||
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== undefined) {
|
||||
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
|
||||
if (dependencies) {
|
||||
mapped.blockedBy = dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.icsEventId] !== undefined) {
|
||||
const icsEventId = frontmatter[this.mapping.icsEventId];
|
||||
// Ensure icsEventId is always an array
|
||||
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.googleCalendarEventId] !== undefined) {
|
||||
mapped.googleCalendarEventId = frontmatter[this.mapping.googleCalendarEventId];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.reminders] !== undefined) {
|
||||
const reminders = frontmatter[this.mapping.reminders];
|
||||
// Ensure reminders is always an array and filter out null/undefined values
|
||||
if (Array.isArray(reminders)) {
|
||||
const filteredReminders = reminders.filter((r) => r != null);
|
||||
if (filteredReminders.length > 0) {
|
||||
mapped.reminders = filteredReminders;
|
||||
}
|
||||
} else if (reminders != null) {
|
||||
mapped.reminders = [reminders];
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.sortOrder] !== undefined) {
|
||||
const val = frontmatter[this.mapping.sortOrder];
|
||||
mapped.sortOrder = typeof val === "string" ? val : String(val);
|
||||
}
|
||||
|
||||
// Handle tags array (includes archive tag)
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
mapped.tags = frontmatter.tags;
|
||||
mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
return mapTaskFromFrontmatter(this.mapping, frontmatter, filePath, storeTitleInFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -199,124 +43,7 @@ export class FieldMapper {
|
|||
taskTag?: string,
|
||||
storeTitleInFilename?: boolean
|
||||
): any {
|
||||
const frontmatter: any = {};
|
||||
|
||||
// Map each field if it exists in task data
|
||||
if (taskData.title !== undefined) {
|
||||
frontmatter[this.mapping.title] = taskData.title;
|
||||
}
|
||||
|
||||
// Note: title is always kept in frontmatter for CLI/API compatibility,
|
||||
// even when storeTitleInFilename is true (filename is derived from title)
|
||||
|
||||
if (taskData.status !== undefined) {
|
||||
// Coerce boolean-like status strings to actual booleans for compatibility with Obsidian checkbox properties
|
||||
const lower = taskData.status.toLowerCase();
|
||||
const coercedValue =
|
||||
lower === "true" || lower === "false" ? lower === "true" : taskData.status;
|
||||
frontmatter[this.mapping.status] = coercedValue;
|
||||
}
|
||||
|
||||
if (taskData.priority !== undefined) {
|
||||
frontmatter[this.mapping.priority] = taskData.priority;
|
||||
}
|
||||
|
||||
if (taskData.due !== undefined) {
|
||||
frontmatter[this.mapping.due] = taskData.due;
|
||||
}
|
||||
|
||||
if (taskData.scheduled !== undefined) {
|
||||
frontmatter[this.mapping.scheduled] = taskData.scheduled;
|
||||
}
|
||||
|
||||
if (taskData.contexts !== undefined && (!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)) {
|
||||
frontmatter[this.mapping.contexts] = taskData.contexts;
|
||||
}
|
||||
|
||||
if (taskData.projects !== undefined && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) {
|
||||
frontmatter[this.mapping.projects] = taskData.projects;
|
||||
}
|
||||
|
||||
if (taskData.timeEstimate !== undefined) {
|
||||
frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate;
|
||||
}
|
||||
|
||||
if (taskData.completedDate !== undefined) {
|
||||
frontmatter[this.mapping.completedDate] = taskData.completedDate;
|
||||
}
|
||||
|
||||
if (taskData.recurrence !== undefined) {
|
||||
frontmatter[this.mapping.recurrence] = taskData.recurrence;
|
||||
}
|
||||
|
||||
if (taskData.recurrence_anchor !== undefined) {
|
||||
frontmatter[this.mapping.recurrenceAnchor] = taskData.recurrence_anchor;
|
||||
}
|
||||
|
||||
if (taskData.dateCreated !== undefined) {
|
||||
frontmatter[this.mapping.dateCreated] = taskData.dateCreated;
|
||||
}
|
||||
|
||||
if (taskData.dateModified !== undefined) {
|
||||
frontmatter[this.mapping.dateModified] = taskData.dateModified;
|
||||
}
|
||||
|
||||
if (taskData.sortOrder !== undefined) {
|
||||
frontmatter[this.mapping.sortOrder] = taskData.sortOrder;
|
||||
}
|
||||
|
||||
if (taskData.timeEntries !== undefined) {
|
||||
frontmatter[this.mapping.timeEntries] = taskData.timeEntries;
|
||||
}
|
||||
|
||||
if (taskData.complete_instances !== undefined) {
|
||||
frontmatter[this.mapping.completeInstances] = taskData.complete_instances;
|
||||
}
|
||||
|
||||
if (taskData.skipped_instances !== undefined && taskData.skipped_instances.length > 0) {
|
||||
frontmatter[this.mapping.skippedInstances] = taskData.skipped_instances;
|
||||
}
|
||||
|
||||
if (taskData.blockedBy !== undefined) {
|
||||
if (Array.isArray(taskData.blockedBy)) {
|
||||
const normalized = taskData.blockedBy
|
||||
.map((item) => normalizeDependencyEntry(item))
|
||||
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
|
||||
if (normalized.length > 0) {
|
||||
frontmatter[this.mapping.blockedBy] = serializeDependencies(normalized);
|
||||
}
|
||||
} else {
|
||||
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
|
||||
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
|
||||
}
|
||||
|
||||
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
|
||||
frontmatter[this.mapping.reminders] = taskData.reminders;
|
||||
}
|
||||
|
||||
// Handle tags (merge archive status into tags array)
|
||||
let tags = taskData.tags ? [...taskData.tags] : [];
|
||||
|
||||
// Ensure task tag is always preserved if provided
|
||||
if (taskTag && !tags.includes(taskTag)) {
|
||||
tags.push(taskTag);
|
||||
}
|
||||
|
||||
if (taskData.archived === true && !tags.includes(this.mapping.archiveTag)) {
|
||||
tags.push(this.mapping.archiveTag);
|
||||
} else if (taskData.archived === false) {
|
||||
tags = tags.filter((tag) => tag !== this.mapping.archiveTag);
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
frontmatter.tags = tags;
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
return mapTaskToFrontmatter(this.mapping, taskData, taskTag, storeTitleInFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -351,12 +78,7 @@ export class FieldMapper {
|
|||
* lookupMappingKey("unknown_field") // Returns: null
|
||||
*/
|
||||
lookupMappingKey(frontmatterPropertyName: string): keyof FieldMapping | null {
|
||||
for (const [mappingKey, propertyName] of Object.entries(this.mapping)) {
|
||||
if (propertyName === frontmatterPropertyName) {
|
||||
return mappingKey as keyof FieldMapping;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return lookupMappingKey(this.mapping, frontmatterPropertyName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -367,7 +89,7 @@ export class FieldMapper {
|
|||
* @returns true if the property is recognized, false otherwise
|
||||
*/
|
||||
isRecognizedProperty(frontmatterPropertyName: string): boolean {
|
||||
return this.lookupMappingKey(frontmatterPropertyName) !== null;
|
||||
return isRecognizedProperty(this.mapping, frontmatterPropertyName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -387,7 +109,7 @@ export class FieldMapper {
|
|||
* isPropertyForField("status", "status") // true
|
||||
*/
|
||||
isPropertyForField(propertyName: string, internalField: keyof FieldMapping): boolean {
|
||||
return this.mapping[internalField] === propertyName;
|
||||
return isPropertyForField(this.mapping, propertyName, internalField);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -402,7 +124,7 @@ export class FieldMapper {
|
|||
* // Returns: ["task-status", "deadline", "priority"]
|
||||
*/
|
||||
toUserFields(internalFields: (keyof FieldMapping)[]): string[] {
|
||||
return internalFields.map((field) => this.mapping[field]);
|
||||
return toUserFields(this.mapping, internalFields);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -418,25 +140,6 @@ export class FieldMapper {
|
|||
* Validate that a mapping has no empty field names
|
||||
*/
|
||||
static validateMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
const fields = Object.keys(mapping) as (keyof FieldMapping)[];
|
||||
for (const field of fields) {
|
||||
if (!mapping[field] || mapping[field].trim() === "") {
|
||||
errors.push(`Field "${field}" cannot be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate values
|
||||
const values = Object.values(mapping);
|
||||
const uniqueValues = new Set(values);
|
||||
if (values.length !== uniqueValues.size) {
|
||||
errors.push("Field mappings must have unique property names");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
return validateFieldMapping(mapping);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ import {
|
|||
} from "../utils/templateProcessor";
|
||||
import {
|
||||
addDTSTARTToRecurrenceRule,
|
||||
calculateDefaultDate,
|
||||
updateDTSTARTInRecurrenceRule,
|
||||
ensureFolderExists,
|
||||
updateToNextScheduledOccurrence,
|
||||
} from "../core/recurrence";
|
||||
import {
|
||||
calculateDefaultDate,
|
||||
ensureFolderExists,
|
||||
splitFrontmatterAndBody,
|
||||
resetMarkdownCheckboxes,
|
||||
} from "../utils/helpers";
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import {
|
|||
TaskCreationData,
|
||||
TaskInfo,
|
||||
} from "../../types";
|
||||
import { addDTSTARTToRecurrenceRule } from "../../core/recurrence";
|
||||
import { FilenameContext, generateTaskFilename, generateUniqueFilename } from "../../utils/filenameGenerator";
|
||||
import { addDTSTARTToRecurrenceRule, ensureFolderExists } from "../../utils/helpers";
|
||||
import { ensureFolderExists } from "../../utils/helpers";
|
||||
import { getCurrentTimestamp } from "../../utils/dateUtils";
|
||||
import { mergeTemplateFrontmatter } from "../../utils/templateProcessor";
|
||||
import type TaskNotesPlugin from "../../main";
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import type TaskNotesPlugin from "../../main";
|
|||
import { EVENT_TASK_UPDATED, IWebhookNotifier, TaskInfo } from "../../types";
|
||||
import {
|
||||
addDTSTARTToRecurrenceRule,
|
||||
splitFrontmatterAndBody,
|
||||
updateToNextScheduledOccurrence,
|
||||
} from "../../core/recurrence";
|
||||
import {
|
||||
splitFrontmatterAndBody,
|
||||
} from "../../utils/helpers";
|
||||
import { generateUniqueFilename } from "../../utils/filenameGenerator";
|
||||
import { getCurrentDateString, getCurrentTimestamp } from "../../utils/dateUtils";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import { normalizePath, TFile, Vault, App, parseYaml, stringifyYaml } from "obsidian";
|
||||
import { format } from "date-fns";
|
||||
import { RRule } from "rrule";
|
||||
import { TimeInfo, TaskInfo, TimeEntry, TimeBlock, DailyNoteFrontmatter } from "../types";
|
||||
import { FieldMapper } from "../services/FieldMapper";
|
||||
import { DEFAULT_FIELD_MAPPING } from "../settings/defaults";
|
||||
import {
|
||||
addDTSTARTToRecurrenceRule as addDTSTARTToRecurrenceRuleCore,
|
||||
addDTSTARTToRecurrenceRuleWithDraggedTime as addDTSTARTToRecurrenceRuleWithDraggedTimeCore,
|
||||
generateRecurringInstances as generateRecurringInstancesCore,
|
||||
getEffectiveTaskStatus as getEffectiveTaskStatusCore,
|
||||
getNextUncompletedOccurrence as getNextUncompletedOccurrenceCore,
|
||||
getRecurrenceDisplayText as getRecurrenceDisplayTextCore,
|
||||
getRecurringTaskCompletionText as getRecurringTaskCompletionTextCore,
|
||||
isDueByRRule as isDueByRRuleCore,
|
||||
shouldShowRecurringTaskOnDate as shouldShowRecurringTaskOnDateCore,
|
||||
shouldUseRecurringTaskUI as shouldUseRecurringTaskUICore,
|
||||
updateDTSTARTInRecurrenceRule as updateDTSTARTInRecurrenceRuleCore,
|
||||
updateToNextScheduledOccurrence as updateToNextScheduledOccurrenceCore,
|
||||
} from "../core/recurrence";
|
||||
import {
|
||||
getTodayString,
|
||||
parseDateToLocal,
|
||||
createUTCDateForRRule,
|
||||
formatDateForStorage,
|
||||
formatDateAsUTCString,
|
||||
hasTimeComponent,
|
||||
parseDateToUTC,
|
||||
isBeforeDateSafe as _isBeforeDateSafe,
|
||||
getTodayLocal as _getTodayLocal,
|
||||
} from "./dateUtils";
|
||||
|
|
@ -371,230 +380,42 @@ export function resetMarkdownCheckboxes(content: string): {
|
|||
* Checks if a recurring task is due on a specific date using RFC 5545 rrule
|
||||
*/
|
||||
export function isDueByRRule(task: TaskInfo, date: Date): boolean {
|
||||
// If no recurrence, non-recurring task is always shown
|
||||
if (!task.recurrence) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If recurrence is a string (rrule format), process it
|
||||
if (typeof task.recurrence === "string") {
|
||||
try {
|
||||
// Parse DTSTART from RRULE string if present, otherwise use fallback logic
|
||||
let dtstart: Date;
|
||||
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
|
||||
|
||||
if (dtstartMatch) {
|
||||
// Extract DTSTART from RRULE string (supports both YYYYMMDD and YYYYMMDDTHHMMSSZ formats)
|
||||
const dtstartStr = dtstartMatch[1];
|
||||
if (dtstartStr.length === 8) {
|
||||
// YYYYMMDD format
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
} else {
|
||||
// YYYYMMDDTHHMMSSZ format - parse manually since createUTCDateForRRule expects YYYY-MM-DD
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
|
||||
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
|
||||
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
|
||||
dtstart = new Date(Date.UTC(year, month, day, hour, minute, second, 0));
|
||||
}
|
||||
} else {
|
||||
// Fallback to original logic for backward compatibility
|
||||
if (task.scheduled) {
|
||||
dtstart = createUTCDateForRRule(task.scheduled);
|
||||
} else if (task.dateCreated) {
|
||||
dtstart = createUTCDateForRRule(task.dateCreated);
|
||||
} else {
|
||||
// If no anchor date available, task cannot generate recurring instances
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the rrule string and create RRule object
|
||||
// Remove DTSTART from the string before parsing since we set it manually
|
||||
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
|
||||
const rruleOptions = RRule.parseString(rruleString);
|
||||
rruleOptions.dtstart = dtstart;
|
||||
|
||||
const rrule = new RRule(rruleOptions);
|
||||
|
||||
// Check if the target date is an occurrence
|
||||
// Use UTC date to match the dtstart timezone
|
||||
const targetDateStart = createUTCDateForRRule(formatDateAsUTCString(date));
|
||||
const occurrences = rrule.between(
|
||||
targetDateStart,
|
||||
new Date(targetDateStart.getTime() + 24 * 60 * 60 * 1000 - 1),
|
||||
true
|
||||
);
|
||||
|
||||
return occurrences.length > 0;
|
||||
} catch (error) {
|
||||
console.error("Error evaluating rrule:", error, {
|
||||
task: task.title,
|
||||
recurrence: task.recurrence,
|
||||
});
|
||||
// Fall back to treating as non-recurring on error
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, it's a non-rrule recurrence string - treat as always due
|
||||
return true;
|
||||
return isDueByRRuleCore(task, date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective status of a task, considering recurrence
|
||||
*/
|
||||
export function getEffectiveTaskStatus(task: any, date: Date, completedStatus?: string): string {
|
||||
if (!task.recurrence) {
|
||||
return task.status || "open";
|
||||
}
|
||||
|
||||
// If it has recurrence, check if it's completed for the specified date
|
||||
const dateStr = formatDateForStorage(date);
|
||||
const completedDates = Array.isArray(task.complete_instances) ? task.complete_instances : [];
|
||||
|
||||
return completedDates.includes(dateStr)
|
||||
? (completedStatus || "done")
|
||||
: (task.status || "open");
|
||||
return getEffectiveTaskStatusCore(task, date, completedStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a recurring task should be due on the current target date
|
||||
*/
|
||||
export function shouldShowRecurringTaskOnDate(task: TaskInfo, targetDate: Date): boolean {
|
||||
if (!task.recurrence) return true; // Non-recurring tasks are always shown
|
||||
|
||||
return isDueByRRule(task, targetDate);
|
||||
return shouldShowRecurringTaskOnDateCore(task, targetDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the completion state text for a recurring task on a specific date
|
||||
*/
|
||||
export function getRecurringTaskCompletionText(task: TaskInfo, targetDate: Date): string {
|
||||
if (!task.recurrence) return "";
|
||||
|
||||
const dateStr = formatDateForStorage(targetDate);
|
||||
const isCompleted = task.complete_instances?.includes(dateStr) || false;
|
||||
|
||||
return isCompleted ? "Completed for this date" : "Not completed for this date";
|
||||
return getRecurringTaskCompletionTextCore(task, targetDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a task should use recurring task UI behavior
|
||||
*/
|
||||
export function shouldUseRecurringTaskUI(task: TaskInfo): boolean {
|
||||
return !!task.recurrence;
|
||||
return shouldUseRecurringTaskUICore(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates recurring task instances within a date range using rrule
|
||||
*/
|
||||
export function generateRecurringInstances(task: TaskInfo, startDate: Date, endDate: Date): Date[] {
|
||||
// If no recurrence, return empty array
|
||||
if (!task.recurrence) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If recurrence is a string (rrule format), use rrule
|
||||
if (typeof task.recurrence === "string") {
|
||||
try {
|
||||
// Parse DTSTART from RRULE string if present, otherwise use fallback logic
|
||||
let dtstart: Date;
|
||||
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
|
||||
|
||||
if (dtstartMatch) {
|
||||
// Extract DTSTART from RRULE string (supports both YYYYMMDD and YYYYMMDDTHHMMSSZ formats)
|
||||
const dtstartStr = dtstartMatch[1];
|
||||
if (dtstartStr.length === 8) {
|
||||
// YYYYMMDD format
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
} else {
|
||||
// YYYYMMDDTHHMMSSZ format - parse manually since createUTCDateForRRule expects YYYY-MM-DD
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
|
||||
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
|
||||
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
|
||||
dtstart = new Date(Date.UTC(year, month, day, hour, minute, second, 0));
|
||||
}
|
||||
} else {
|
||||
// Fallback to original logic for backward compatibility
|
||||
if (task.scheduled) {
|
||||
dtstart = createUTCDateForRRule(task.scheduled);
|
||||
} else if (task.dateCreated) {
|
||||
dtstart = createUTCDateForRRule(task.dateCreated);
|
||||
} else {
|
||||
// If no anchor date available, task cannot generate recurring instances
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the rrule string and create RRule object
|
||||
// Remove DTSTART from the string before parsing since we set it manually
|
||||
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
|
||||
const rruleOptions = RRule.parseString(rruleString);
|
||||
rruleOptions.dtstart = dtstart;
|
||||
|
||||
const rrule = new RRule(rruleOptions);
|
||||
|
||||
// Convert start and end dates to UTC to match dtstart
|
||||
// Use getUTC* methods to extract UTC date components, not local timezone components
|
||||
// This prevents off-by-one day errors for users in non-UTC timezones (issue #1582)
|
||||
const utcStartDate = new Date(
|
||||
Date.UTC(
|
||||
startDate.getUTCFullYear(),
|
||||
startDate.getUTCMonth(),
|
||||
startDate.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
);
|
||||
const utcEndDate = new Date(
|
||||
Date.UTC(
|
||||
endDate.getUTCFullYear(),
|
||||
endDate.getUTCMonth(),
|
||||
endDate.getUTCDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999
|
||||
)
|
||||
);
|
||||
|
||||
// Generate occurrences within the date range
|
||||
return rrule.between(utcStartDate, utcEndDate, true);
|
||||
} catch (error) {
|
||||
console.error("Error generating recurring instances:", error, {
|
||||
task: task.title,
|
||||
recurrence: task.recurrence,
|
||||
});
|
||||
// Fall back to legacy method on error
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy method (for object recurrence or errors)
|
||||
const instances: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
if (isDueByRRule(task, current)) {
|
||||
instances.push(new Date(current));
|
||||
}
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return instances;
|
||||
return generateRecurringInstancesCore(task, startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -602,184 +423,7 @@ export function generateRecurringInstances(task: TaskInfo, startDate: Date, endD
|
|||
* Returns null if no future occurrences exist
|
||||
*/
|
||||
export function getNextUncompletedOccurrence(task: TaskInfo): Date | null {
|
||||
// If no recurrence, return null
|
||||
if (!task.recurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = task.recurrence_anchor || 'scheduled'; // Default to scheduled
|
||||
|
||||
if (anchor === 'completion') {
|
||||
// For completion-based recurrence, DTSTART is updated on each completion
|
||||
// So we just need to get the next occurrence from the RRULE
|
||||
// We don't filter by complete_instances because the DTSTART has already been shifted
|
||||
return getNextCompletionBasedOccurrence(task);
|
||||
} else {
|
||||
// For scheduled-based recurrence, DTSTART is fixed
|
||||
// We need to generate occurrences and filter out completed ones
|
||||
return getNextScheduledBasedOccurrence(task);
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntervalFromRecurrence(recurrence: string): number {
|
||||
const match = recurrence.match(/INTERVAL=(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets next occurrence for scheduled-based (fixed) recurrence
|
||||
*/
|
||||
function getNextScheduledBasedOccurrence(task: TaskInfo): Date | null {
|
||||
if (!task.recurrence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current date as starting point using UTC anchor principle
|
||||
const todayStr = getTodayString(); // YYYY-MM-DD format
|
||||
const today = parseDateToUTC(todayStr); // UTC anchored for consistent logic
|
||||
|
||||
// Determine look-ahead period based on recurrence frequency
|
||||
// This ensures we can find at least one future occurrence
|
||||
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
|
||||
const interval = parseIntervalFromRecurrence(task.recurrence);
|
||||
let lookAheadDays = 365; // Default: 1 year
|
||||
if (task.recurrence.includes("FREQ=DAILY")) {
|
||||
lookAheadDays = Math.max(30, interval * 1 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
|
||||
lookAheadDays = Math.max(90, interval * 7 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
|
||||
lookAheadDays = Math.max(400, interval * 31 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=YEARLY")) {
|
||||
lookAheadDays = Math.max(800, interval * 366 * 2);
|
||||
}
|
||||
|
||||
// Start from the DTSTART (or earlier) to ensure we catch all occurrences
|
||||
// This handles cases where DTSTART is in the past
|
||||
let startDate = today;
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
|
||||
if (dtstartMatch) {
|
||||
const dtstartStr = dtstartMatch[1];
|
||||
if (dtstartStr.length === 8) {
|
||||
// YYYYMMDD format
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
const dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
// Use the earlier of today or dtstart
|
||||
startDate = dtstart < today ? dtstart : today;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endDate = new Date(today.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Generate all occurrences from startDate onwards
|
||||
const occurrences = generateRecurringInstances(task, startDate, endDate);
|
||||
|
||||
// Combine completed AND skipped instances for faster lookup
|
||||
const processedInstances = new Set([
|
||||
...(task.complete_instances || []),
|
||||
...(task.skipped_instances || []),
|
||||
]);
|
||||
|
||||
// Find the first occurrence that hasn't been completed OR skipped AND is today or in the future
|
||||
for (const occurrence of occurrences) {
|
||||
const occurrenceStr = formatDateForStorage(occurrence);
|
||||
if (!processedInstances.has(occurrenceStr) && occurrence >= today) {
|
||||
return occurrence;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No future occurrences or all completed/skipped
|
||||
} catch (error) {
|
||||
console.error("Error calculating next scheduled-based occurrence:", error, {
|
||||
task: task.title,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets next occurrence for completion-based (flexible) recurrence
|
||||
* For completion-based recurrence, the DTSTART in the RRULE is updated on each completion
|
||||
* to the completion date, so we simply get the next occurrence from the current DTSTART
|
||||
*/
|
||||
function getNextCompletionBasedOccurrence(task: TaskInfo): Date | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current date as starting point using UTC anchor principle
|
||||
const todayStr = getTodayString(); // YYYY-MM-DD format
|
||||
const today = parseDateToUTC(todayStr); // UTC anchored for consistent logic
|
||||
|
||||
// Determine look-ahead period based on recurrence frequency
|
||||
// This ensures we can find at least one future occurrence
|
||||
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
|
||||
const interval = parseIntervalFromRecurrence(task.recurrence);
|
||||
let lookAheadDays = 365; // Default: 1 year
|
||||
if (task.recurrence.includes("FREQ=DAILY")) {
|
||||
lookAheadDays = Math.max(30, interval * 1 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
|
||||
lookAheadDays = Math.max(90, interval * 7 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
|
||||
lookAheadDays = Math.max(400, interval * 31 * 2);
|
||||
} else if (task.recurrence.includes("FREQ=YEARLY")) {
|
||||
lookAheadDays = Math.max(800, interval * 366 * 2);
|
||||
}
|
||||
|
||||
// Extract DTSTART date from the RRULE
|
||||
let dtstartDate: Date | null = null;
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
|
||||
if (dtstartMatch) {
|
||||
const dtstartStr = dtstartMatch[1];
|
||||
if (dtstartStr.length === 8) {
|
||||
// YYYYMMDD format
|
||||
const year = parseInt(dtstartStr.slice(0, 4));
|
||||
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
|
||||
const day = parseInt(dtstartStr.slice(6, 8));
|
||||
dtstartDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For completion-based recurrence, we want the NEXT occurrence AFTER the DTSTART (completion date)
|
||||
// Start from DTSTART (if available) and look forward
|
||||
const startDate = dtstartDate || today;
|
||||
const endDate = new Date(startDate.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Generate occurrences from the RRULE
|
||||
const occurrences = generateRecurringInstances(task, startDate, endDate);
|
||||
|
||||
// For completion-based recurrence, we DON'T filter by complete_instances
|
||||
// because the DTSTART has already been shifted to the latest completion date
|
||||
// However, we DO filter out skipped instances
|
||||
const skippedInstances = new Set(task.skipped_instances || []);
|
||||
|
||||
// Find the first occurrence that is AFTER the DTSTART (not equal to it) and not skipped
|
||||
const dtstartTime = dtstartDate ? dtstartDate.getTime() : 0;
|
||||
for (const occurrence of occurrences) {
|
||||
const occurrenceStr = formatDateForStorage(occurrence);
|
||||
if (
|
||||
occurrence.getTime() > dtstartTime &&
|
||||
occurrence >= today &&
|
||||
!skippedInstances.has(occurrenceStr)
|
||||
) {
|
||||
return occurrence;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No future occurrences
|
||||
} catch (error) {
|
||||
console.error("Error calculating completion-based recurrence:", error, {
|
||||
task: task.title,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return getNextUncompletedOccurrenceCore(task);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -792,75 +436,14 @@ export function updateToNextScheduledOccurrence(
|
|||
task: TaskInfo,
|
||||
maintainDueOffset = true
|
||||
): { scheduled: string | null; due: string | null } {
|
||||
const nextOccurrence = getNextUncompletedOccurrence(task);
|
||||
let nextScheduleStr: string | null = null;
|
||||
let nextDueStr: string | null = null;
|
||||
let nextDueDate: Date | null = null;
|
||||
|
||||
if (nextOccurrence) {
|
||||
// Calculate the offset between original scheduled and due dates (only if setting is enabled)
|
||||
if (maintainDueOffset) {
|
||||
try {
|
||||
const originalScheduled = task.scheduled ? parseDateToUTC(task.scheduled) : null;
|
||||
const originalDue = task.due ? parseDateToUTC(task.due) : null;
|
||||
|
||||
if (originalScheduled && originalDue) {
|
||||
// Calculate the time difference
|
||||
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
|
||||
if (nextOccurrence) {
|
||||
// Apply the same offset to get the new due date
|
||||
nextDueDate = new Date(nextOccurrence.getTime() + offsetMs);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating next due date with offset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve time component if original scheduled date had time
|
||||
if (task.scheduled && task.scheduled.includes("T")) {
|
||||
const timePart = task.scheduled.split("T")[1];
|
||||
nextScheduleStr = `${formatDateForStorage(nextOccurrence)}T${timePart}`;
|
||||
} else {
|
||||
nextScheduleStr = formatDateForStorage(nextOccurrence);
|
||||
}
|
||||
if (nextDueDate && task.due && task.due.includes("T")) {
|
||||
const timePart = task.due.split("T")[1];
|
||||
nextDueStr = `${formatDateForStorage(nextDueDate)}T${timePart}`;
|
||||
} else if (nextDueDate) {
|
||||
nextDueStr = formatDateForStorage(nextDueDate);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduled: nextScheduleStr,
|
||||
due: nextDueStr,
|
||||
};
|
||||
return updateToNextScheduledOccurrenceCore(task, maintainDueOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rrule string to human-readable text
|
||||
*/
|
||||
export function getRecurrenceDisplayText(recurrence: string): string {
|
||||
if (!recurrence) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle rrule string format
|
||||
if (recurrence.includes("FREQ=")) {
|
||||
// Remove DTSTART from the string before parsing since RRule.fromString() expects only RRULE parameters
|
||||
const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "");
|
||||
const rrule = RRule.fromString(rruleString);
|
||||
return rrule.toText();
|
||||
}
|
||||
|
||||
// Fallback for unknown format
|
||||
return "rrule";
|
||||
} catch (error) {
|
||||
console.error("Error converting recurrence to display text:", error, { recurrence });
|
||||
return "rrule";
|
||||
}
|
||||
return getRecurrenceDisplayTextCore(recurrence);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1311,55 +894,7 @@ export function filterEmptyProjects(projects: string[]): string[] {
|
|||
* Follows the UTC Anchor principle for consistent date handling
|
||||
*/
|
||||
export function addDTSTARTToRecurrenceRule(task: TaskInfo): string | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if DTSTART is already present
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
return task.recurrence; // Already has DTSTART, return as-is
|
||||
}
|
||||
|
||||
// Determine the source date string using the same fallback logic as isDueByRRule
|
||||
let sourceDateString: string;
|
||||
if (task.scheduled) {
|
||||
sourceDateString = task.scheduled;
|
||||
} else if (task.dateCreated) {
|
||||
sourceDateString = task.dateCreated;
|
||||
} else {
|
||||
// No anchor date available, cannot add DTSTART
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the plugin's established date handling approach
|
||||
let dtstartValue: string;
|
||||
|
||||
if (hasTimeComponent(sourceDateString)) {
|
||||
// Has time component - parse as local time for accuracy, then format for DTSTART
|
||||
const dateTime = parseDateToLocal(sourceDateString);
|
||||
const year = dateTime.getFullYear();
|
||||
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(dateTime.getDate()).padStart(2, "0");
|
||||
const hours = String(dateTime.getHours()).padStart(2, "0");
|
||||
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
} else {
|
||||
// Date-only - use UTC anchor principle for consistency
|
||||
const date = parseDateToUTC(sourceDateString);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
// Add DTSTART at the beginning of the recurrence rule
|
||||
return `DTSTART:${dtstartValue};${task.recurrence}`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date for DTSTART:", error, { sourceDateString });
|
||||
return null; // Return null on parsing errors
|
||||
}
|
||||
return addDTSTARTToRecurrenceRuleCore(task);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1373,45 +908,7 @@ export function updateDTSTARTInRecurrenceRule(
|
|||
recurrence: string,
|
||||
dateStr: string
|
||||
): string | null {
|
||||
if (!recurrence || typeof recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format the new DTSTART value
|
||||
let dtstartValue: string;
|
||||
|
||||
if (hasTimeComponent(dateStr)) {
|
||||
// Has time component - parse as local time for accuracy, then format for DTSTART
|
||||
const dateTime = parseDateToLocal(dateStr);
|
||||
const year = dateTime.getFullYear();
|
||||
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(dateTime.getDate()).padStart(2, "0");
|
||||
const hours = String(dateTime.getHours()).padStart(2, "0");
|
||||
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
} else {
|
||||
// Date-only - use UTC anchor principle for consistency
|
||||
const date = parseDateToUTC(dateStr);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
// Check if DTSTART is already present
|
||||
if (recurrence.includes("DTSTART:")) {
|
||||
// Replace existing DTSTART
|
||||
return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`);
|
||||
} else {
|
||||
// Add DTSTART at the beginning
|
||||
return `DTSTART:${dtstartValue};${recurrence}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating DTSTART in recurrence rule:", error, { dateStr });
|
||||
return null;
|
||||
}
|
||||
return updateDTSTARTInRecurrenceRuleCore(recurrence, dateStr);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1424,60 +921,7 @@ export function addDTSTARTToRecurrenceRuleWithDraggedTime(
|
|||
draggedStart: Date,
|
||||
allDay: boolean
|
||||
): string | null {
|
||||
if (!task.recurrence || typeof task.recurrence !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if DTSTART is already present
|
||||
if (task.recurrence.includes("DTSTART:")) {
|
||||
return task.recurrence; // Already has DTSTART, return as-is
|
||||
}
|
||||
|
||||
// Determine the source date string using the same fallback logic as isDueByRRule
|
||||
let sourceDateString: string;
|
||||
if (task.scheduled) {
|
||||
sourceDateString = task.scheduled;
|
||||
} else if (task.dateCreated) {
|
||||
sourceDateString = task.dateCreated;
|
||||
} else {
|
||||
// No anchor date available, cannot add DTSTART
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the plugin's established date handling approach
|
||||
let dtstartValue: string;
|
||||
|
||||
if (allDay) {
|
||||
// All-day event - use date-only format from the source date (not draggedStart)
|
||||
const date = parseDateToUTC(sourceDateString);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}`;
|
||||
} else {
|
||||
// Timed event - use date from source, time from draggedStart
|
||||
const sourceDate = parseDateToUTC(sourceDateString);
|
||||
const year = sourceDate.getUTCFullYear();
|
||||
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
|
||||
|
||||
// Use the time from the dragged position
|
||||
const hours = String(draggedStart.getHours()).padStart(2, "0");
|
||||
const minutes = String(draggedStart.getMinutes()).padStart(2, "0");
|
||||
dtstartValue = `${year}${month}${day}T${hours}${minutes}00Z`;
|
||||
}
|
||||
|
||||
// Add DTSTART at the beginning of the recurrence rule
|
||||
return `DTSTART:${dtstartValue};${task.recurrence}`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date for DTSTART with dragged time:", error, {
|
||||
sourceDateString,
|
||||
draggedStart,
|
||||
allDay,
|
||||
});
|
||||
return null; // Return null on parsing errors
|
||||
}
|
||||
return addDTSTARTToRecurrenceRuleWithDraggedTimeCore(task, draggedStart, allDay);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
69
tests/unit/core/fieldMapping.test.ts
Normal file
69
tests/unit/core/fieldMapping.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
mapTaskFromFrontmatter,
|
||||
mapTaskToFrontmatter,
|
||||
validateFieldMapping,
|
||||
} from "../../../src/core/fieldMapping";
|
||||
import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults";
|
||||
|
||||
describe("core/fieldMapping", () => {
|
||||
it("maps frontmatter into task fields without a FieldMapper instance", () => {
|
||||
const mapped = mapTaskFromFrontmatter(
|
||||
DEFAULT_FIELD_MAPPING,
|
||||
{
|
||||
title: "Mapped title",
|
||||
status: true,
|
||||
contexts: "work",
|
||||
projects: ["alpha"],
|
||||
recurrence_anchor: "completion",
|
||||
complete_instances: ["2026-03-01", 123, "2026-03-02"],
|
||||
tags: ["task", "archived"],
|
||||
},
|
||||
"Tasks/Mapped title.md",
|
||||
true
|
||||
);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
title: "Mapped title",
|
||||
status: "true",
|
||||
contexts: ["work"],
|
||||
projects: ["alpha"],
|
||||
recurrence_anchor: "completion",
|
||||
complete_instances: ["2026-03-01", "2026-03-02"],
|
||||
tags: ["task", "archived"],
|
||||
archived: true,
|
||||
path: "Tasks/Mapped title.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps task fields back to frontmatter with serialized dependency data", () => {
|
||||
const frontmatter = mapTaskToFrontmatter(
|
||||
DEFAULT_FIELD_MAPPING,
|
||||
{
|
||||
title: "Task",
|
||||
status: "open",
|
||||
blockedBy: [{ uid: "[[Other Task]]", reltype: "FINISHTOSTART" }],
|
||||
recurrence_anchor: "scheduled",
|
||||
tags: ["alpha"],
|
||||
archived: true,
|
||||
},
|
||||
"task"
|
||||
);
|
||||
|
||||
expect(frontmatter.title).toBe("Task");
|
||||
expect(frontmatter.status).toBe("open");
|
||||
expect(frontmatter.recurrence_anchor).toBe("scheduled");
|
||||
expect(frontmatter.blockedBy).toEqual([{ uid: "[[Other Task]]", reltype: "FINISHTOSTART" }]);
|
||||
expect(frontmatter.tags).toEqual(expect.arrayContaining(["alpha", "task", "archived"]));
|
||||
});
|
||||
|
||||
it("validates duplicate mapping values as invalid", () => {
|
||||
const duplicateMapping = {
|
||||
...DEFAULT_FIELD_MAPPING,
|
||||
status: "title",
|
||||
};
|
||||
|
||||
const result = validateFieldMapping(duplicateMapping);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Field mappings must have unique property names");
|
||||
});
|
||||
});
|
||||
54
tests/unit/core/recurrence.test.ts
Normal file
54
tests/unit/core/recurrence.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
addDTSTARTToRecurrenceRule,
|
||||
getNextUncompletedOccurrence,
|
||||
updateToNextScheduledOccurrence,
|
||||
} from "../../../src/core/recurrence";
|
||||
import type { TaskInfo } from "../../../src/types";
|
||||
import * as dateUtils from "../../../src/utils/dateUtils";
|
||||
|
||||
jest.mock("../../../src/utils/dateUtils", () => {
|
||||
const actual = jest.requireActual("../../../src/utils/dateUtils");
|
||||
return {
|
||||
...actual,
|
||||
getTodayString: jest.fn(() => "2026-03-29"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("core/recurrence", () => {
|
||||
it("calculates the next scheduled-based occurrence from a narrow task shape", () => {
|
||||
const next = getNextUncompletedOccurrence({
|
||||
title: "Recurring task",
|
||||
recurrence: "DTSTART:20260301;FREQ=WEEKLY;INTERVAL=1",
|
||||
recurrence_anchor: "scheduled",
|
||||
scheduled: "2026-03-01",
|
||||
complete_instances: ["2026-03-01", "2026-03-08", "2026-03-15", "2026-03-22"],
|
||||
});
|
||||
|
||||
expect(dateUtils.formatDateForStorage(next!)).toBe("2026-03-29");
|
||||
});
|
||||
|
||||
it("preserves due offset when advancing to the next occurrence", () => {
|
||||
const result = updateToNextScheduledOccurrence({
|
||||
title: "Task with due offset",
|
||||
recurrence: "DTSTART:20260301;FREQ=WEEKLY;INTERVAL=1",
|
||||
recurrence_anchor: "scheduled",
|
||||
scheduled: "2026-03-22",
|
||||
due: "2026-03-24",
|
||||
complete_instances: ["2026-03-01", "2026-03-08", "2026-03-15", "2026-03-22"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
scheduled: "2026-03-29",
|
||||
due: "2026-03-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds DTSTART without depending on a full TaskInfo object", () => {
|
||||
const recurrence = addDTSTARTToRecurrenceRule({
|
||||
recurrence: "FREQ=DAILY;INTERVAL=2",
|
||||
scheduled: "2026-03-29",
|
||||
});
|
||||
|
||||
expect(recurrence).toBe("DTSTART:20260329;FREQ=DAILY;INTERVAL=2");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue