Skip to content

Commit 060ba11

Browse files
authored
Merge pull request #186 from cloud-atlas-ai/feature-flexible-date-input
feat: implement flexible date input for getEvents() method
2 parents e962977 + ca3f256 commit 060ba11

File tree

5 files changed

+335
-11
lines changed

5 files changed

+335
-11
lines changed

src/DateNormalizer.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { moment } from "obsidian";
2+
3+
// Type for flexible date input
4+
export type FlexibleDateInput = string | moment.Moment | Date;
5+
6+
/**
7+
* Handles normalization of various date input types to standardized string format
8+
*/
9+
export class DateNormalizer {
10+
/**
11+
* Normalizes a single date input to YYYY-MM-DD string format
12+
* @param dateInput - Can be a string, moment object, or Date object
13+
* @returns Normalized date string in YYYY-MM-DD format
14+
* @throws Error if the input is invalid or unsupported
15+
*/
16+
static normalizeDateInput(dateInput: FlexibleDateInput): string {
17+
if (dateInput === null || dateInput === undefined) {
18+
throw new Error("Date input cannot be null or undefined");
19+
}
20+
21+
// Handle moment objects
22+
if (moment.isMoment(dateInput)) {
23+
return dateInput.format('YYYY-MM-DD');
24+
}
25+
26+
// Handle Date objects
27+
if (dateInput instanceof Date) {
28+
return moment(dateInput).format('YYYY-MM-DD');
29+
}
30+
31+
// Handle strings
32+
if (typeof dateInput === 'string') {
33+
// If already in YYYY-MM-DD format, return as-is
34+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
35+
return dateInput;
36+
}
37+
38+
// Try to parse the string with moment
39+
const parsed = moment(dateInput);
40+
if (parsed.isValid()) {
41+
return parsed.format('YYYY-MM-DD');
42+
}
43+
44+
throw new Error(`Invalid date string: ${dateInput}`);
45+
}
46+
47+
throw new Error(`Unsupported date input type: ${typeof dateInput}`);
48+
}
49+
50+
/**
51+
* Normalizes an array of flexible date inputs to YYYY-MM-DD string format
52+
* @param dateInputs - Array of dates in various formats
53+
* @returns Array of normalized date strings in YYYY-MM-DD format
54+
* @throws Error if any individual date input is invalid
55+
*/
56+
static normalizeDateInputs(dateInputs: FlexibleDateInput[]): string[] {
57+
return dateInputs.map(this.normalizeDateInput);
58+
}
59+
60+
/**
61+
* Validates that a date input is supported (without parsing)
62+
* @param dateInput - Input to validate
63+
* @returns true if the input type is supported, false otherwise
64+
*/
65+
static isSupportedType(dateInput: any): dateInput is FlexibleDateInput {
66+
return (
67+
typeof dateInput === 'string' ||
68+
moment.isMoment(dateInput) ||
69+
dateInput instanceof Date
70+
);
71+
}
72+
73+
/**
74+
* Gets a human-readable description of supported date formats
75+
* @returns String describing supported formats
76+
*/
77+
static getSupportedFormatsDescription(): string {
78+
return "Supported formats: YYYY-MM-DD strings, moment objects, Date objects, or parseable date strings (e.g., '2025/03/01', 'March 1, 2025')";
79+
}
80+
}

src/icalUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const ical = require('node-ical');
1+
import * as ical from 'node-ical';
22
import { tz } from 'moment-timezone';
33
import { moment } from "obsidian";
44

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

170-
for (let i in data) {
170+
for (const i in data) {
171171
if (data[i].type != "VEVENT")
172172
continue;
173173
vevents.push(data[i]);

src/main.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import {
2020
request
2121
} from 'obsidian';
2222
import { parseIcs, filterMatchingEvents, extractMeetingInfo } from './icalUtils';
23-
import { IEvent, IAttendee } from './IEvent';
23+
import { IEvent } from './IEvent';
24+
import { DateNormalizer, FlexibleDateInput } from './DateNormalizer';
2425

2526
export default class ICSPlugin extends Plugin {
2627
data: ICSSettings;
@@ -68,13 +69,23 @@ export default class ICSPlugin extends Plugin {
6869
}
6970

7071

71-
async getEvents(...dates: string[]): Promise<IEvent[]> {
72+
async getEvents(...dates: FlexibleDateInput[]): Promise<IEvent[]> {
7273
if (dates.length === 0 || dates.some(date => !date)) {
73-
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);
74+
new Notice(`⚠️ ICS Plugin: No valid date provided to getEvents(). ${DateNormalizer.getSupportedFormatsDescription()}`, 10000);
7475
}
7576

76-
let events: IEvent[] = [];
77-
let errorMessages: string[] = []; // To store error messages
77+
// Normalize all date inputs to YYYY-MM-DD string format
78+
let normalizedDates: string[];
79+
try {
80+
normalizedDates = DateNormalizer.normalizeDateInputs(dates);
81+
} catch (error) {
82+
new Notice(`⚠️ ICS Plugin: Error parsing date inputs: ${error.message}`, 10000);
83+
console.error("Date parsing error:", error);
84+
return [];
85+
}
86+
87+
const events: IEvent[] = [];
88+
const errorMessages: string[] = []; // To store error messages
7889

7990
for (const calendar in this.data.calendars) {
8091
const calendarSetting = this.data.calendars[calendar];
@@ -92,16 +103,16 @@ export default class ICSPlugin extends Plugin {
92103
// Existing logic for remote URLs
93104
icsArray = parseIcs(await request({ url: calendarSetting.icsUrl }));
94105
}
95-
} catch (error) {
96-
console.error(`Error processing calendar ${calendarSetting.icsName}: ${error}`);
106+
} catch (processingError) {
107+
console.error(`Error processing calendar ${calendarSetting.icsName}: ${processingError}`);
97108
errorMessages.push(`Error processing calendar "${calendarSetting.icsName}"`);
98109
}
99110

100-
var dateEvents;
111+
let dateEvents;
101112

102113
// Exception handling for parsing and filtering
103114
try {
104-
dateEvents = dateEvents = filterMatchingEvents(icsArray, dates, calendarSetting.format.showOngoing)
115+
dateEvents = filterMatchingEvents(icsArray, normalizedDates, calendarSetting.format.showOngoing)
105116
.filter(e => this.excludeTransparentEvents(e, calendarSetting))
106117
.filter(e => this.excludeDeclinedEvents(e, calendarSetting));
107118

tests/DateNormalizer.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { DateNormalizer, FlexibleDateInput } from '../src/DateNormalizer';
2+
import { moment } from 'obsidian';
3+
4+
describe('DateNormalizer', () => {
5+
describe('normalizeDateInput', () => {
6+
describe('string inputs', () => {
7+
it('should handle YYYY-MM-DD format (already normalized)', () => {
8+
const result = DateNormalizer.normalizeDateInput('2025-03-01');
9+
expect(result).toBe('2025-03-01');
10+
});
11+
12+
it('should parse and normalize various string formats', () => {
13+
const testCases = [
14+
{ input: '2025/03/01', expected: '2025-03-01' },
15+
{ input: '03/01/2025', expected: '2025-03-01' },
16+
{ input: 'March 1, 2025', expected: '2025-03-01' },
17+
{ input: '1 Mar 2025', expected: '2025-03-01' },
18+
{ input: '2025-03-01T12:30:00', expected: '2025-03-01' },
19+
{ input: '2025-03-01T12:30:00Z', expected: '2025-03-01' }
20+
];
21+
22+
testCases.forEach(({ input, expected }) => {
23+
const result = DateNormalizer.normalizeDateInput(input);
24+
expect(result).toBe(expected);
25+
});
26+
});
27+
28+
it('should throw error for invalid date strings', () => {
29+
const invalidDates = [
30+
'invalid-date',
31+
'not-a-date',
32+
'foo bar',
33+
''
34+
];
35+
36+
invalidDates.forEach(invalidDate => {
37+
expect(() => DateNormalizer.normalizeDateInput(invalidDate))
38+
.toThrow(`Invalid date string: ${invalidDate}`);
39+
});
40+
});
41+
42+
it('should handle edge case invalid dates that moment accepts', () => {
43+
// Note: moment is quite permissive - these dates are technically invalid
44+
// but moment will try to parse them, so we test that they at least return something
45+
const edgeCaseDates = [
46+
'2025-13-01', // Invalid month - moment converts to 2026-01-01
47+
'2025-02-30' // Invalid day for February - moment converts to 2025-03-02
48+
];
49+
50+
edgeCaseDates.forEach(edgeDate => {
51+
// These should not throw, but should return some normalized date
52+
const result = DateNormalizer.normalizeDateInput(edgeDate);
53+
expect(typeof result).toBe('string');
54+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
55+
});
56+
});
57+
});
58+
59+
describe('moment object inputs', () => {
60+
it('should handle moment objects', () => {
61+
const momentObj = moment('2025-03-01T12:30:00');
62+
const result = DateNormalizer.normalizeDateInput(momentObj);
63+
expect(result).toBe('2025-03-01');
64+
});
65+
66+
it('should handle moment objects with different timezones', () => {
67+
const momentUtc = moment.utc('2025-03-01T23:30:00');
68+
const result = DateNormalizer.normalizeDateInput(momentUtc);
69+
expect(result).toBe('2025-03-01');
70+
});
71+
72+
it('should handle moment objects created from different sources', () => {
73+
const fromString = moment('March 1, 2025');
74+
const fromArray = moment([2025, 2, 1]); // month is 0-indexed in moment
75+
76+
expect(DateNormalizer.normalizeDateInput(fromString)).toBe('2025-03-01');
77+
expect(DateNormalizer.normalizeDateInput(fromArray)).toBe('2025-03-01');
78+
});
79+
});
80+
81+
describe('Date object inputs', () => {
82+
it('should handle Date objects', () => {
83+
const dateObj = new Date('2025-03-01T12:30:00');
84+
const result = DateNormalizer.normalizeDateInput(dateObj);
85+
expect(result).toBe('2025-03-01');
86+
});
87+
88+
it('should handle Date objects with different times', () => {
89+
const morningDate = new Date('2025-03-01T06:00:00');
90+
const eveningDate = new Date('2025-03-01T18:30:00');
91+
92+
expect(DateNormalizer.normalizeDateInput(morningDate)).toBe('2025-03-01');
93+
expect(DateNormalizer.normalizeDateInput(eveningDate)).toBe('2025-03-01');
94+
});
95+
});
96+
97+
describe('error handling', () => {
98+
it('should throw error for null input', () => {
99+
expect(() => DateNormalizer.normalizeDateInput(null as any))
100+
.toThrow('Date input cannot be null or undefined');
101+
});
102+
103+
it('should throw error for undefined input', () => {
104+
expect(() => DateNormalizer.normalizeDateInput(undefined as any))
105+
.toThrow('Date input cannot be null or undefined');
106+
});
107+
108+
it('should throw error for unsupported input types', () => {
109+
const unsupportedInputs = [
110+
123,
111+
true,
112+
false,
113+
{},
114+
[],
115+
Symbol('test')
116+
];
117+
118+
unsupportedInputs.forEach(input => {
119+
expect(() => DateNormalizer.normalizeDateInput(input as any))
120+
.toThrow(`Unsupported date input type: ${typeof input}`);
121+
});
122+
});
123+
});
124+
});
125+
126+
describe('normalizeDateInputs', () => {
127+
it('should handle arrays of mixed input types', () => {
128+
const inputs: FlexibleDateInput[] = [
129+
'2025-03-01',
130+
moment('2025-03-02'),
131+
new Date('2025-03-03T12:00:00'),
132+
'2025/03/04',
133+
'March 5, 2025'
134+
];
135+
136+
const results = DateNormalizer.normalizeDateInputs(inputs);
137+
expect(results).toEqual([
138+
'2025-03-01',
139+
'2025-03-02',
140+
'2025-03-03',
141+
'2025-03-04',
142+
'2025-03-05'
143+
]);
144+
});
145+
146+
it('should handle empty arrays', () => {
147+
const results = DateNormalizer.normalizeDateInputs([]);
148+
expect(results).toEqual([]);
149+
});
150+
151+
it('should propagate errors from individual date parsing', () => {
152+
const inputs: FlexibleDateInput[] = ['2025-03-01', 'invalid-date'];
153+
expect(() => DateNormalizer.normalizeDateInputs(inputs))
154+
.toThrow('Invalid date string: invalid-date');
155+
});
156+
157+
it('should handle single-element arrays', () => {
158+
const results = DateNormalizer.normalizeDateInputs([moment('2025-03-01')]);
159+
expect(results).toEqual(['2025-03-01']);
160+
});
161+
});
162+
163+
describe('isSupportedType', () => {
164+
it('should return true for supported types', () => {
165+
const supportedInputs = [
166+
'2025-03-01',
167+
moment('2025-03-01'),
168+
new Date('2025-03-01'),
169+
'March 1, 2025',
170+
'2025/03/01'
171+
];
172+
173+
supportedInputs.forEach(input => {
174+
expect(DateNormalizer.isSupportedType(input)).toBe(true);
175+
});
176+
});
177+
178+
it('should return false for unsupported types', () => {
179+
const unsupportedInputs = [
180+
123,
181+
true,
182+
null,
183+
undefined,
184+
{},
185+
[],
186+
Symbol('test')
187+
];
188+
189+
unsupportedInputs.forEach(input => {
190+
expect(DateNormalizer.isSupportedType(input)).toBe(false);
191+
});
192+
});
193+
});
194+
195+
describe('getSupportedFormatsDescription', () => {
196+
it('should return a helpful description string', () => {
197+
const description = DateNormalizer.getSupportedFormatsDescription();
198+
expect(typeof description).toBe('string');
199+
expect(description.length).toBeGreaterThan(0);
200+
expect(description).toContain('YYYY-MM-DD');
201+
expect(description).toContain('moment');
202+
expect(description).toContain('Date');
203+
});
204+
});
205+
206+
describe('integration scenarios', () => {
207+
it('should handle Templater-style usage patterns', () => {
208+
// Simulate common Templater scenarios
209+
const fileTitle = '20250301'; // YYYYMMDD format
210+
const momentFromTitle = moment(fileTitle, 'YYYYMMDD');
211+
const result = DateNormalizer.normalizeDateInput(momentFromTitle);
212+
expect(result).toBe('2025-03-01');
213+
});
214+
215+
it('should handle date ranges', () => {
216+
const startDate = '2025-03-01';
217+
const endDate = moment('2025-03-07');
218+
219+
const results = DateNormalizer.normalizeDateInputs([startDate, endDate]);
220+
expect(results).toEqual(['2025-03-01', '2025-03-07']);
221+
});
222+
223+
it('should handle timezone edge cases', () => {
224+
// Test dates around timezone boundaries
225+
const utcDate = new Date('2025-03-01T23:59:59Z');
226+
const result = DateNormalizer.normalizeDateInput(utcDate);
227+
expect(result).toBe('2025-03-01'); // Should be same day regardless of local timezone
228+
});
229+
});
230+
});

tests/icalUtils.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { parseIcs, filterMatchingEvents, extractMeetingInfo } from '../src/icalUtils';
2+
import { moment } from 'obsidian';
23

34
describe('icalUtils', () => {
45
describe('parseIcs', () => {
@@ -197,4 +198,6 @@ END:VCALENDAR`;
197198
expect(results[3]).toHaveLength(1); // Should appear
198199
});
199200
});
201+
202+
200203
});

0 commit comments

Comments
 (0)