feat: port GCD dedup algorithm from ical_organizer.py
This commit is contained in:
154
src/lib/organize.js
Normal file
154
src/lib/organize.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* GCD-based recurrence detection - port of ical_organizer.py.
|
||||||
|
* Collapses a flat list of per-occurrence events into RRULE + EXDATE.
|
||||||
|
*/
|
||||||
|
import { bydayFromDate } from './weekday.js'
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Grouping
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
/**
|
||||||
|
* Identity of a recurring series.
|
||||||
|
* Two events belong together iff they share summary, location, start
|
||||||
|
* wall-clock time, duration (minutes), and weekday.
|
||||||
|
*/
|
||||||
|
export function seriesKey(ev) {
|
||||||
|
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
|
||||||
|
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
||||||
|
const [eh, em] = ev.dtendTime.split(':').map(Number)
|
||||||
|
const durationMin = (eh * 60 + em) - (sh * 60 + sm)
|
||||||
|
return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Interval detection
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
function gcd(a, b) {
|
||||||
|
while (b) { [a, b] = [b, a % b] }
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the recurrence interval in days, or null if irregular.
|
||||||
|
* Computes GCD of all consecutive date gaps.
|
||||||
|
*/
|
||||||
|
export function detectInterval(dateStrings) {
|
||||||
|
if (dateStrings.length < 2) return null
|
||||||
|
const dates = [...dateStrings].sort()
|
||||||
|
const gaps = []
|
||||||
|
for (let i = 0; i < dates.length - 1; i++) {
|
||||||
|
const a = new Date(dates[i] + 'T00:00:00')
|
||||||
|
const b = new Date(dates[i + 1] + 'T00:00:00')
|
||||||
|
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
|
||||||
|
}
|
||||||
|
let g = gaps[0]
|
||||||
|
for (let i = 1; i < gaps.length; i++) {
|
||||||
|
g = gcd(g, gaps[i])
|
||||||
|
}
|
||||||
|
return g > 0 ? g : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Series fitting
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
/**
|
||||||
|
* Try to collapse events into one recurring event.
|
||||||
|
* Returns { base, rrule, exdates } or null to decline.
|
||||||
|
*/
|
||||||
|
export function fitSeries(evs) {
|
||||||
|
if (evs.length < 2) return null
|
||||||
|
|
||||||
|
const sorted = [...evs].sort((a, b) =>
|
||||||
|
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||||||
|
)
|
||||||
|
const starts = sorted.map(e => e.dtstartDate)
|
||||||
|
const interval = detectInterval(starts)
|
||||||
|
if (!interval) return null
|
||||||
|
|
||||||
|
const first = sorted[0]
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
const present = new Set(starts)
|
||||||
|
|
||||||
|
// Build expected grid first..last step interval days
|
||||||
|
const expected = []
|
||||||
|
const cur = new Date(first.dtstartDate + 'T00:00:00')
|
||||||
|
const lastDate = new Date(last.dtstartDate + 'T00:00:00')
|
||||||
|
while (cur <= lastDate) {
|
||||||
|
const y = cur.getFullYear()
|
||||||
|
const m = String(cur.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(cur.getDate()).padStart(2, '0')
|
||||||
|
expected.push(`${y}-${m}-${d}`)
|
||||||
|
cur.setDate(cur.getDate() + interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSet = new Set(expected)
|
||||||
|
const missing = expected.filter(d => !present.has(d))
|
||||||
|
const offGrid = starts.filter(d => !expectedSet.has(d))
|
||||||
|
if (offGrid.length > 0) return null
|
||||||
|
|
||||||
|
// "Mostly regular": at least 2 occurrences and occurrences >= skips
|
||||||
|
if (evs.length < 2 || missing.length >= evs.length) return null
|
||||||
|
|
||||||
|
// Build rrule
|
||||||
|
const firstDate = new Date(first.dtstartDate + 'T00:00:00')
|
||||||
|
let freq, step, byday
|
||||||
|
if (interval % 7 === 0) {
|
||||||
|
freq = 'WEEKLY'
|
||||||
|
step = interval / 7
|
||||||
|
byday = bydayFromDate(firstDate)
|
||||||
|
} else {
|
||||||
|
freq = 'DAILY'
|
||||||
|
step = interval
|
||||||
|
byday = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rrule = { freq, interval: step, byday, untilDate: last.dtstartDate }
|
||||||
|
return { base: first, rrule, exdates: missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Organize
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
/**
|
||||||
|
* Collapse flat events into recurring series.
|
||||||
|
* Returns { events, stats: { series, flat } }.
|
||||||
|
*/
|
||||||
|
export function organize(events) {
|
||||||
|
// Group by series key
|
||||||
|
const groups = new Map()
|
||||||
|
for (const ev of events) {
|
||||||
|
const key = seriesKey(ev)
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key).push(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
let nSeries = 0
|
||||||
|
let nFlat = 0
|
||||||
|
|
||||||
|
for (const evs of groups.values()) {
|
||||||
|
// Sort by date
|
||||||
|
evs.sort((a, b) =>
|
||||||
|
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fit = fitSeries(evs)
|
||||||
|
if (fit) {
|
||||||
|
// Collapsed event: preserve base's UID (not reassign)
|
||||||
|
result.push({
|
||||||
|
...fit.base,
|
||||||
|
rrule: fit.rrule,
|
||||||
|
exdates: fit.exdates,
|
||||||
|
})
|
||||||
|
nSeries++
|
||||||
|
} else {
|
||||||
|
// Keep flat
|
||||||
|
for (const ev of evs) {
|
||||||
|
result.push(ev)
|
||||||
|
nFlat++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { events: result, stats: { series: nSeries, flat: nFlat } }
|
||||||
|
}
|
||||||
100
test/organize.test.js
Normal file
100
test/organize.test.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { parseICS } from '../src/lib/ical-io.js'
|
||||||
|
import { organize, seriesKey, detectInterval, fitSeries } from '../src/lib/organize.js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
||||||
|
const { events } = parseICS(GOLDEN)
|
||||||
|
|
||||||
|
describe('seriesKey', () => {
|
||||||
|
it('groups events with same summary/location/time/duration/weekday', () => {
|
||||||
|
const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00
|
||||||
|
const e2 = events[1] // same series, next week
|
||||||
|
expect(seriesKey(e1)).toBe(seriesKey(e2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates events with different summary', () => {
|
||||||
|
const e1 = events[0] // ACC INFO SYS
|
||||||
|
// Find a BUS LAW event
|
||||||
|
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
|
||||||
|
expect(seriesKey(e1)).not.toBe(seriesKey(eBus))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detectInterval', () => {
|
||||||
|
it('returns 7 for weekly dates', () => {
|
||||||
|
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-17']
|
||||||
|
expect(detectInterval(dates)).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for single date', () => {
|
||||||
|
expect(detectInterval(['2026-07-27'])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 7 for dates with a skip (gaps 7,7,14,7)', () => {
|
||||||
|
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
|
||||||
|
expect(detectInterval(dates)).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fitSeries', () => {
|
||||||
|
it('fits ACC INFO SYS series (12 events, 1 skip)', () => {
|
||||||
|
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||||||
|
const result = fitSeries(accEvents)
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result.rrule.freq).toBe('WEEKLY')
|
||||||
|
expect(result.rrule.interval).toBe(1)
|
||||||
|
expect(result.rrule.byday).toBe('MO')
|
||||||
|
expect(result.exdates).toContain('2026-09-21')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => {
|
||||||
|
const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop')
|
||||||
|
expect(wsEvents).toHaveLength(3)
|
||||||
|
expect(fitSeries(wsEvents)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('organize (golden test against org.ics)', () => {
|
||||||
|
const { events: organized, stats } = organize(events)
|
||||||
|
|
||||||
|
it('collapses 61 events into 8', () => {
|
||||||
|
expect(organized).toHaveLength(8)
|
||||||
|
expect(stats.series).toBe(5)
|
||||||
|
expect(stats.flat).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces 5 recurring events with RRULE', () => {
|
||||||
|
const recurring = organized.filter(e => e.rrule !== null)
|
||||||
|
expect(recurring).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces 3 flat BUS LAW Workshop events', () => {
|
||||||
|
const flatWs = organized.filter(
|
||||||
|
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null,
|
||||||
|
)
|
||||||
|
expect(flatWs).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACC INFO SYS series has 1 exdate (Sep 21)', () => {
|
||||||
|
const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||||||
|
expect(acc.rrule).not.toBeNull()
|
||||||
|
expect(acc.exdates).toEqual(['2026-09-21'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('BUS LAW Tutorial series has 2 exdates', () => {
|
||||||
|
const bl = organized.find(e => e.summary === 'BUS LAW, Tutorial')
|
||||||
|
expect(bl.rrule).not.toBeNull()
|
||||||
|
expect(bl.exdates).toHaveLength(2)
|
||||||
|
expect(bl.exdates).toContain('2026-08-31')
|
||||||
|
expect(bl.exdates).toContain('2026-09-21')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves original UID of base event', () => {
|
||||||
|
const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||||||
|
expect(acc.uid).toBe('uid0') // first event in series
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user