iamcal 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,18 +1,15 @@
1
- export interface Property {
2
- name: string
3
- params: string[]
4
- value: string
5
- }
1
+ import { parseDate, toDateString, toDateTimeString } from './parse'
2
+ import { Property } from './types'
6
3
 
7
4
  // Max line length as defined by RFC 5545 3.1.
8
5
  const MAX_LINE_LENGTH = 75
9
6
 
10
7
  export class Component {
11
8
  name: string
12
- properties: Array<Property>
13
- components: Array<Component>
9
+ properties: Property[]
10
+ components: Component[]
14
11
 
15
- constructor(name: string, properties?: Array<Property>, components?: Array<Component>) {
12
+ constructor(name: string, properties?: Property[], components?: Component[]) {
16
13
  this.name = name
17
14
  if (properties) {
18
15
  this.properties = properties
@@ -40,7 +37,7 @@ export class Component {
40
37
  // Wrap lines
41
38
  while (line.length > MAX_LINE_LENGTH) {
42
39
  lines.push(line.substring(0, MAX_LINE_LENGTH))
43
- line = " " + line.substring(MAX_LINE_LENGTH)
40
+ line = ' ' + line.substring(MAX_LINE_LENGTH)
44
41
  }
45
42
  lines.push(line)
46
43
  }
@@ -66,11 +63,11 @@ export class Component {
66
63
  return null
67
64
  }
68
65
 
69
- setProperty(name: string, value: string) {
66
+ setProperty(name: string, value: string): this {
70
67
  for (const property of this.properties) {
71
68
  if (property.name === name) {
72
69
  property.value = value
73
- return
70
+ return this
74
71
  }
75
72
  }
76
73
  this.properties.push({
@@ -78,6 +75,14 @@ export class Component {
78
75
  params: [],
79
76
  value: value,
80
77
  })
78
+ return this
79
+ }
80
+
81
+ removePropertiesWithName(name: string) {
82
+ const index = this.properties.findIndex(p => p.name === name)
83
+ if (index === -1) return this
84
+ // Remove property at index
85
+ this.properties.splice(index, 1)
81
86
  }
82
87
 
83
88
  getPropertyParams(name: string): string[] | null {
@@ -89,79 +94,486 @@ export class Component {
89
94
  return null
90
95
  }
91
96
 
92
- setPropertyParams(name: string, params: string[]) {
97
+ setPropertyParams(name: string, params: string[]): this {
93
98
  for (const property of this.properties) {
94
99
  if (property.name === name) {
95
100
  property.params = params
96
101
  }
97
102
  }
103
+ return this
104
+ }
105
+
106
+ addComponent(component: Component): this {
107
+ this.components.push(component)
108
+ return this
109
+ }
110
+
111
+ /**
112
+ * Remove a component from this component
113
+ * @returns `true` if the component was removed. `false` if the component was not present
114
+ */
115
+ removeComponent(component: Component): boolean {
116
+ const index = this.components.indexOf(component)
117
+ if (index === -1) return false
118
+
119
+ // Remove element at index from list
120
+ this.components.splice(index, 1)
121
+
122
+ return true
123
+ }
124
+
125
+ getComponents(name: string): Component[] {
126
+ const components: Component[] = []
127
+
128
+ for (let component of this.components) {
129
+ if (component.name === name) {
130
+ components.push(component)
131
+ }
132
+ }
133
+
134
+ return components
98
135
  }
99
136
  }
100
137
 
138
+ /**
139
+ * Represents a VCALENDAR component, the root component of an iCalendar file.
140
+ */
101
141
  export class Calendar extends Component {
102
142
  name = 'VCALENDAR'
103
143
 
104
- constructor(component: Component) {
144
+ /**
145
+ * @param prodid A unique identifier of the program creating the calendar.
146
+ *
147
+ * Example: `-//Google Inc//Google Calendar 70.9054//EN`
148
+ */
149
+ constructor(prodid: string)
150
+ /**
151
+ * @param prodid A unique identifier of the program creating the calendar.
152
+ *
153
+ * Example: `-//Google Inc//Google Calendar 70.9054//EN`
154
+ * @param version The version of the iCalendar specification that this calendar uses.
155
+ */
156
+ constructor(prodid: string, version: string)
157
+ /**
158
+ * @param component A VCALENDAR component.
159
+ */
160
+ constructor(component: Component)
161
+ constructor(a?: string | Component, b?: string) {
162
+ var component: Component
163
+ if (a instanceof Component) {
164
+ component = a as Component
165
+ } else {
166
+ const prodid = a as string
167
+ const version = (b as string) ?? '2.0'
168
+ component = new Component('VCALENDAR')
169
+ component.setProperty('PRODID', prodid)
170
+ component.setProperty('VERSION', version)
171
+ }
105
172
  super(component.name, component.properties, component.components)
106
173
  }
107
174
 
108
- events(): Array<CalendarEvent> {
109
- const events = new Array<CalendarEvent>()
175
+ events(): CalendarEvent[] {
176
+ return this.getComponents('VEVENT').map(c => new CalendarEvent(c))
177
+ }
110
178
 
111
- for (let component of this.components) {
112
- if (component.name === 'VEVENT') {
113
- events.push(new CalendarEvent(component))
179
+ removeEvent(event: CalendarEvent): boolean
180
+ removeEvent(uid: string): boolean
181
+ removeEvent(a: CalendarEvent | string): boolean {
182
+ if (a instanceof CalendarEvent) {
183
+ const event = a as CalendarEvent
184
+ return this.removeComponent(event)
185
+ } else {
186
+ const uid = a as string
187
+ for (const event of this.events()) {
188
+ if (event.uid() !== uid) continue
189
+ return this.removeComponent(event)
114
190
  }
115
191
  }
192
+ return false
193
+ }
194
+
195
+ prodId(): string {
196
+ return this.getProperty('PRODID')!.value
197
+ }
198
+
199
+ setProdId(value: string): this {
200
+ return this.setProperty('PRODID', value)
201
+ }
202
+
203
+ version(): string {
204
+ return this.getProperty('VERSION')!.value
205
+ }
206
+
207
+ setVersion(value: string): this {
208
+ return this.setProperty('VERSION', value)
209
+ }
210
+
211
+ calScale(): string | undefined {
212
+ return this.getProperty('CALSCALE')?.value
213
+ }
214
+
215
+ setCalScale(value: string): this {
216
+ return this.setProperty('CALSCALE', value)
217
+ }
116
218
 
117
- return events
219
+ removeCalScale() {
220
+ this.removePropertiesWithName('CALSCALE')
221
+ }
222
+
223
+ method(): string | undefined {
224
+ return this.getProperty('METHOD')?.value
225
+ }
226
+
227
+ setMethod(value: string) {
228
+ this.setProperty('METHOD', value)
229
+ }
230
+
231
+ removeMethod() {
232
+ this.removePropertiesWithName('METHOD')
233
+ }
234
+
235
+ calendarName(): string | undefined {
236
+ return this.getProperty('X-WR-CALNAME')?.value
237
+ }
238
+
239
+ setCalendarName(value: string) {
240
+ this.setProperty('X-WR-CALNAME', value)
241
+ }
242
+
243
+ removeCalendarName() {
244
+ this.removePropertiesWithName('X-WR-CALNAME')
245
+ }
246
+
247
+ calendarDescription(): string | undefined {
248
+ return this.getProperty('X-WR-CALDESC')?.value
249
+ }
250
+
251
+ setCalendarDescription(value: string): this {
252
+ return this.setProperty('X-WR-CALDESC', value)
253
+ }
254
+
255
+ removeCalendarDescription() {
256
+ this.removePropertiesWithName('X-WR-CALDESC')
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Represents a VTIMEZONE component, containing time zone definitions.
262
+ */
263
+ export class TimeZone extends Component {
264
+ constructor(id: string)
265
+ constructor(component: Component)
266
+ constructor(a: string | Component) {
267
+ var component: Component
268
+ if (a instanceof Component) {
269
+ component = a as Component
270
+ } else {
271
+ const tzid = a as string
272
+ component = new Component('VTIMEZONE')
273
+ component.setProperty('TZID', tzid)
274
+ }
275
+ super(component.name, component.properties, component.components)
276
+ }
277
+
278
+ id(): string {
279
+ return this.getProperty('TZID')!.value
280
+ }
281
+
282
+ setId(value: string): this {
283
+ return this.setProperty('TZID', value)
284
+ }
285
+
286
+ lastMod(): Date | undefined {
287
+ const text = this.getProperty('LAST-MOD')
288
+ if (!text) return
289
+ return parseDate(text)
290
+ }
291
+
292
+ setLastMod(value: Date): this {
293
+ return this.setProperty('LAST-MOD', value.toISOString())
294
+ }
295
+
296
+ removeLastMod() {
297
+ this.removePropertiesWithName('LAST-MOD')
298
+ }
299
+
300
+ url(): string | undefined {
301
+ return this.getProperty('TZURL')?.value
302
+ }
303
+
304
+ setUrl(value: string): this {
305
+ return this.setProperty('TZURL', value)
306
+ }
307
+
308
+ removeUrl() {
309
+ this.removePropertiesWithName('TZURL')
310
+ }
311
+
312
+ /** Get all time offsets. */
313
+ offsets(): TimeZoneOffset[] {
314
+ const offsets: TimeZoneOffset[] = []
315
+ this.components.forEach(component => {
316
+ if (component.name === 'STANDARD' || component.name === 'DAYLIGHT') {
317
+ offsets.push(new TimeZoneOffset(component))
318
+ }
319
+ })
320
+ return offsets
321
+ }
322
+
323
+ /** Get standard/winter time offsets. */
324
+ standardOffsets(): TimeZoneOffset[] {
325
+ return this.getComponents('STANDARD').map(c => new TimeZoneOffset(c))
326
+ }
327
+
328
+ /** Get daylight savings time offsets. */
329
+ daylightOffsets(): TimeZoneOffset[] {
330
+ return this.getComponents('DAYLIGHT').map(c => new TimeZoneOffset(c))
331
+ }
332
+ }
333
+
334
+ export type OffsetType = 'DAYLIGHT' | 'STANDARD'
335
+ type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
336
+ export type Offset = `${'-' | '+'}${Digit}${Digit}${Digit}${Digit}`
337
+ /** Represents a STANDARD or DAYLIGHT component, defining a time zone offset. */
338
+ class TimeZoneOffset extends Component {
339
+ /**
340
+ *
341
+ * @param type If this is a STANDARD or DAYLIGHT component.
342
+ * @param start From when this offset is active.
343
+ * @param offsetFrom The offset that is in use prior to this time zone observance.
344
+ * @param offsetTo The offset that is in use during this time zone observance.
345
+ */
346
+ constructor(type: OffsetType, start: Date, offsetFrom: Offset, offsetTo: Offset)
347
+ constructor(component: Component)
348
+ constructor(a: OffsetType | Component, b?: Date, c?: Offset, d?: Offset) {
349
+ var component: Component
350
+ if (a instanceof Component) {
351
+ component = a as Component
352
+ } else {
353
+ const name = a as OffsetType
354
+ const start = b as Date
355
+ const offsetFrom = c as Offset
356
+ const offsetTo = d as Offset
357
+ component = new Component(name)
358
+ component.setProperty('DTSTART', toDateTimeString(start))
359
+ component.setProperty('TZOFFSETFROM', offsetFrom)
360
+ component.setProperty('TZOFFSETTO', offsetTo)
361
+ }
362
+ super(component.name, component.properties, component.components)
363
+ }
364
+
365
+ start(): Date {
366
+ return parseDate(this.getProperty('DTSTART')!)
367
+ }
368
+
369
+ setStart(value: Date, fullDay: boolean = false): this {
370
+ if (fullDay) {
371
+ this.setProperty('DTSTART', toDateString(value))
372
+ this.setPropertyParams('DTSTART', ['VALUE=DATE'])
373
+ } else {
374
+ this.setProperty('DTSTART', toDateTimeString(value))
375
+ }
376
+ return this
377
+ }
378
+
379
+ offsetFrom(): Offset {
380
+ return this.getProperty('TZOFFSETFROM')!.value as Offset
381
+ }
382
+
383
+ setOffsetFrom(value: Offset): this {
384
+ return this.setProperty('TZOFFSETFROM', value)
385
+ }
386
+
387
+ offsetTo(): Offset {
388
+ return this.getProperty('TZOFFSETTO')!.value as Offset
389
+ }
390
+
391
+ setOffsetTo(value: Offset): this {
392
+ return this.setProperty('TZOFFSETTO', value)
393
+ }
394
+
395
+ comment(): string | undefined {
396
+ return this.getProperty('COMMENT')?.value
397
+ }
398
+
399
+ setComment(value: string): this {
400
+ return this.setProperty('COMMENT', value)
401
+ }
402
+
403
+ removeComment() {
404
+ this.removePropertiesWithName('COMMENT')
405
+ }
406
+
407
+ timeZoneName(): string | undefined {
408
+ return this.getProperty('TZNAME')?.value
409
+ }
410
+
411
+ setTimeZoneName(value: string): this {
412
+ return this.setProperty('TZNAME', value)
413
+ }
414
+
415
+ removeTimeZoneName() {
416
+ this.removePropertiesWithName('TZNAME')
118
417
  }
119
418
  }
120
419
 
121
420
  export class CalendarEvent extends Component {
122
421
  name = 'VEVENT'
123
422
 
124
- constructor(component: Component) {
423
+ constructor(uid: string, dtstamp: Date)
424
+ constructor(component: Component)
425
+ constructor(a: string | Component, b?: Date) {
426
+ var component: Component
427
+ if (b) {
428
+ const uid = a as string
429
+ const dtstamp = b as Date
430
+ component = new Component('VEVENT')
431
+ component.setProperty('UID', uid)
432
+ component.setProperty('DTSTAMP', toDateTimeString(dtstamp))
433
+ } else {
434
+ component = a as Component
435
+ }
125
436
  super(component.name, component.properties, component.components)
126
437
  }
127
438
 
439
+ stamp(): Date {
440
+ return parseDate(this.getProperty('DTSTAMP')!)
441
+ }
442
+
443
+ setStamp(value: Date, fullDay: boolean = false): this {
444
+ if (fullDay) {
445
+ this.setProperty('DTSTAMP', toDateString(value))
446
+ this.setPropertyParams('DTSTAMP', ['VALUE=DATE'])
447
+ } else {
448
+ this.setProperty('DTSTAMP', toDateTimeString(value))
449
+ }
450
+ return this
451
+ }
452
+
453
+ uid(): string {
454
+ return this.getProperty('UID')!.value
455
+ }
456
+
457
+ setUid(value: string): this {
458
+ return this.setProperty('UID', value)
459
+ }
460
+
128
461
  summary(): string {
129
462
  return this.getProperty('SUMMARY')!.value
130
463
  }
131
464
 
132
- setSummary(value: string) {
133
- this.setProperty("SUMMARY", value)
465
+ setSummary(value: string): this {
466
+ return this.setProperty('SUMMARY', value)
467
+ }
468
+
469
+ removeSummary() {
470
+ this.removePropertiesWithName('SUMMARY')
134
471
  }
135
472
 
136
473
  description(): string {
137
474
  return this.getProperty('DESCRIPTION')!.value
138
475
  }
139
476
 
140
- setDescription(value: string) {
141
- this.setProperty("DESCRIPTION", value)
477
+ setDescription(value: string): this {
478
+ return this.setProperty('DESCRIPTION', value)
479
+ }
480
+
481
+ removeDescription() {
482
+ this.removePropertiesWithName('DESCRIPTION')
142
483
  }
143
484
 
144
485
  location(): string | undefined {
145
486
  return this.getProperty('LOCATION')?.value
146
487
  }
147
488
 
148
- setLocation(value: string) {
149
- this.setProperty("LOCATION", value)
489
+ setLocation(value: string): this {
490
+ return this.setProperty('LOCATION', value)
491
+ }
492
+
493
+ removeLocation() {
494
+ this.removePropertiesWithName('LOCATION')
150
495
  }
151
496
 
497
+ /**
498
+ * Get the start of the event.
499
+ * If set as a full day the time will be at the start of the day.
500
+ */
152
501
  start(): Date {
153
- return new Date(this.getProperty('DTSTART')!.value)
502
+ return parseDate(this.getProperty('DTSTART')!)
503
+ }
504
+
505
+ setStart(value: Date, fullDay: boolean = false): this {
506
+ if (fullDay) {
507
+ this.setProperty('DTSTART', toDateString(value))
508
+ this.setPropertyParams('DTSTART', ['VALUE=DATE'])
509
+ } else {
510
+ this.setProperty('DTSTART', toDateTimeString(value))
511
+ }
512
+ return this
154
513
  }
155
514
 
156
- setStart(value: Date) {
157
- this.setProperty("DTSTART", value.toISOString())
515
+ removeStart() {
516
+ this.removePropertiesWithName('DTSTART')
158
517
  }
159
518
 
519
+ /**
520
+ * Get the end of the event.
521
+ * If set as a full day the time will be at the end of the day.
522
+ */
160
523
  end(): Date {
161
- return new Date(this.getProperty('DTEND')!.value)
524
+ const endDate = parseDate(this.getProperty('DTEND')!)
525
+ if (this.getPropertyParams('DTEND')?.includes('VALUE=DATE')) {
526
+ const ONE_DAY = 24 * 60 * 60 * 1000
527
+ const endTime = new Date(endDate.getTime() + ONE_DAY)
528
+ return endTime
529
+ } else {
530
+ return endDate
531
+ }
532
+ }
533
+
534
+ setEnd(value: Date, fullDay: boolean = false): this {
535
+ if (fullDay) {
536
+ this.setProperty('DTEND', toDateString(value))
537
+ this.setPropertyParams('DTEND', ['VALUE=DATE'])
538
+ } else {
539
+ this.setProperty('DTEND', toDateTimeString(value))
540
+ }
541
+ return this
542
+ }
543
+
544
+ removeEnd() {
545
+ this.removePropertiesWithName('DTEND')
546
+ }
547
+
548
+ created(): Date | undefined {
549
+ const property = this.getProperty('CREATED')
550
+ if (!property) return
551
+ return parseDate(property)
162
552
  }
163
-
164
- setEnd(value: Date) {
165
- this.setProperty("DTEND", value.toISOString())
553
+
554
+ setCreated(value: Date): this {
555
+ return this.setProperty('CREATED', toDateTimeString(value))
556
+ }
557
+
558
+ removeCreated() {
559
+ this.removePropertiesWithName('CREATED')
560
+ }
561
+
562
+ geo(): [number, number] | undefined {
563
+ const text = this.getProperty('GEO')?.value
564
+ if (!text) return
565
+ const pattern = /^[+-]?\d+(\.\d+)?,[+-]?\d+(\.\d+)?$/
566
+ if (!pattern.test(text)) throw new Error(`Failed to parse GEO property: ${text}`)
567
+ const [longitude, latitude] = text.split(',')
568
+ return [parseFloat(longitude), parseFloat(latitude)]
569
+ }
570
+
571
+ setGeo(latitude: number, longitude: number): this {
572
+ const text = `${latitude},${longitude}`
573
+ return this
574
+ }
575
+
576
+ removeGeo() {
577
+ this.removePropertiesWithName('GEO')
166
578
  }
167
579
  }
package/src/parse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import readline from 'readline'
2
2
  import { Readable } from 'stream'
3
3
  import { Calendar, CalendarEvent, Component } from '.'
4
+ import { Property } from './types'
4
5
 
5
6
  export class DeserializationError extends Error {
6
7
  name = 'DeserializationError'
@@ -162,4 +163,27 @@ export async function parseEvent(text: string): Promise<CalendarEvent> {
162
163
  if (component.name !== "VEVENT")
163
164
  throw new DeserializationError("Not an event")
164
165
  return new CalendarEvent(component)
166
+ }
167
+
168
+ export function parseDate(dateProperty: Property): Date {
169
+ const value = dateProperty.value.trim()
170
+ if (dateProperty.params.includes('VALUE=DATE')) {
171
+ // Parse date only
172
+ return new Date(`${value.substring(0, 4)}-${value.substring(4, 6)}-${value.substring(6, 8)}`)
173
+ } else {
174
+ // Parse date and time
175
+ return new Date(`${value.substring(0, 4)}-${value.substring(4, 6)}-${value.substring(6, 8)} ${value.substring(9, 11)}:${value.substring(11, 13)}:${value.substring(13, 15)}`)
176
+ }
177
+ }
178
+
179
+ export function toTimeString(date: Date): string {
180
+ return `${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}${date.getSeconds().toString().padStart(2, '0')}`
181
+ }
182
+
183
+ export function toDateString(date: Date): string {
184
+ return `${date.getFullYear().toString().padStart(4, '0')}${date.getMonth().toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}`
185
+ }
186
+
187
+ export function toDateTimeString(date: Date): string {
188
+ return `${toDateString(date)}T${toTimeString(date)}`
165
189
  }
package/src/types.ts ADDED
@@ -0,0 +1,5 @@
1
+ export interface Property {
2
+ name: string
3
+ params: string[]
4
+ value: string
5
+ }