style: 移除 emoji, 注释改为中文 (保留专业名词)
- 移除所有 Vue 组件和 README 中的 emoji (按钮文字、标题、功能列表) - 将 src 下所有英文注释改为中文, 保留专业名词 (RRULE, EXDATE, ICS 等) - 涉及文件: date.js, event-factory.js, ical-io.js, organize.js, recur.js, split.js, weekday.js, useCalendar.js, EventDetail.vue, App.vue, DropZone.vue, EventList.vue, HeaderBar.vue, README.md
This commit is contained in:
14
README.md
14
README.md
@@ -4,13 +4,13 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- 📥 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
||||
- 🔁 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
|
||||
- ✏️ 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
||||
- ✂️ 拆分事件:把一个重复事件按日期拆成前后两段
|
||||
- ➕ 增删事件:新增单次/重复事件,删除任意事件
|
||||
- ↶ 撤销重做:快照式 undo/redo,最多 50 步
|
||||
- ⬇️ 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
||||
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
||||
- 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
|
||||
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
||||
- 拆分事件:把一个重复事件按日期拆成前后两段
|
||||
- 增删事件:新增单次/重复事件,删除任意事件
|
||||
- 撤销重做:快照式 undo/redo,最多 50 步
|
||||
- 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const { isLoaded } = useCalendar()
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<h1>📅 ICS 日程整理器</h1>
|
||||
<h1>ICS 日程整理器</h1>
|
||||
<HeaderBar />
|
||||
</header>
|
||||
<main class="app-main">
|
||||
|
||||
@@ -39,7 +39,7 @@ function onPick(e) {
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="dropzone-content">
|
||||
<div class="icon">📅</div>
|
||||
<div class="icon">日历</div>
|
||||
<p class="hint">拖入 .ics 文件,或</p>
|
||||
<label class="pick-btn">
|
||||
点击选择文件
|
||||
|
||||
@@ -7,7 +7,7 @@ import OccurrenceGrid from './OccurrenceGrid.vue'
|
||||
|
||||
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
|
||||
|
||||
// Local form state, synced when selection changes
|
||||
// 本地表单状态, 选择切换时同步
|
||||
const form = ref({})
|
||||
const showSplit = ref(false)
|
||||
const splitDate = ref('')
|
||||
@@ -94,7 +94,7 @@ function onToggleOcc(date) {
|
||||
<template>
|
||||
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div>
|
||||
<div v-else class="detail">
|
||||
<!-- Basic info -->
|
||||
<!-- 基本信息 -->
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="form-group">
|
||||
<label>标题 (SUMMARY)</label>
|
||||
@@ -119,7 +119,7 @@ function onToggleOcc(date) {
|
||||
<textarea v-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- RRULE -->
|
||||
<!-- 重复规则 (RRULE) -->
|
||||
<div class="section-title">重复规则 (RRULE)</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
@@ -156,7 +156,7 @@ function onToggleOcc(date) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Occurrence grid -->
|
||||
<!-- occurrence / 排除日期网格 -->
|
||||
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div>
|
||||
<OccurrenceGrid
|
||||
v-if="form.is_recurring && selectedEvent.rrule"
|
||||
@@ -164,7 +164,7 @@ function onToggleOcc(date) {
|
||||
@toggle="onToggleOcc"
|
||||
/>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" @click="applyChanges">应用更改</button>
|
||||
<button
|
||||
@@ -172,12 +172,12 @@ function onToggleOcc(date) {
|
||||
class="btn btn-secondary"
|
||||
@click="showSplit = !showSplit"
|
||||
>
|
||||
✂ 拆分此日程
|
||||
拆分此日程
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="onDelete">删除此日程</button>
|
||||
</div>
|
||||
|
||||
<!-- Split panel -->
|
||||
<!-- 拆分面板 -->
|
||||
<div v-if="showSplit && selectedEvent.rrule" class="split-panel">
|
||||
<p>从指定日期起,把此重复事件拆成前后两段。</p>
|
||||
<input type="date" v-model="splitDate" />
|
||||
|
||||
@@ -15,7 +15,7 @@ function dowFor(ev) {
|
||||
<div class="stat">
|
||||
共 {{ stats.total }} 个日程({{ stats.recurring }} 重复 / {{ stats.single }} 单次)
|
||||
</div>
|
||||
<button class="add-btn" @click="addEvent">➕ 新增日程</button>
|
||||
<button class="add-btn" @click="addEvent">新增日程</button>
|
||||
<div class="list">
|
||||
<div
|
||||
v-for="ev in state.events"
|
||||
|
||||
@@ -41,13 +41,13 @@ function onDownload() {
|
||||
<template>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||||
🔁 整理去重
|
||||
整理去重
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo">↶ 撤销</button>
|
||||
<button class="btn btn-secondary" @click="redo" :disabled="!canRedo">↷ 重做</button>
|
||||
<button class="btn btn-secondary" @click="onImport">📥 导入文件</button>
|
||||
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo">撤销</button>
|
||||
<button class="btn btn-secondary" @click="redo" :disabled="!canRedo">重做</button>
|
||||
<button class="btn btn-secondary" @click="onImport">导入文件</button>
|
||||
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||||
⬇ 下载 ICS
|
||||
下载 ICS
|
||||
</button>
|
||||
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改">●</span>
|
||||
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { splitRecurringEvent } from '../lib/split.js'
|
||||
|
||||
const MAX_HISTORY = 50
|
||||
|
||||
// Singleton reactive state
|
||||
// 单例响应式状态
|
||||
const state = reactive({
|
||||
meta: null,
|
||||
events: [],
|
||||
@@ -18,7 +18,7 @@ const state = reactive({
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Snapshot / undo
|
||||
// 快照 / 撤销
|
||||
// ------------------------------------------------------------------ //
|
||||
function snapshot() {
|
||||
return {
|
||||
@@ -41,7 +41,7 @@ function restore(snap) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Actions
|
||||
// 操作
|
||||
// ------------------------------------------------------------------ //
|
||||
function loadFile(file) {
|
||||
return file.text().then((text) => {
|
||||
@@ -143,7 +143,7 @@ function markSaved() {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Computed
|
||||
// 计算属性
|
||||
// ------------------------------------------------------------------ //
|
||||
const selectedEvent = computed(() =>
|
||||
state.events.find((e) => e.uid === state.selectedUid) || null,
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
/**
|
||||
* Centralized date-string utilities.
|
||||
* 集中式的日期字符串工具集。
|
||||
*
|
||||
* All date values in the app use the 'YYYY-MM-DD' (wall-clock) format.
|
||||
* These helpers are the single source of truth for formatting and
|
||||
* arithmetic on that format, replacing ad-hoc inline code that was
|
||||
* duplicated across organize.js, recur.js, useCalendar.js, and ical-io.js.
|
||||
* 应用内所有日期值统一使用 'YYYY-MM-DD' (本地墙钟时间) 格式。
|
||||
* 本模块是格式化与日期运算的唯一来源, 替代此前散落在 organize.js、
|
||||
* recur.js、useCalendar.js、ical-io.js 中的重复实现。
|
||||
*/
|
||||
|
||||
/** Pad a number to 2 digits with leading zero. */
|
||||
/** 将数字补零为 2 位字符串。 */
|
||||
export function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** Convert a JS Date to 'YYYY-MM-DD' (local wall-clock). */
|
||||
/** 将 JS Date 转换为 'YYYY-MM-DD' (本地墙钟时间)。 */
|
||||
export function toISODate(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
||||
}
|
||||
|
||||
/** Parse 'YYYY-MM-DD' into { year, month, day } (month is 1-based). */
|
||||
/** 将 'YYYY-MM-DD' 解析为 { year, month, day } (month 为 1 基)。 */
|
||||
export function parseISODate(s) {
|
||||
const [y, m, d] = s.split('-').map(Number)
|
||||
return { year: y, month: m, day: d }
|
||||
}
|
||||
|
||||
/** Build a local JS Date at midnight from 'YYYY-MM-DD'. */
|
||||
/** 由 'YYYY-MM-DD' 构造本地午夜 JS Date。 */
|
||||
export function toDate(isoDate) {
|
||||
return new Date(isoDate + 'T00:00:00')
|
||||
}
|
||||
|
||||
/** Add `days` days to an ISO date string, return new ISO string. */
|
||||
/** 在 ISO 日期字符串上增加 `days` 天, 返回新的 ISO 字符串。 */
|
||||
export function addDays(isoDate, days) {
|
||||
const d = toDate(isoDate)
|
||||
d.setDate(d.getDate() + days)
|
||||
return toISODate(d)
|
||||
}
|
||||
|
||||
/** Whole-day difference between two ISO date strings (b - a). */
|
||||
/** 两个 ISO 日期字符串之间的整天差 (b - a)。 */
|
||||
export function daysBetween(isoA, isoB) {
|
||||
return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000))
|
||||
}
|
||||
|
||||
/** Today's date as 'YYYY-MM-DD' (local). */
|
||||
/** 今天的日期, 格式为 'YYYY-MM-DD' (本地)。 */
|
||||
export function todayISO() {
|
||||
return toISODate(new Date())
|
||||
}
|
||||
|
||||
/**
|
||||
* Current UTC timestamp in compact RFC 5545 form (e.g. '20260711T085008Z').
|
||||
* Used for DTSTAMP on new events.
|
||||
* 当前 UTC 时间戳, 紧凑 RFC 5545 格式 (如 '20260711T085008Z')。
|
||||
* 用于新事件的 DTSTAMP。
|
||||
*/
|
||||
export function nowCompactTimestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
|
||||
}
|
||||
|
||||
/** Format a JS Date's time as 'HH:MM'. */
|
||||
/** 将 JS Date 的时间格式化为 'HH:MM'。 */
|
||||
export function toHHMM(date) {
|
||||
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* Event model factory and helpers.
|
||||
* 事件模型工厂与辅助函数。
|
||||
*
|
||||
* The EventModel is the plain-JS shape used throughout the app (see
|
||||
* docs/superpowers/specs/... §4.2). This module centralizes creation
|
||||
* and cloning so that useCalendar.js stays focused on state management.
|
||||
* EventModel 是应用内通用的纯 JS 数据结构 (见 docs/superpowers/specs/...
|
||||
* §4.2)。本模块集中管理事件的创建与克隆, 使 useCalendar.js 专注于状态管理。
|
||||
*/
|
||||
import { todayISO, nowCompactTimestamp } from './date.js'
|
||||
import { genUUID } from './uuid.js'
|
||||
|
||||
/**
|
||||
* Create a new single-occurrence event with sensible defaults.
|
||||
* @param {object} [overrides] - partial fields to override
|
||||
* @returns {object} a fresh EventModel
|
||||
* 创建一个具有合理默认值的单次事件。
|
||||
* @param {object} [overrides] - 需覆盖的部分字段
|
||||
* @returns {object} 一个全新的 EventModel
|
||||
*/
|
||||
export function createEvent(overrides = {}) {
|
||||
return {
|
||||
@@ -31,12 +30,11 @@ export function createEvent(overrides = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone an event (or any plain-data object).
|
||||
* 深拷贝一个事件 (或任意纯数据对象)。
|
||||
*
|
||||
* Uses JSON round-trip rather than structuredClone because events may be
|
||||
* wrapped in Vue reactive proxies, which structuredClone cannot handle.
|
||||
* EventModel contains only plain data (strings, arrays, nested objects),
|
||||
* so JSON cloning is safe and lossless here.
|
||||
* 使用 JSON 往返而非 structuredClone, 因为事件可能被 Vue 响应式代理包裹,
|
||||
* structuredClone 无法处理。EventModel 仅含纯数据 (字符串、数组、嵌套对象),
|
||||
* 因此 JSON 拷贝在此是安全且无损的。
|
||||
*/
|
||||
export function cloneEvent(ev) {
|
||||
return JSON.parse(JSON.stringify(ev))
|
||||
|
||||
@@ -2,7 +2,7 @@ import ICAL from 'ical.js'
|
||||
import { pad2, parseISODate } from './date.js'
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Timezone registration (ical.js does not bundle IANA zones)
|
||||
// 时区注册 (ical.js 不内置 IANA 时区)
|
||||
// ------------------------------------------------------------------ //
|
||||
function registerTimezones(vcalendar) {
|
||||
for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) {
|
||||
@@ -14,8 +14,8 @@ function registerTimezones(vcalendar) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// ICAL.Time <-> plain string helpers
|
||||
// (ICAL.Time exposes .year/.month/.day/.hour/.minute directly)
|
||||
// ICAL.Time <-> 普通字符串辅助函数
|
||||
// (ICAL.Time 直接暴露 .year/.month/.day/.hour/.minute)
|
||||
// ------------------------------------------------------------------ //
|
||||
function icalTimeToISODate(t) {
|
||||
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
|
||||
@@ -33,14 +33,14 @@ function getTzid(component, propName) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Parse a single VEVENT component -> plain EventModel
|
||||
// 解析单个 VEVENT 组件 -> 普通 EventModel
|
||||
// ------------------------------------------------------------------ //
|
||||
function parseEvent(veventComp) {
|
||||
const event = new ICAL.Event(veventComp)
|
||||
const start = event.startDate
|
||||
const end = event.endDate
|
||||
|
||||
// Determine tzid
|
||||
// 确定 tzid
|
||||
const tzid = getTzid(veventComp, 'dtstart')
|
||||
|
||||
// RRULE
|
||||
@@ -61,7 +61,7 @@ function parseEvent(veventComp) {
|
||||
}
|
||||
}
|
||||
|
||||
// EXDATEs
|
||||
// EXDATE
|
||||
const exdates = []
|
||||
for (const prop of veventComp.getAllProperties('exdate')) {
|
||||
for (const t of prop.getValues()) {
|
||||
@@ -69,9 +69,9 @@ function parseEvent(veventComp) {
|
||||
}
|
||||
}
|
||||
|
||||
// _raw: capture DTSTAMP and any other unmapped properties
|
||||
// Use toICALString() to preserve the compact RFC5545 form ("20260711T085008Z")
|
||||
// rather than toString()'s ISO 8601 form ("2026-07-11T08:50:08Z").
|
||||
// _raw: 保存 DTSTAMP 及其他未映射的属性
|
||||
// 使用 toICALString() 保留紧凑 RFC5545 形式 ("20260711T085008Z"),
|
||||
// 而非 toString() 的 ISO 8601 形式 ("2026-07-11T08:50:08Z")。
|
||||
const _raw = {}
|
||||
const dtstamp = veventComp.getFirstPropertyValue('dtstamp')
|
||||
if (dtstamp) _raw.dtstamp = dtstamp.toICALString()
|
||||
@@ -92,16 +92,16 @@ function parseEvent(veventComp) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Parse full ICS text -> { meta, events }
|
||||
// 解析完整 ICS 文本 -> { meta, events }
|
||||
// ------------------------------------------------------------------ //
|
||||
export function parseICS(text) {
|
||||
const jcal = ICAL.parse(text)
|
||||
const vcalendar = new ICAL.Component(jcal)
|
||||
|
||||
// Register timezones from VTIMEZONEs before constructing zoned Times
|
||||
// 在构造带时区的 Time 之前, 先从 VTIMEZONE 注册时区
|
||||
registerTimezones(vcalendar)
|
||||
|
||||
// Meta
|
||||
// 日历元信息
|
||||
const meta = {
|
||||
prodid: vcalendar.getFirstPropertyValue('prodid') || '',
|
||||
version: vcalendar.getFirstPropertyValue('version') || '2.0',
|
||||
@@ -109,19 +109,19 @@ export function parseICS(text) {
|
||||
method: vcalendar.getFirstPropertyValue('method') || null,
|
||||
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null,
|
||||
xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null,
|
||||
// Store raw jCal arrays (not ICAL.Component instances) so the meta stays
|
||||
// plain-serializable for Vue reactivity and JSON snapshot cloning.
|
||||
// 存储原始 jCal 数组 (而非 ICAL.Component 实例), 使 meta 保持纯可序列化,
|
||||
// 便于 Vue 响应式与 JSON 快照克隆。
|
||||
vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal),
|
||||
}
|
||||
|
||||
// Events
|
||||
// 事件
|
||||
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
|
||||
|
||||
return { meta, events }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Helpers for serialization
|
||||
// 序列化辅助函数
|
||||
// ------------------------------------------------------------------ //
|
||||
function parseHHMM(s) {
|
||||
const [h, m] = s.split(':').map(Number)
|
||||
@@ -143,10 +143,10 @@ function makeTime(dateStr, timeStr, tzid) {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse a compact RFC5545 date-time string (e.g. "20260711T085008Z" or
|
||||
// "20260711T085008") back into an ICAL.Time. ical.js's public Time.fromString
|
||||
// rejects the compact form, so we parse it manually and hand the Time object
|
||||
// to updatePropertyWithValue (which writes it back in compact form).
|
||||
// 解析紧凑 RFC5545 日期时间字符串 (如 "20260711T085008Z" 或
|
||||
// "20260711T085008") 为 ICAL.Time。ical.js 的 Time.fromString 不接受紧凑
|
||||
// 格式, 因此手动解析后构造 Time 对象传给 updatePropertyWithValue
|
||||
// (该方法会以紧凑格式写回)。
|
||||
function parseCompactDateTime(s) {
|
||||
const isUtc = s.endsWith('Z')
|
||||
const core = isUtc ? s.slice(0, -1) : s
|
||||
@@ -168,7 +168,7 @@ function parseCompactDateTime(s) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Serialize { meta, events } -> ICS text
|
||||
// 序列化 { meta, events } -> ICS 文本
|
||||
// ------------------------------------------------------------------ //
|
||||
export function serializeICS({ meta, events }) {
|
||||
const vcalendar = new ICAL.Component('vcalendar')
|
||||
@@ -180,8 +180,8 @@ export function serializeICS({ meta, events }) {
|
||||
if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname)
|
||||
if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
|
||||
|
||||
// Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS),
|
||||
// so wrap each directly in a new Component (avoids moving the original).
|
||||
// 重新添加 VTIMEZONE。meta.vtimezones 存储的是原始 jCal 数组 (见 parseICS),
|
||||
// 因此直接用新 Component 包裹 (避免移动原始组件)。
|
||||
for (const vtzJCal of meta.vtimezones || []) {
|
||||
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
|
||||
}
|
||||
@@ -204,10 +204,9 @@ export function serializeICS({ meta, events }) {
|
||||
const deProp = vevent.addPropertyWithValue('dtend', dtend)
|
||||
if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid)
|
||||
|
||||
// DTSTAMP - _raw.dtstamp is stored in compact RFC5545 form
|
||||
// ("20260711T085008Z"); pass an ICAL.Time object so ical.js decorates it
|
||||
// correctly and writes it back in the same compact form. Passing the raw
|
||||
// string fails (ical.js 2.x rejects compact form in fromDateTimeString).
|
||||
// DTSTAMP - _raw.dtstamp 以紧凑 RFC5545 格式存储 ("20260711T085008Z");
|
||||
// 传入 ICAL.Time 对象使 ical.js 正确装饰并以相同紧凑格式写回。
|
||||
// 直接传原始字符串会失败 (ical.js 2.x 的 fromDateTimeString 不接受紧凑格式)。
|
||||
const dtstampRaw = ev._raw?.dtstamp
|
||||
const dtstampTime = dtstampRaw
|
||||
? parseCompactDateTime(dtstampRaw)
|
||||
@@ -220,7 +219,7 @@ export function serializeICS({ meta, events }) {
|
||||
const recurData = { freq: r.freq, interval: r.interval || 1 }
|
||||
if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday
|
||||
if (r.untilDate) {
|
||||
// UNTIL as UTC (RFC 5545): build zoned time at 23:59 then convert
|
||||
// UNTIL 使用 UTC (RFC 5545): 先构造 23:59 的带时区时间再转换
|
||||
const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid)
|
||||
recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone)
|
||||
}
|
||||
@@ -228,7 +227,7 @@ export function serializeICS({ meta, events }) {
|
||||
vevent.addPropertyWithValue('rrule', recur)
|
||||
}
|
||||
|
||||
// EXDATEs (in DTSTART's timezone)
|
||||
// EXDATE (使用 DTSTART 的时区)
|
||||
for (const exDate of ev.exdates || []) {
|
||||
const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid)
|
||||
const exProp = vevent.addPropertyWithValue('exdate', exTime)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* GCD-based recurrence detection - port of ical_organizer.py.
|
||||
* Collapses a flat list of per-occurrence events into RRULE + EXDATE.
|
||||
* 基于 GCD 的重复检测 - 移植自 ical_organizer.py。
|
||||
* 将扁平的单次事件列表合并为 RRULE + EXDATE。
|
||||
*/
|
||||
import { bydayFromDate } from './weekday.js'
|
||||
import { toDate, toISODate, addDays, daysBetween } from './date.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 [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
||||
@@ -22,7 +22,7 @@ export function seriesKey(ev) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Interval detection
|
||||
// 间隔检测
|
||||
// ------------------------------------------------------------------ //
|
||||
function gcd(a, b) {
|
||||
while (b) { [a, b] = [b, a % b] }
|
||||
@@ -30,8 +30,8 @@ function gcd(a, b) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the recurrence interval in days, or null if irregular.
|
||||
* Computes GCD of all consecutive date gaps.
|
||||
* 返回重复间隔 (天数), 不规则时返回 null。
|
||||
* 计算所有相邻日期间隔的 GCD。
|
||||
*/
|
||||
export function detectInterval(dateStrings) {
|
||||
if (dateStrings.length < 2) return null
|
||||
@@ -48,13 +48,13 @@ export function detectInterval(dateStrings) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Series fitting
|
||||
// 系列拟合
|
||||
// ------------------------------------------------------------------ //
|
||||
const byDate = (a, b) => (a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0)
|
||||
|
||||
/**
|
||||
* Build the expected occurrence grid from first to last at the given interval.
|
||||
* Returns { expected: string[], missing: string[] } relative to present dates.
|
||||
* 从首日期到末日期按给定间隔构建期望的 occurrence 网格。
|
||||
* 返回 { expected, missing, offGrid } (相对于实际存在的日期)。
|
||||
*/
|
||||
function buildGrid(firstDate, lastDate, interval, present) {
|
||||
const expected = []
|
||||
@@ -70,8 +70,8 @@ function buildGrid(firstDate, lastDate, interval, present) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to collapse events into one recurring event.
|
||||
* Returns { base, rrule, exdates } or null to decline.
|
||||
* 尝试将事件合并为一个重复事件。
|
||||
* 返回 { base, rrule, exdates }, 无法合并时返回 null。
|
||||
*/
|
||||
export function fitSeries(evs) {
|
||||
if (evs.length < 2) return null
|
||||
@@ -88,10 +88,10 @@ export function fitSeries(evs) {
|
||||
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
|
||||
if (offGrid.length > 0) return null
|
||||
|
||||
// "Mostly regular": at least 2 occurrences and occurrences >= skips
|
||||
// "基本规律": 至少 2 个 occurrence, 且 occurrence 数 >= 跳过数
|
||||
if (evs.length < 2 || missing.length >= evs.length) return null
|
||||
|
||||
// Build rrule
|
||||
// 构建 rrule
|
||||
const firstDate = toDate(first.dtstartDate)
|
||||
let freq, step, byday
|
||||
if (interval % 7 === 0) {
|
||||
@@ -109,14 +109,14 @@ export function fitSeries(evs) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Organize
|
||||
// 整理
|
||||
// ------------------------------------------------------------------ //
|
||||
/**
|
||||
* Collapse flat events into recurring series.
|
||||
* Returns { events, stats: { series, flat } }.
|
||||
* 将扁平事件合并为重复系列。
|
||||
* 返回 { 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)
|
||||
@@ -133,7 +133,7 @@ export function organize(events) {
|
||||
|
||||
const fit = fitSeries(evs)
|
||||
if (fit) {
|
||||
// Collapsed event: preserve base's UID (not reassign)
|
||||
// 合并后的事件: 保留 base 的 UID (不重新分配)
|
||||
result.push({
|
||||
...fit.base,
|
||||
rrule: fit.rrule,
|
||||
@@ -141,7 +141,7 @@ export function organize(events) {
|
||||
})
|
||||
nSeries++
|
||||
} else {
|
||||
// Keep flat
|
||||
// 保留为独立事件
|
||||
for (const ev of evs) {
|
||||
result.push(ev)
|
||||
nFlat++
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Recurrence grid expansion - port of ical_editor.py's occurrence generation.
|
||||
* occurrences are computed on-demand from exdates, never stored on the model.
|
||||
* 重复网格展开 - 移植自 ical_editor.py 的 occurrence 生成逻辑。
|
||||
* occurrence 按需从 exdates 计算, 不存储在模型上。
|
||||
*/
|
||||
import { toISODate, toDate, addDays } from './date.js'
|
||||
|
||||
/** Interval in days for a recurring event. */
|
||||
/** 重复事件的间隔天数。 */
|
||||
export function intervalDays(ev) {
|
||||
if (!ev.rrule) return 0
|
||||
const { freq, interval } = ev.rrule
|
||||
@@ -15,11 +15,11 @@ export function intervalDays(ev) {
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
/**
|
||||
* Expand a recurring event into an occurrence grid.
|
||||
* Returns [{ date: 'YYYY-MM-DD', skipped: boolean }].
|
||||
* 将重复事件展开为 occurrence 网格。
|
||||
* 返回 [{ date: 'YYYY-MM-DD', skipped: boolean }]。
|
||||
*/
|
||||
export function expandOccurrences(ev) {
|
||||
// Non-recurring: single occurrence
|
||||
// 非重复事件: 单个 occurrence
|
||||
if (!ev.rrule) {
|
||||
return [{ date: ev.dtstartDate, skipped: false }]
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* Split a recurring event into two at a given date.
|
||||
* 将一个重复事件在指定日期拆分为两段。
|
||||
*
|
||||
* Extracted from useCalendar.js as a pure function so the store stays
|
||||
* focused on state management. The grid-snapping logic reuses
|
||||
* intervalDays from recur.js (single source of truth for step size).
|
||||
* 从 useCalendar.js 提取为纯函数, 使 store 专注于状态管理。
|
||||
* 网格对齐逻辑复用 recur.js 的 intervalDays (步长的唯一来源)。
|
||||
*
|
||||
* @param {object} ev - the event to split (must have a non-null rrule)
|
||||
* @param {string} splitDate - 'YYYY-MM-DD', must be within the event's range
|
||||
* @returns {[object, object]|null} [partA, partB] or null if the date is
|
||||
* out of range or the event is not recurring.
|
||||
* @param {object} ev - 待拆分的事件 (必须有非空 rrule)
|
||||
* @param {string} splitDate - 'YYYY-MM-DD', 必须在事件范围内
|
||||
* @returns {[object, object]|null} [partA, partB], 日期超出范围或
|
||||
* 事件非重复时返回 null。
|
||||
*/
|
||||
import { intervalDays } from './recur.js'
|
||||
import { cloneEvent } from './event-factory.js'
|
||||
import { genUUID } from './uuid.js'
|
||||
import { toDate, toISODate, addDays, daysBetween } from './date.js'
|
||||
|
||||
/** Snap a date forward to the next grid point on or after it. */
|
||||
/** 将日期向前对齐到不早于它的网格点。 */
|
||||
function snapToGrid(startDate, targetDate, stepDays, untilDate) {
|
||||
const start = toDate(startDate)
|
||||
let snap = toDate(targetDate)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// JS getDay() index: 0=Sunday .. 6=Saturday
|
||||
// JS getDay() 索引: 0=周日 .. 6=周六
|
||||
export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
// BYDAY codes indexed by JS getDay(): DOW_EN[getDay()] => 'SU'..'SA'
|
||||
// BYDAY 编码, 按 JS getDay() 索引: DOW_EN[getDay()] => 'SU'..'SA'
|
||||
export const DOW_EN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
|
||||
|
||||
export const BYDAY_TO_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }
|
||||
|
||||
/** Return the BYDAY code ('SU'..'SA') for a JS Date. */
|
||||
/** 返回 JS Date 对应的 BYDAY 编码 ('SU'..'SA')。 */
|
||||
export function bydayFromDate(date) {
|
||||
return DOW_EN[date.getDay()]
|
||||
}
|
||||
|
||||
/** Return the Chinese weekday label for a JS Date. */
|
||||
/** 返回 JS Date 对应的中文星期标签。 */
|
||||
export function dowLabel(date) {
|
||||
return DOW[date.getDay()]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user