refactor(date-parsing): migrate to date-fns and add custom date format support

- Replace complex regex-based date parsing with date-fns library
- Add support for configurable custom date formats in settings
- Implement safe error handling to prevent parsing failures from throwing
- Support additional formats including timestamps (yyyyMMddHHmmss)
- Maintain backward compatibility with existing date parsing behavior
- Add UI for managing custom date formats in View Settings tab

The migration to date-fns simplifies the codebase by removing manual regex
patterns while providing more robust and standardized date parsing. Custom
formats can now be configured per-vault through the settings interface.
This commit is contained in:
quorafind 2025-08-16 14:16:38 +08:00
parent ca3f7058db
commit feaa74a963
11 changed files with 558 additions and 59 deletions

View file

@ -630,6 +630,10 @@ export interface TaskProgressBarSettings {
enablePriorityPicker: boolean;
enablePriorityKeyboardShortcuts: boolean;
enableDatePicker: boolean;
// Date Parsing Settings
customDateFormats: string[];
enableCustomDateFormats: boolean;
recurrenceDateBase: "due" | "scheduled" | "current"; // Base date for calculating next recurrence
// Task Filter Settings
@ -1425,6 +1429,10 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
blockRefPrefix: "timer"
},
// Custom Date Format Defaults
enableCustomDateFormats: false,
customDateFormats: [],
// Onboarding Defaults
onboarding: {
completed: false,

View file

@ -94,6 +94,11 @@ export const getConfig = (
// File Metadata Inheritance
fileMetadataInheritance: plugin?.settings?.fileMetadataInheritance,
// Custom date formats for parsing
customDateFormats: plugin?.settings?.enableCustomDateFormats
? plugin?.settings?.customDateFormats
: undefined,
};
return config;

View file

@ -161,6 +161,154 @@ export function renderViewSettingsTab(
.setDesc(t("Configure how task metadata is parsed and recognized."))
.setHeading();
// Date Format Configuration
new Setting(containerEl)
.setName(t("Enable custom date formats"))
.setDesc(
t(
"Enable custom date format patterns for parsing dates. When enabled, the parser will try your custom formats before falling back to default formats."
)
)
.addToggle((toggle) => {
toggle
.setValue(settingTab.plugin.settings.enableCustomDateFormats ?? false)
.onChange((value) => {
settingTab.plugin.settings.enableCustomDateFormats = value;
settingTab.applySettingsUpdate();
settingTab.display(); // Refresh to show/hide custom formats settings
});
});
if (settingTab.plugin.settings.enableCustomDateFormats) {
// Container for custom date formats
const dateFormatsContainer = containerEl.createDiv({
cls: "task-genius-date-formats-container",
});
// Header with description
dateFormatsContainer.createEl("h3", {
text: t("Custom date formats"),
cls: "task-genius-formats-header"
});
dateFormatsContainer.createEl("p", {
text: t("Add custom date format patterns. Date patterns: yyyy (4-digit year), yy (2-digit year), MM (2-digit month), M (1-2 digit month), dd (2-digit day), d (1-2 digit day), MMM (short month name), MMMM (full month name). Time patterns: HH (2-digit hour), mm (2-digit minute), ss (2-digit second). Use single quotes for literals (e.g., 'T' for ISO format)."),
cls: "setting-item-description"
});
// Container for format list
const formatListContainer = dateFormatsContainer.createDiv({
cls: "task-genius-format-list",
});
// Function to render the format list
const renderFormatList = () => {
formatListContainer.empty();
const formats = settingTab.plugin.settings.customDateFormats ?? [];
// Render existing formats
formats.forEach((format, index) => {
const formatItem = formatListContainer.createDiv({
cls: "task-genius-format-item",
});
// Format input
const formatInput = formatItem.createEl("input", {
type: "text",
value: format,
cls: "task-genius-format-input",
placeholder: t("Enter date format (e.g., yyyy-MM-dd or yyyyMMdd_HHmmss)"),
});
formatInput.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
settingTab.plugin.settings.customDateFormats![index] = target.value.trim();
settingTab.applySettingsUpdate();
});
// Delete button
const deleteBtn = formatItem.createEl("button", {
cls: "task-genius-format-delete-btn",
text: "×",
attr: {
"aria-label": t("Delete format"),
"title": t("Delete this format"),
}
});
deleteBtn.addEventListener("click", () => {
settingTab.plugin.settings.customDateFormats!.splice(index, 1);
settingTab.applySettingsUpdate();
renderFormatList();
});
});
// Add new format button
const addFormatBtn = formatListContainer.createEl("button", {
cls: "task-genius-add-format-btn",
text: t("+ Add Date Format"),
});
addFormatBtn.addEventListener("click", () => {
if (!settingTab.plugin.settings.customDateFormats) {
settingTab.plugin.settings.customDateFormats = [];
}
settingTab.plugin.settings.customDateFormats.push("");
settingTab.applySettingsUpdate();
renderFormatList();
// Focus on the new input
const inputs = formatListContainer.querySelectorAll(".task-genius-format-input");
const lastInput = inputs[inputs.length - 1] as HTMLInputElement;
if (lastInput) {
lastInput.focus();
}
});
};
// Initial render
renderFormatList();
// Add example dates section
const examplesContainer = containerEl.createDiv({
cls: "task-genius-date-examples",
});
examplesContainer.createEl("h4", {
text: t("Format Examples:"),
cls: "task-genius-examples-header"
});
const exampleFormats = [
{ format: "yyyy-MM-dd", example: "2025-08-16" },
{ format: "dd/MM/yyyy", example: "16/08/2025" },
{ format: "MM-dd-yyyy", example: "08-16-2025" },
{ format: "yyyy.MM.dd", example: "2025.08.16" },
{ format: "yyyyMMdd", example: "20250816" },
{ format: "yyyyMMdd_HHmmss", example: "20250816_144403" },
{ format: "yyyyMMddHHmmss", example: "20250816144403" },
{ format: "yyyy-MM-dd'T'HH:mm", example: "2025-08-16T14:44" },
{ format: "dd MMM yyyy", example: "16 Aug 2025" },
{ format: "MMM dd, yyyy", example: "Aug 16, 2025" },
{ format: "yyyy年MM月dd日", example: "2025年08月16日" },
];
const table = examplesContainer.createEl("table", {
cls: "task-genius-date-examples-table",
});
const headerRow = table.createEl("tr");
headerRow.createEl("th", { text: t("Format Pattern") });
headerRow.createEl("th", { text: t("Example") });
exampleFormats.forEach(({ format, example }) => {
const row = table.createEl("tr");
row.createEl("td", { text: format });
row.createEl("td", { text: example });
});
}
// Get current metadata format to show appropriate settings
const isDataviewFormat =
settingTab.plugin.settings.preferMetadataFormat === "dataview";

View file

@ -1339,3 +1339,160 @@
font-style: italic;
margin-top: 4px;
}
/* Date format configuration styles */
.task-genius-date-formats-container {
margin-top: 15px;
padding: 15px;
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.task-genius-formats-header {
margin-top: 0;
margin-bottom: 10px;
font-size: 1.1em;
font-weight: 600;
color: var(--text-normal);
}
.task-genius-format-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.task-genius-format-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background-color: var(--background-primary);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
transition: all 0.2s ease;
}
.task-genius-format-item:hover {
border-color: var(--interactive-accent);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.task-genius-format-input {
flex: 1;
padding: 6px 10px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: 0.95em;
transition: border-color 0.2s ease;
}
.task-genius-format-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.task-genius-format-input::placeholder {
color: var(--text-faint);
font-style: italic;
}
.task-genius-format-delete-btn {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-modifier-error);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
font-size: 20px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
}
.task-genius-format-delete-btn:hover {
background-color: var(--background-modifier-error-hover);
transform: scale(1.1);
}
.task-genius-add-format-btn {
padding: 10px 16px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
align-self: flex-start;
margin-top: 5px;
}
.task-genius-add-format-btn:hover {
background-color: var(--interactive-accent-hover);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.task-genius-add-format-btn:active {
transform: translateY(0);
}
/* Date format examples section */
.task-genius-date-examples {
margin-top: 20px;
padding: 15px;
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.task-genius-examples-header {
margin-top: 0;
margin-bottom: 12px;
font-size: 1em;
font-weight: 600;
color: var(--text-normal);
}
.task-genius-date-examples-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.task-genius-date-examples-table th,
.task-genius-date-examples-table td {
padding: 8px 12px;
text-align: left;
border: 1px solid var(--background-modifier-border);
}
.task-genius-date-examples-table th {
background-color: var(--background-secondary-alt);
font-weight: 600;
color: var(--text-normal);
}
.task-genius-date-examples-table td {
background-color: var(--background-primary);
font-family: var(--font-monospace);
}
.task-genius-date-examples-table tr:nth-child(even) td {
background-color: var(--background-primary-alt);
}
.task-genius-date-examples-table tr:hover td {
background-color: var(--background-modifier-hover);
}

View file

@ -25,6 +25,7 @@ export interface TaskParserConfig {
emojiMapping: Record<string, string>; // Emoji to metadata key mapping, e.g. "📅" -> "due"
metadataParseMode: MetadataParseMode; // Metadata parsing mode
specialTagPrefixes: Record<string, string>; // Special tag prefix mapping, e.g. "project" -> "project"
customDateFormats?: string[]; // Custom date format patterns for parsing dates
// File Metadata Inheritance
fileMetadataInheritance?: {

View file

@ -1,76 +1,111 @@
import {
format,
isToday,
isTomorrow,
isThisYear,
parse,
parseISO,
isValid,
startOfDay,
} from "date-fns";
import { enUS } from "date-fns/locale";
/**
* Format a date in a human-readable format
* @param date Date to format
* @returns Formatted date string
*/
export function formatDate(date: Date): string {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Check if date is today or tomorrow
if (date.getTime() === today.getTime()) {
if (isToday(date)) {
return "Today";
} else if (date.getTime() === tomorrow.getTime()) {
} else if (isTomorrow(date)) {
return "Tomorrow";
}
// Format as Month Day, Year for other dates
const options: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
};
// Only add year if it's not the current year
if (date.getFullYear() !== now.getFullYear()) {
options.year = "numeric";
if (isThisYear(date)) {
return format(date, "MMM d");
} else {
return format(date, "MMM d, yyyy");
}
return date.toLocaleDateString(undefined, options);
}
/**
* Parse a date string in the format YYYY-MM-DD
* Parse a date string in various formats
* @param dateString Date string to parse
* @param customFormats Optional array of custom date format patterns to try
* @returns Parsed date as a number or undefined if invalid
*/
export function parseLocalDate(dateString: string): number | undefined {
export function parseLocalDate(
dateString: string,
customFormats?: string[]
): number | undefined {
if (!dateString) return undefined;
// Basic regex check for YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
console.warn(`Worker: Invalid date format encountered: ${dateString}`);
// Trim whitespace
dateString = dateString.trim();
// Skip template strings
if (dateString.includes("{{") || dateString.includes("}}")) {
return undefined;
}
const parts = dateString.split("-");
if (parts.length === 3) {
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10); // 1-based month
const day = parseInt(parts[2], 10);
// Validate date parts
if (
!isNaN(year) &&
!isNaN(month) &&
month >= 1 &&
month <= 12 &&
!isNaN(day) &&
day >= 1 &&
day <= 31
) {
// Use local time to create date object
const date = new Date(year, month - 1, day);
// Check if constructed date is valid (e.g., handle 2/30 case)
if (
date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
) {
date.setHours(0, 0, 0, 0); // Standardize time part for date comparison
return date.getTime();
// Define default format patterns to try with date-fns
const defaultFormats = [
"yyyy-MM-dd", // ISO format
"yyyy/MM/dd", // YYYY/MM/DD
"dd-MM-yyyy", // DD-MM-YYYY
"dd/MM/yyyy", // DD/MM/YYYY
"MM-dd-yyyy", // MM-DD-YYYY
"MM/dd/yyyy", // MM/DD/YYYY
"yyyy.MM.dd", // YYYY.MM.DD
"dd.MM.yyyy", // DD.MM.YYYY
"yyyy年M月d日", // Chinese/Japanese format
"MMM d, yyyy", // MMM DD, YYYY (e.g., Jan 15, 2025)
"MMM dd, yyyy", // MMM DD, YYYY with leading zero
"d MMM yyyy", // DD MMM YYYY (e.g., 15 Jan 2025)
"dd MMM yyyy", // DD MMM YYYY with leading zero
"yyyyMMddHHmmss",
"yyyyMMdd_HHmmss",
];
// Combine custom formats with default formats
const allFormats = customFormats
? [...customFormats, ...defaultFormats]
: defaultFormats;
// Try each format with date-fns parse
for (const formatString of allFormats) {
try {
const parsedDate = parse(dateString, formatString, new Date(), {
locale: enUS,
});
// Check if the parsed date is valid
if (isValid(parsedDate)) {
// Set to start of day to match original behavior
const normalizedDate = startOfDay(parsedDate);
return normalizedDate.getTime();
}
} catch (e) {
// Silently continue to next format
continue;
}
}
console.warn(`Worker: Invalid date values after parsing: ${dateString}`);
// Try parseISO as a fallback for ISO strings
try {
const isoDate = parseISO(dateString);
if (isValid(isoDate)) {
const normalizedDate = startOfDay(isoDate);
return normalizedDate.getTime();
}
} catch (e) {
// Silently continue
}
// If all parsing attempts fail, log a warning
console.warn(`Worker: Could not parse date: ${dateString}`);
return undefined;
}
@ -83,8 +118,8 @@ export function parseLocalDate(dateString: string): number | undefined {
export function getTodayLocalDateString(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
@ -97,8 +132,8 @@ export function getTodayLocalDateString(): string {
*/
export function getLocalDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

View file

@ -26,6 +26,7 @@ export class MarkdownTaskParser {
private currentHeadingLevel?: number;
private fileMetadata?: Record<string, any>; // Store file frontmatter metadata
private projectConfigCache?: Record<string, any>; // Cache for project config files
private customDateFormats?: string[]; // Store custom date formats from settings
// Date parsing cache to improve performance for large-scale parsing
private static dateCache = new Map<string, number | undefined>();
@ -33,6 +34,8 @@ export class MarkdownTaskParser {
constructor(config: TaskParserConfig) {
this.config = config;
// Extract custom date formats if available
this.customDateFormats = config.customDateFormats;
}
// Public alias for extractMetadataAndTags
@ -1111,13 +1114,14 @@ export class MarkdownTaskParser {
if (!dateStr) return undefined;
// Check cache first to avoid repeated date parsing
const cachedDate = MarkdownTaskParser.dateCache.get(dateStr);
const cacheKey = `${dateStr}_${(this.customDateFormats || []).join(',')}`;
const cachedDate = MarkdownTaskParser.dateCache.get(cacheKey);
if (cachedDate !== undefined) {
return cachedDate;
}
// Parse date and cache the result
const date = parseLocalDate(dateStr);
// Parse date with custom formats and cache the result
const date = parseLocalDate(dateStr, this.customDateFormats);
// Implement cache size limit to prevent memory issues
if (
@ -1131,7 +1135,7 @@ export class MarkdownTaskParser {
}
}
MarkdownTaskParser.dateCache.set(dateStr, date);
MarkdownTaskParser.dateCache.set(cacheKey, date);
return date;
}

View file

@ -18,6 +18,7 @@ import { getConfig } from "../../common/task-parser-config";
import { FileMetadataTaskParser } from "./FileMetadataTaskParser";
import { CanvasParser } from "../parsing/CanvasParser";
import { SupportedFileType } from "../fileTypeUtils";
import { TgProject } from "../../types/task";
/**
* Enhanced task parsing using configurable parser
@ -46,7 +47,7 @@ function parseTasksWithConfigurableParser(
// Enhanced parsing: use pre-computed data if available
let enhancedFileMetadata = fileMetadata;
let projectConfigData: Record<string, any> | undefined;
let tgProject: import("../../types/task").TgProject | undefined;
let tgProject: TgProject | undefined;
// Only process enhanced project data if enhanced project is enabled
if (

View file

@ -179,6 +179,10 @@ export type TaskWorkerSettings = {
projectTagPrefix?: Record<MetadataFormat, string>;
contextTagPrefix?: Record<MetadataFormat, string>;
areaTagPrefix?: Record<MetadataFormat, string>;
// Custom date format settings
enableCustomDateFormats?: boolean;
customDateFormats?: string[];
// Enhanced project configuration (basic config for fallback)
projectConfig?: {

View file

@ -52,6 +52,8 @@ export interface WorkerPoolOptions {
focusHeading: string;
fileParsingConfig?: FileParsingConfiguration;
fileMetadataInheritance?: FileMetadataInheritanceConfig;
enableCustomDateFormats?: boolean;
customDateFormats?: string[];
};
}

File diff suppressed because one or more lines are too long