feat: port GCD dedup algorithm from ical_organizer.py

This commit is contained in:
timeTable2 dev
2026-07-13 18:36:29 +08:00
parent e187337127
commit dc70a04b54
2 changed files with 254 additions and 0 deletions

154
src/lib/organize.js Normal file
View 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 } }
}