首次提交:SyncCalendar 项目

This commit is contained in:
2026-09-13 15:50:37 +08:00
commit bbb3ec5334
53 changed files with 7123 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
// entry/src/main/ets/common/RruleUtil.ets
// RRULE 展开:支持 DAILY / WEEKLY / MONTHLY / YEARLY
// 支持 INTERVAL、COUNT、UNTIL、BYDAY(周重复,如 MO,WE,FR)、BYMONTHDAY(月重复)
export const DAY_MS: number = 86400000;
export class RruleUtil {
/**
* 展开重复规则,返回窗口内的发生时刻(毫秒时间戳)。
* @param rrule 原始 RRULE 值(如 FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH
* @param dtstart 首次发生时间(毫秒)
* @param windowStart 窗口起点(毫秒)
* @param windowEnd 窗口终点(毫秒)
* @param exdates EXDATE 排除时刻列表(毫秒),按整天或精确值匹配
* @param maxCount 结果数量上限(防止超大结果)
*/
static expand(rrule: string, dtstart: number, windowStart: number, windowEnd: number,
exdates: number[], maxCount: number): number[] {
const result: number[] = [];
if (rrule.trim() === '') {
return result;
}
let freq: string = '';
let interval: number = 1;
let count: number = -1;
let until: number = 0;
let byday: string[] = [];
let bymonthday: number[] = [];
const parts: string[] = rrule.split(';');
for (const part of parts) {
const idx: number = part.indexOf('=');
if (idx <= 0) {
continue;
}
const key: string = part.substring(0, idx).trim().toUpperCase();
const value: string = part.substring(idx + 1).trim();
if (key === 'FREQ') {
freq = value.toUpperCase();
} else if (key === 'INTERVAL') {
interval = Math.max(1, Number(value));
} else if (key === 'COUNT') {
count = Number(value);
} else if (key === 'UNTIL') {
until = RruleUtil.parseUntil(value);
} else if (key === 'BYDAY') {
byday = value.split(',').map((s: string): string => s.trim().toUpperCase());
} else if (key === 'BYMONTHDAY') {
bymonthday = value.split(',').map((s: string): number => Number(s.trim()));
}
}
if (freq === '' || Number.isNaN(interval) || interval < 1) {
return result;
}
const base = new Date(dtstart);
const baseZero = new Date(dtstart);
baseZero.setHours(0, 0, 0, 0);
const baseZeroMs: number = baseZero.getTime();
const timeOfDay: number = dtstart - baseZeroMs;
let generated: number = 0;
// 接受一个发生:处理 COUNT/UNTIL/EXDATE/窗口过滤
const accept = (occ: number): boolean => {
if (count > 0 && generated >= count) {
return false;
}
if (until > 0 && occ > until) {
return false;
}
generated++;
if (occ >= dtstart && occ <= windowEnd + DAY_MS - 1000 && occ >= windowStart - 40 * DAY_MS) {
const occDay: number = RruleUtil.dayFloor(occ);
let excluded: boolean = false;
for (const ex of exdates) {
if (ex === occ || RruleUtil.dayFloor(ex) === occDay) {
excluded = true;
break;
}
}
if (!excluded && result.length < maxCount) {
result.push(occ);
}
}
return true;
};
if (freq === 'DAILY') {
let occ: number = dtstart;
let guard: number = 0;
while (occ <= windowEnd + DAY_MS && guard < 5000) {
guard++;
if (!accept(occ)) {
break;
}
const d = new Date(occ);
d.setDate(d.getDate() + interval);
occ = d.getTime();
}
} else if (freq === 'WEEKLY') {
const targets: number[] = [];
if (byday.length > 0) {
for (const code of byday) {
const wd: number = RruleUtil.weekdayFromCode(code);
if (wd >= 0) {
targets.push(wd);
}
}
}
if (targets.length === 0) {
targets.push(base.getDay());
}
const anchorWeek: number = RruleUtil.weekStartMs(dtstart);
let dayMs: number = baseZeroMs;
let guard: number = 0;
while (dayMs <= windowEnd + DAY_MS && guard < 5000) {
guard++;
const wd: number = new Date(dayMs).getDay();
if (targets.includes(wd)) {
const weekIndex: number =
Math.round((RruleUtil.weekStartMs(dayMs) - anchorWeek) / (7 * DAY_MS));
if (weekIndex % interval === 0) {
if (!accept(dayMs + timeOfDay)) {
break;
}
}
}
dayMs += DAY_MS;
}
} else if (freq === 'MONTHLY') {
let y: number = base.getFullYear();
let m: number = base.getMonth();
let guard: number = 0;
while (guard < 1500) {
guard++;
const monthFirst: number = new Date(y, m, 1).getTime();
if (monthFirst > windowEnd + DAY_MS) {
break;
}
const days: number[] = (bymonthday.length > 0 && !bymonthday.some((v: number): boolean => Number.isNaN(v)))
? bymonthday : [base.getDate()];
let stop: boolean = false;
for (const md of days) {
const dim: number = new Date(y, m + 1, 0).getDate();
if (md >= 1 && md <= dim) {
const occ: number = new Date(y, m, md,
base.getHours(), base.getMinutes(), base.getSeconds()).getTime();
if (!accept(occ)) {
stop = true;
break;
}
}
}
if (stop) {
break;
}
m += interval;
while (m > 11) {
m -= 12;
y++;
}
}
} else if (freq === 'YEARLY') {
let y: number = base.getFullYear();
let guard: number = 0;
while (guard < 300) {
guard++;
const occ: number = new Date(y, base.getMonth(), base.getDate(),
base.getHours(), base.getMinutes(), base.getSeconds()).getTime();
if (occ > windowEnd + DAY_MS) {
break;
}
if (!accept(occ)) {
break;
}
y += interval;
}
}
return result;
}
/** 当天 0 点 */
static dayFloor(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** 所在周的周一 0 点 */
static weekStartMs(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
const offset: number = (d.getDay() + 6) % 7;
return d.getTime() - offset * DAY_MS;
}
/** BYDAY 代码 → getDay() 星期值(0=周日);2TU 等带序号的取后两位 */
static weekdayFromCode(code: string): number {
if (code.startsWith('MO')) {
return 1;
}
if (code.startsWith('TU')) {
return 2;
}
if (code.startsWith('WE')) {
return 3;
}
if (code.startsWith('TH')) {
return 4;
}
if (code.startsWith('FR')) {
return 5;
}
if (code.startsWith('SA')) {
return 6;
}
if (code.startsWith('SU')) {
return 0;
}
return -1;
}
/** UNTIL 值:20260913T235959Z / 20260913T235959 / 20260913 */
private static parseUntil(value: string): number {
const v: string = value.trim().toUpperCase();
if (/^\d{8}$/.test(v)) {
return new Date(Number(v.substring(0, 4)), Number(v.substring(4, 6)) - 1,
Number(v.substring(6, 8)), 23, 59, 59).getTime();
}
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/.exec(v);
if (m === null) {
return 0;
}
if (v.endsWith('Z')) {
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
Number(m[4]), Number(m[5]), Number(m[6]));
}
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
Number(m[4]), Number(m[5]), Number(m[6])).getTime();
}
}