Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/DateNormalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { moment } from "obsidian";

// Type for flexible date input
export type FlexibleDateInput = string | moment.Moment | Date;

/**
* Handles normalization of various date input types to standardized string format
*/
export class DateNormalizer {
/**
* Normalizes a single date input to YYYY-MM-DD string format
* @param dateInput - Can be a string, moment object, or Date object
* @returns Normalized date string in YYYY-MM-DD format
* @throws Error if the input is invalid or unsupported
*/
static normalizeDateInput(dateInput: FlexibleDateInput): string {
if (dateInput === null || dateInput === undefined) {
throw new Error("Date input cannot be null or undefined");
}

// Handle moment objects
if (moment.isMoment(dateInput)) {
return dateInput.format('YYYY-MM-DD');
}

// Handle Date objects
if (dateInput instanceof Date) {
return moment(dateInput).format('YYYY-MM-DD');
}

// Handle strings
if (typeof dateInput === 'string') {
// If already in YYYY-MM-DD format, return as-is
if (/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
return dateInput;
}

// Try to parse the string with moment
const parsed = moment(dateInput);
if (parsed.isValid()) {
return parsed.format('YYYY-MM-DD');
}

throw new Error(`Invalid date string: ${dateInput}`);
}

throw new Error(`Unsupported date input type: ${typeof dateInput}`);
}

/**
* Normalizes an array of flexible date inputs to YYYY-MM-DD string format
* @param dateInputs - Array of dates in various formats
* @returns Array of normalized date strings in YYYY-MM-DD format
* @throws Error if any individual date input is invalid
*/
static normalizeDateInputs(dateInputs: FlexibleDateInput[]): string[] {
return dateInputs.map(this.normalizeDateInput);
}

/**
* Validates that a date input is supported (without parsing)
* @param dateInput - Input to validate
* @returns true if the input type is supported, false otherwise
*/
static isSupportedType(dateInput: any): dateInput is FlexibleDateInput {
return (
typeof dateInput === 'string' ||
moment.isMoment(dateInput) ||
dateInput instanceof Date
);
}

/**
* Gets a human-readable description of supported date formats
* @returns String describing supported formats
*/
static getSupportedFormatsDescription(): string {
return "Supported formats: YYYY-MM-DD strings, moment objects, Date objects, or parseable date strings (e.g., '2025/03/01', 'March 1, 2025')";
}
}
4 changes: 2 additions & 2 deletions src/icalUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const ical = require('node-ical');
import * as ical from 'node-ical';
import { tz } from 'moment-timezone';
import { moment } from "obsidian";

Expand Down Expand Up @@ -167,7 +167,7 @@ export function parseIcs(ics: string) {
const data = ical.parseICS(ics);
const vevents = [];

for (let i in data) {
for (const i in data) {
if (data[i].type != "VEVENT")
continue;
vevents.push(data[i]);
Expand Down
29 changes: 20 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
request
} from 'obsidian';
import { parseIcs, filterMatchingEvents, extractMeetingInfo } from './icalUtils';
import { IEvent, IAttendee } from './IEvent';
import { IEvent } from './IEvent';
import { DateNormalizer, FlexibleDateInput } from './DateNormalizer';

export default class ICSPlugin extends Plugin {
data: ICSSettings;
Expand Down Expand Up @@ -68,13 +69,23 @@ export default class ICSPlugin extends Plugin {
}


async getEvents(...dates: string[]): Promise<IEvent[]> {
async getEvents(...dates: FlexibleDateInput[]): Promise<IEvent[]> {
if (dates.length === 0 || dates.some(date => !date)) {
new Notice("⚠️ ICS Plugin: No valid date provided to getEvents(). Please ensure a proper date is passed in the format 'YYYY-MM-DD'. Using the current date.", 10000);
new Notice(`⚠️ ICS Plugin: No valid date provided to getEvents(). ${DateNormalizer.getSupportedFormatsDescription()}`, 10000);
}

let events: IEvent[] = [];
let errorMessages: string[] = []; // To store error messages
// Normalize all date inputs to YYYY-MM-DD string format
let normalizedDates: string[];
try {
normalizedDates = DateNormalizer.normalizeDateInputs(dates);
} catch (error) {
new Notice(`⚠️ ICS Plugin: Error parsing date inputs: ${error.message}`, 10000);
console.error("Date parsing error:", error);
return [];
}

const events: IEvent[] = [];
const errorMessages: string[] = []; // To store error messages

for (const calendar in this.data.calendars) {
const calendarSetting = this.data.calendars[calendar];
Expand All @@ -92,16 +103,16 @@ export default class ICSPlugin extends Plugin {
// Existing logic for remote URLs
icsArray = parseIcs(await request({ url: calendarSetting.icsUrl }));
}
} catch (error) {
console.error(`Error processing calendar ${calendarSetting.icsName}: ${error}`);
} catch (processingError) {
console.error(`Error processing calendar ${calendarSetting.icsName}: ${processingError}`);
errorMessages.push(`Error processing calendar "${calendarSetting.icsName}"`);
}

var dateEvents;
let dateEvents;

// Exception handling for parsing and filtering
try {
dateEvents = dateEvents = filterMatchingEvents(icsArray, dates, calendarSetting.format.showOngoing)
dateEvents = filterMatchingEvents(icsArray, normalizedDates, calendarSetting.format.showOngoing)
.filter(e => this.excludeTransparentEvents(e, calendarSetting))
.filter(e => this.excludeDeclinedEvents(e, calendarSetting));

Expand Down
230 changes: 230 additions & 0 deletions tests/DateNormalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { DateNormalizer, FlexibleDateInput } from '../src/DateNormalizer';
import { moment } from 'obsidian';

describe('DateNormalizer', () => {
describe('normalizeDateInput', () => {
describe('string inputs', () => {
it('should handle YYYY-MM-DD format (already normalized)', () => {
const result = DateNormalizer.normalizeDateInput('2025-03-01');
expect(result).toBe('2025-03-01');
});

it('should parse and normalize various string formats', () => {
const testCases = [
{ input: '2025/03/01', expected: '2025-03-01' },
{ input: '03/01/2025', expected: '2025-03-01' },
{ input: 'March 1, 2025', expected: '2025-03-01' },
{ input: '1 Mar 2025', expected: '2025-03-01' },
{ input: '2025-03-01T12:30:00', expected: '2025-03-01' },
{ input: '2025-03-01T12:30:00Z', expected: '2025-03-01' }
];

testCases.forEach(({ input, expected }) => {
const result = DateNormalizer.normalizeDateInput(input);
expect(result).toBe(expected);
});
});

it('should throw error for invalid date strings', () => {
const invalidDates = [
'invalid-date',
'not-a-date',
'foo bar',
''
];

invalidDates.forEach(invalidDate => {
expect(() => DateNormalizer.normalizeDateInput(invalidDate))
.toThrow(`Invalid date string: ${invalidDate}`);
});
});

it('should handle edge case invalid dates that moment accepts', () => {
// Note: moment is quite permissive - these dates are technically invalid
// but moment will try to parse them, so we test that they at least return something
const edgeCaseDates = [
'2025-13-01', // Invalid month - moment converts to 2026-01-01
'2025-02-30' // Invalid day for February - moment converts to 2025-03-02
];

edgeCaseDates.forEach(edgeDate => {
// These should not throw, but should return some normalized date
const result = DateNormalizer.normalizeDateInput(edgeDate);
expect(typeof result).toBe('string');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
});

describe('moment object inputs', () => {
it('should handle moment objects', () => {
const momentObj = moment('2025-03-01T12:30:00');
const result = DateNormalizer.normalizeDateInput(momentObj);
expect(result).toBe('2025-03-01');
});

it('should handle moment objects with different timezones', () => {
const momentUtc = moment.utc('2025-03-01T23:30:00');
const result = DateNormalizer.normalizeDateInput(momentUtc);
expect(result).toBe('2025-03-01');
});

it('should handle moment objects created from different sources', () => {
const fromString = moment('March 1, 2025');
const fromArray = moment([2025, 2, 1]); // month is 0-indexed in moment

expect(DateNormalizer.normalizeDateInput(fromString)).toBe('2025-03-01');
expect(DateNormalizer.normalizeDateInput(fromArray)).toBe('2025-03-01');
});
});

describe('Date object inputs', () => {
it('should handle Date objects', () => {
const dateObj = new Date('2025-03-01T12:30:00');
const result = DateNormalizer.normalizeDateInput(dateObj);
expect(result).toBe('2025-03-01');
});

it('should handle Date objects with different times', () => {
const morningDate = new Date('2025-03-01T06:00:00');
const eveningDate = new Date('2025-03-01T18:30:00');

expect(DateNormalizer.normalizeDateInput(morningDate)).toBe('2025-03-01');
expect(DateNormalizer.normalizeDateInput(eveningDate)).toBe('2025-03-01');
});
});

describe('error handling', () => {
it('should throw error for null input', () => {
expect(() => DateNormalizer.normalizeDateInput(null as any))
.toThrow('Date input cannot be null or undefined');
});

it('should throw error for undefined input', () => {
expect(() => DateNormalizer.normalizeDateInput(undefined as any))
.toThrow('Date input cannot be null or undefined');
});

it('should throw error for unsupported input types', () => {
const unsupportedInputs = [
123,
true,
false,
{},
[],
Symbol('test')
];

unsupportedInputs.forEach(input => {
expect(() => DateNormalizer.normalizeDateInput(input as any))
.toThrow(`Unsupported date input type: ${typeof input}`);
});
});
});
});

describe('normalizeDateInputs', () => {
it('should handle arrays of mixed input types', () => {
const inputs: FlexibleDateInput[] = [
'2025-03-01',
moment('2025-03-02'),
new Date('2025-03-03T12:00:00'),
'2025/03/04',
'March 5, 2025'
];

const results = DateNormalizer.normalizeDateInputs(inputs);
expect(results).toEqual([
'2025-03-01',
'2025-03-02',
'2025-03-03',
'2025-03-04',
'2025-03-05'
]);
});

it('should handle empty arrays', () => {
const results = DateNormalizer.normalizeDateInputs([]);
expect(results).toEqual([]);
});

it('should propagate errors from individual date parsing', () => {
const inputs: FlexibleDateInput[] = ['2025-03-01', 'invalid-date'];
expect(() => DateNormalizer.normalizeDateInputs(inputs))
.toThrow('Invalid date string: invalid-date');
});

it('should handle single-element arrays', () => {
const results = DateNormalizer.normalizeDateInputs([moment('2025-03-01')]);
expect(results).toEqual(['2025-03-01']);
});
});

describe('isSupportedType', () => {
it('should return true for supported types', () => {
const supportedInputs = [
'2025-03-01',
moment('2025-03-01'),
new Date('2025-03-01'),
'March 1, 2025',
'2025/03/01'
];

supportedInputs.forEach(input => {
expect(DateNormalizer.isSupportedType(input)).toBe(true);
});
});

it('should return false for unsupported types', () => {
const unsupportedInputs = [
123,
true,
null,
undefined,
{},
[],
Symbol('test')
];

unsupportedInputs.forEach(input => {
expect(DateNormalizer.isSupportedType(input)).toBe(false);
});
});
});

describe('getSupportedFormatsDescription', () => {
it('should return a helpful description string', () => {
const description = DateNormalizer.getSupportedFormatsDescription();
expect(typeof description).toBe('string');
expect(description.length).toBeGreaterThan(0);
expect(description).toContain('YYYY-MM-DD');
expect(description).toContain('moment');
expect(description).toContain('Date');
});
});

describe('integration scenarios', () => {
it('should handle Templater-style usage patterns', () => {
// Simulate common Templater scenarios
const fileTitle = '20250301'; // YYYYMMDD format
const momentFromTitle = moment(fileTitle, 'YYYYMMDD');
const result = DateNormalizer.normalizeDateInput(momentFromTitle);
expect(result).toBe('2025-03-01');
});

it('should handle date ranges', () => {
const startDate = '2025-03-01';
const endDate = moment('2025-03-07');

const results = DateNormalizer.normalizeDateInputs([startDate, endDate]);
expect(results).toEqual(['2025-03-01', '2025-03-07']);
});

it('should handle timezone edge cases', () => {
// Test dates around timezone boundaries
const utcDate = new Date('2025-03-01T23:59:59Z');
const result = DateNormalizer.normalizeDateInput(utcDate);
expect(result).toBe('2025-03-01'); // Should be same day regardless of local timezone
});
});
});
3 changes: 3 additions & 0 deletions tests/icalUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseIcs, filterMatchingEvents, extractMeetingInfo } from '../src/icalUtils';
import { moment } from 'obsidian';

describe('icalUtils', () => {
describe('parseIcs', () => {
Expand Down Expand Up @@ -197,4 +198,6 @@ END:VCALENDAR`;
expect(results[3]).toHaveLength(1); // Should appear
});
});


});