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:
zikai
2026-07-22 02:55:45 +00:00
parent e9fc10c89d
commit 84dbaf9cd2
14 changed files with 118 additions and 123 deletions

View File

@@ -4,13 +4,13 @@
## 功能 ## 功能
- 📥 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析 - 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
- 🔁 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE - 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
- ✏️ 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期 - 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
- ✂️ 拆分事件:把一个重复事件按日期拆成前后两段 - 拆分事件:把一个重复事件按日期拆成前后两段
- 增删事件:新增单次/重复事件,删除任意事件 - 增删事件:新增单次/重复事件,删除任意事件
- 撤销重做:快照式 undo/redo最多 50 步 - 撤销重做:快照式 undo/redo最多 50 步
- ⬇️ 导出 ICS下载整理后的 `.ics`,可直接导入日历应用 - 导出 ICS下载整理后的 `.ics`,可直接导入日历应用
## 项目结构 ## 项目结构

View File

@@ -11,7 +11,7 @@ const { isLoaded } = useCalendar()
<template> <template>
<div class="app"> <div class="app">
<header class="app-header"> <header class="app-header">
<h1>📅 ICS 日程整理器</h1> <h1>ICS 日程整理器</h1>
<HeaderBar /> <HeaderBar />
</header> </header>
<main class="app-main"> <main class="app-main">

View File

@@ -39,7 +39,7 @@ function onPick(e) {
@drop.prevent="onDrop" @drop.prevent="onDrop"
> >
<div class="dropzone-content"> <div class="dropzone-content">
<div class="icon">📅</div> <div class="icon">日历</div>
<p class="hint">拖入 .ics 文件</p> <p class="hint">拖入 .ics 文件</p>
<label class="pick-btn"> <label class="pick-btn">
点击选择文件 点击选择文件

View File

@@ -7,7 +7,7 @@ import OccurrenceGrid from './OccurrenceGrid.vue'
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar() const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
// Local form state, synced when selection changes // 本地表单状态, 选择切换时同步
const form = ref({}) const form = ref({})
const showSplit = ref(false) const showSplit = ref(false)
const splitDate = ref('') const splitDate = ref('')
@@ -94,7 +94,7 @@ function onToggleOcc(date) {
<template> <template>
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div> <div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div>
<div v-else class="detail"> <div v-else class="detail">
<!-- Basic info --> <!-- 基本信息 -->
<div class="section-title">基本信息</div> <div class="section-title">基本信息</div>
<div class="form-group"> <div class="form-group">
<label>标题 (SUMMARY)</label> <label>标题 (SUMMARY)</label>
@@ -119,7 +119,7 @@ function onToggleOcc(date) {
<textarea v-model="form.description"></textarea> <textarea v-model="form.description"></textarea>
</div> </div>
<!-- RRULE --> <!-- 重复规则 (RRULE) -->
<div class="section-title">重复规则 (RRULE)</div> <div class="section-title">重复规则 (RRULE)</div>
<div class="form-group"> <div class="form-group">
<label> <label>
@@ -156,7 +156,7 @@ function onToggleOcc(date) {
</div> </div>
</div> </div>
<!-- Occurrence grid --> <!-- occurrence / 排除日期网格 -->
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div> <div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div>
<OccurrenceGrid <OccurrenceGrid
v-if="form.is_recurring && selectedEvent.rrule" v-if="form.is_recurring && selectedEvent.rrule"
@@ -164,7 +164,7 @@ function onToggleOcc(date) {
@toggle="onToggleOcc" @toggle="onToggleOcc"
/> />
<!-- Toolbar --> <!-- 工具栏 -->
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" @click="applyChanges">应用更改</button> <button class="btn btn-primary" @click="applyChanges">应用更改</button>
<button <button
@@ -172,12 +172,12 @@ function onToggleOcc(date) {
class="btn btn-secondary" class="btn btn-secondary"
@click="showSplit = !showSplit" @click="showSplit = !showSplit"
> >
拆分此日程 拆分此日程
</button> </button>
<button class="btn btn-danger" @click="onDelete">删除此日程</button> <button class="btn btn-danger" @click="onDelete">删除此日程</button>
</div> </div>
<!-- Split panel --> <!-- 拆分面板 -->
<div v-if="showSplit && selectedEvent.rrule" class="split-panel"> <div v-if="showSplit && selectedEvent.rrule" class="split-panel">
<p>从指定日期起把此重复事件拆成前后两段</p> <p>从指定日期起把此重复事件拆成前后两段</p>
<input type="date" v-model="splitDate" /> <input type="date" v-model="splitDate" />

View File

@@ -15,7 +15,7 @@ function dowFor(ev) {
<div class="stat"> <div class="stat">
{{ stats.total }} 个日程{{ stats.recurring }} 重复 / {{ stats.single }} 单次 {{ stats.total }} 个日程{{ stats.recurring }} 重复 / {{ stats.single }} 单次
</div> </div>
<button class="add-btn" @click="addEvent"> 新增日程</button> <button class="add-btn" @click="addEvent">新增日程</button>
<div class="list"> <div class="list">
<div <div
v-for="ev in state.events" v-for="ev in state.events"

View File

@@ -41,13 +41,13 @@ function onDownload() {
<template> <template>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0"> <button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
🔁 整理去重 整理去重
</button> </button>
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo"> 撤销</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="redo" :disabled="!canRedo">重做</button>
<button class="btn btn-secondary" @click="onImport">📥 导入文件</button> <button class="btn btn-secondary" @click="onImport">导入文件</button>
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0"> <button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
下载 ICS 下载 ICS
</button> </button>
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改"></span> <span v-if="state.dirty" class="dirty-dot" title="有未保存更改"></span>
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden /> <input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />

View File

@@ -6,7 +6,7 @@ import { splitRecurringEvent } from '../lib/split.js'
const MAX_HISTORY = 50 const MAX_HISTORY = 50
// Singleton reactive state // 单例响应式状态
const state = reactive({ const state = reactive({
meta: null, meta: null,
events: [], events: [],
@@ -18,7 +18,7 @@ const state = reactive({
}) })
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Snapshot / undo // 快照 / 撤销
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function snapshot() { function snapshot() {
return { return {
@@ -41,7 +41,7 @@ function restore(snap) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Actions // 操作
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function loadFile(file) { function loadFile(file) {
return file.text().then((text) => { return file.text().then((text) => {
@@ -143,7 +143,7 @@ function markSaved() {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Computed // 计算属性
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
const selectedEvent = computed(() => const selectedEvent = computed(() =>
state.events.find((e) => e.uid === state.selectedUid) || null, state.events.find((e) => e.uid === state.selectedUid) || null,

View File

@@ -1,59 +1,58 @@
/** /**
* Centralized date-string utilities. * 集中式的日期字符串工具集。
* *
* All date values in the app use the 'YYYY-MM-DD' (wall-clock) format. * 应用内所有日期值统一使用 'YYYY-MM-DD' (本地墙钟时间) 格式。
* These helpers are the single source of truth for formatting and * 本模块是格式化与日期运算的唯一来源, 替代此前散落在 organize.js、
* arithmetic on that format, replacing ad-hoc inline code that was * recur.js、useCalendar.js、ical-io.js 中的重复实现。
* duplicated across organize.js, recur.js, useCalendar.js, and ical-io.js.
*/ */
/** Pad a number to 2 digits with leading zero. */ /** 将数字补零为 2 位字符串。 */
export function pad2(n) { export function pad2(n) {
return String(n).padStart(2, '0') 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) { export function toISODate(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` 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) { export function parseISODate(s) {
const [y, m, d] = s.split('-').map(Number) const [y, m, d] = s.split('-').map(Number)
return { year: y, month: m, day: d } 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) { export function toDate(isoDate) {
return new Date(isoDate + 'T00:00:00') 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) { export function addDays(isoDate, days) {
const d = toDate(isoDate) const d = toDate(isoDate)
d.setDate(d.getDate() + days) d.setDate(d.getDate() + days)
return toISODate(d) return toISODate(d)
} }
/** Whole-day difference between two ISO date strings (b - a). */ /** 两个 ISO 日期字符串之间的整天差 (b - a) */
export function daysBetween(isoA, isoB) { export function daysBetween(isoA, isoB) {
return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000)) return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000))
} }
/** Today's date as 'YYYY-MM-DD' (local). */ /** 今天的日期, 格式为 'YYYY-MM-DD' (本地)。 */
export function todayISO() { export function todayISO() {
return toISODate(new Date()) return toISODate(new Date())
} }
/** /**
* Current UTC timestamp in compact RFC 5545 form (e.g. '20260711T085008Z'). * 当前 UTC 时间戳, 紧凑 RFC 5545 格式 (如 '20260711T085008Z')
* Used for DTSTAMP on new events. * 用于新事件的 DTSTAMP。
*/ */
export function nowCompactTimestamp() { export function nowCompactTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' 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) { export function toHHMM(date) {
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}` return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
} }

View File

@@ -1,17 +1,16 @@
/** /**
* Event model factory and helpers. * 事件模型工厂与辅助函数。
* *
* The EventModel is the plain-JS shape used throughout the app (see * EventModel 是应用内通用的纯 JS 数据结构 (见 docs/superpowers/specs/...
* docs/superpowers/specs/... §4.2). This module centralizes creation * §4.2)。本模块集中管理事件的创建与克隆, 使 useCalendar.js 专注于状态管理。
* and cloning so that useCalendar.js stays focused on state management.
*/ */
import { todayISO, nowCompactTimestamp } from './date.js' import { todayISO, nowCompactTimestamp } from './date.js'
import { genUUID } from './uuid.js' import { genUUID } from './uuid.js'
/** /**
* Create a new single-occurrence event with sensible defaults. * 创建一个具有合理默认值的单次事件。
* @param {object} [overrides] - partial fields to override * @param {object} [overrides] - 需覆盖的部分字段
* @returns {object} a fresh EventModel * @returns {object} 一个全新的 EventModel
*/ */
export function createEvent(overrides = {}) { export function createEvent(overrides = {}) {
return { 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 * 使用 JSON 往返而非 structuredClone, 因为事件可能被 Vue 响应式代理包裹,
* wrapped in Vue reactive proxies, which structuredClone cannot handle. * structuredClone 无法处理。EventModel 仅含纯数据 (字符串、数组、嵌套对象),
* EventModel contains only plain data (strings, arrays, nested objects), * 因此 JSON 拷贝在此是安全且无损的。
* so JSON cloning is safe and lossless here.
*/ */
export function cloneEvent(ev) { export function cloneEvent(ev) {
return JSON.parse(JSON.stringify(ev)) return JSON.parse(JSON.stringify(ev))

View File

@@ -2,7 +2,7 @@ import ICAL from 'ical.js'
import { pad2, parseISODate } from './date.js' import { pad2, parseISODate } from './date.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Timezone registration (ical.js does not bundle IANA zones) // 时区注册 (ical.js 不内置 IANA 时区)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function registerTimezones(vcalendar) { function registerTimezones(vcalendar) {
for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) { for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) {
@@ -14,8 +14,8 @@ function registerTimezones(vcalendar) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// ICAL.Time <-> plain string helpers // ICAL.Time <-> 普通字符串辅助函数
// (ICAL.Time exposes .year/.month/.day/.hour/.minute directly) // (ICAL.Time 直接暴露 .year/.month/.day/.hour/.minute)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function icalTimeToISODate(t) { function icalTimeToISODate(t) {
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}` 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) { function parseEvent(veventComp) {
const event = new ICAL.Event(veventComp) const event = new ICAL.Event(veventComp)
const start = event.startDate const start = event.startDate
const end = event.endDate const end = event.endDate
// Determine tzid // 确定 tzid
const tzid = getTzid(veventComp, 'dtstart') const tzid = getTzid(veventComp, 'dtstart')
// RRULE // RRULE
@@ -61,7 +61,7 @@ function parseEvent(veventComp) {
} }
} }
// EXDATEs // EXDATE
const exdates = [] const exdates = []
for (const prop of veventComp.getAllProperties('exdate')) { for (const prop of veventComp.getAllProperties('exdate')) {
for (const t of prop.getValues()) { for (const t of prop.getValues()) {
@@ -69,9 +69,9 @@ function parseEvent(veventComp) {
} }
} }
// _raw: capture DTSTAMP and any other unmapped properties // _raw: 保存 DTSTAMP 及其他未映射的属性
// Use toICALString() to preserve the compact RFC5545 form ("20260711T085008Z") // 使用 toICALString() 保留紧凑 RFC5545 形式 ("20260711T085008Z"),
// rather than toString()'s ISO 8601 form ("2026-07-11T08:50:08Z"). // 而非 toString() ISO 8601 形式 ("2026-07-11T08:50:08Z")
const _raw = {} const _raw = {}
const dtstamp = veventComp.getFirstPropertyValue('dtstamp') const dtstamp = veventComp.getFirstPropertyValue('dtstamp')
if (dtstamp) _raw.dtstamp = dtstamp.toICALString() 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) { export function parseICS(text) {
const jcal = ICAL.parse(text) const jcal = ICAL.parse(text)
const vcalendar = new ICAL.Component(jcal) const vcalendar = new ICAL.Component(jcal)
// Register timezones from VTIMEZONEs before constructing zoned Times // 在构造带时区的 Time 之前, 先从 VTIMEZONE 注册时区
registerTimezones(vcalendar) registerTimezones(vcalendar)
// Meta // 日历元信息
const meta = { const meta = {
prodid: vcalendar.getFirstPropertyValue('prodid') || '', prodid: vcalendar.getFirstPropertyValue('prodid') || '',
version: vcalendar.getFirstPropertyValue('version') || '2.0', version: vcalendar.getFirstPropertyValue('version') || '2.0',
@@ -109,19 +109,19 @@ export function parseICS(text) {
method: vcalendar.getFirstPropertyValue('method') || null, method: vcalendar.getFirstPropertyValue('method') || null,
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null, xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null,
xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null, xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null,
// Store raw jCal arrays (not ICAL.Component instances) so the meta stays // 存储原始 jCal 数组 (而非 ICAL.Component 实例), 使 meta 保持纯可序列化,
// plain-serializable for Vue reactivity and JSON snapshot cloning. // 便于 Vue 响应式与 JSON 快照克隆。
vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal), vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal),
} }
// Events // 事件
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent) const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
return { meta, events } return { meta, events }
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Helpers for serialization // 序列化辅助函数
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function parseHHMM(s) { function parseHHMM(s) {
const [h, m] = s.split(':').map(Number) 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 // 解析紧凑 RFC5545 日期时间字符串 (如 "20260711T085008Z"
// "20260711T085008") back into an ICAL.Time. ical.js's public Time.fromString // "20260711T085008") ICAL.Timeical.js Time.fromString 不接受紧凑
// rejects the compact form, so we parse it manually and hand the Time object // 格式, 因此手动解析后构造 Time 对象传给 updatePropertyWithValue
// to updatePropertyWithValue (which writes it back in compact form). // (该方法会以紧凑格式写回)。
function parseCompactDateTime(s) { function parseCompactDateTime(s) {
const isUtc = s.endsWith('Z') const isUtc = s.endsWith('Z')
const core = isUtc ? s.slice(0, -1) : s 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 }) { export function serializeICS({ meta, events }) {
const vcalendar = new ICAL.Component('vcalendar') 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.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname)
if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone) if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
// Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS), // 重新添加 VTIMEZONEmeta.vtimezones 存储的是原始 jCal 数组 (见 parseICS),
// so wrap each directly in a new Component (avoids moving the original). // 因此直接用新 Component 包裹 (避免移动原始组件)。
for (const vtzJCal of meta.vtimezones || []) { for (const vtzJCal of meta.vtimezones || []) {
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal)) vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
} }
@@ -204,10 +204,9 @@ export function serializeICS({ meta, events }) {
const deProp = vevent.addPropertyWithValue('dtend', dtend) const deProp = vevent.addPropertyWithValue('dtend', dtend)
if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid) if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid)
// DTSTAMP - _raw.dtstamp is stored in compact RFC5545 form // DTSTAMP - _raw.dtstamp 以紧凑 RFC5545 格式存储 ("20260711T085008Z");
// ("20260711T085008Z"); pass an ICAL.Time object so ical.js decorates it // 传入 ICAL.Time 对象使 ical.js 正确装饰并以相同紧凑格式写回。
// correctly and writes it back in the same compact form. Passing the raw // 直接传原始字符串会失败 (ical.js 2.x 的 fromDateTimeString 不接受紧凑格式)。
// string fails (ical.js 2.x rejects compact form in fromDateTimeString).
const dtstampRaw = ev._raw?.dtstamp const dtstampRaw = ev._raw?.dtstamp
const dtstampTime = dtstampRaw const dtstampTime = dtstampRaw
? parseCompactDateTime(dtstampRaw) ? parseCompactDateTime(dtstampRaw)
@@ -220,7 +219,7 @@ export function serializeICS({ meta, events }) {
const recurData = { freq: r.freq, interval: r.interval || 1 } const recurData = { freq: r.freq, interval: r.interval || 1 }
if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday
if (r.untilDate) { 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) const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid)
recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone) recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone)
} }
@@ -228,7 +227,7 @@ export function serializeICS({ meta, events }) {
vevent.addPropertyWithValue('rrule', recur) vevent.addPropertyWithValue('rrule', recur)
} }
// EXDATEs (in DTSTART's timezone) // EXDATE (使用 DTSTART 的时区)
for (const exDate of ev.exdates || []) { for (const exDate of ev.exdates || []) {
const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid) const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid)
const exProp = vevent.addPropertyWithValue('exdate', exTime) const exProp = vevent.addPropertyWithValue('exdate', exTime)

View File

@@ -1,17 +1,17 @@
/** /**
* GCD-based recurrence detection - port of ical_organizer.py. * 基于 GCD 的重复检测 - 移植自 ical_organizer.py
* Collapses a flat list of per-occurrence events into RRULE + EXDATE. * 将扁平的单次事件列表合并为 RRULE + EXDATE
*/ */
import { bydayFromDate } from './weekday.js' import { bydayFromDate } from './weekday.js'
import { toDate, toISODate, addDays, daysBetween } from './date.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) { export function seriesKey(ev) {
const [sh, sm] = ev.dtstartTime.split(':').map(Number) const [sh, sm] = ev.dtstartTime.split(':').map(Number)
@@ -22,7 +22,7 @@ export function seriesKey(ev) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Interval detection // 间隔检测
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function gcd(a, b) { function gcd(a, b) {
while (b) { [a, b] = [b, 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. * 返回重复间隔 (天数), 不规则时返回 null。
* Computes GCD of all consecutive date gaps. * 计算所有相邻日期间隔的 GCD。
*/ */
export function detectInterval(dateStrings) { export function detectInterval(dateStrings) {
if (dateStrings.length < 2) return null 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) 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. * 从首日期到末日期按给定间隔构建期望的 occurrence 网格。
* Returns { expected: string[], missing: string[] } relative to present dates. * 返回 { expected, missing, offGrid } (相对于实际存在的日期)。
*/ */
function buildGrid(firstDate, lastDate, interval, present) { function buildGrid(firstDate, lastDate, interval, present) {
const expected = [] 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) { export function fitSeries(evs) {
if (evs.length < 2) return null if (evs.length < 2) return null
@@ -88,10 +88,10 @@ export function fitSeries(evs) {
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present) const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
if (offGrid.length > 0) return null 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 if (evs.length < 2 || missing.length >= evs.length) return null
// Build rrule // 构建 rrule
const firstDate = toDate(first.dtstartDate) const firstDate = toDate(first.dtstartDate)
let freq, step, byday let freq, step, byday
if (interval % 7 === 0) { 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) { export function organize(events) {
// Group by series key // 按系列标识分组
const groups = new Map() const groups = new Map()
for (const ev of events) { for (const ev of events) {
const key = seriesKey(ev) const key = seriesKey(ev)
@@ -133,7 +133,7 @@ export function organize(events) {
const fit = fitSeries(evs) const fit = fitSeries(evs)
if (fit) { if (fit) {
// Collapsed event: preserve base's UID (not reassign) // 合并后的事件: 保留 base UID (不重新分配)
result.push({ result.push({
...fit.base, ...fit.base,
rrule: fit.rrule, rrule: fit.rrule,
@@ -141,7 +141,7 @@ export function organize(events) {
}) })
nSeries++ nSeries++
} else { } else {
// Keep flat // 保留为独立事件
for (const ev of evs) { for (const ev of evs) {
result.push(ev) result.push(ev)
nFlat++ nFlat++

View File

@@ -1,10 +1,10 @@
/** /**
* Recurrence grid expansion - port of ical_editor.py's occurrence generation. * 重复网格展开 - 移植自 ical_editor.py occurrence 生成逻辑。
* occurrences are computed on-demand from exdates, never stored on the model. * occurrence 按需从 exdates 计算, 不存储在模型上。
*/ */
import { toISODate, toDate, addDays } from './date.js' import { toISODate, toDate, addDays } from './date.js'
/** Interval in days for a recurring event. */ /** 重复事件的间隔天数。 */
export function intervalDays(ev) { export function intervalDays(ev) {
if (!ev.rrule) return 0 if (!ev.rrule) return 0
const { freq, interval } = ev.rrule const { freq, interval } = ev.rrule
@@ -15,11 +15,11 @@ export function intervalDays(ev) {
const MAX_OCCURRENCES = 500 const MAX_OCCURRENCES = 500
/** /**
* Expand a recurring event into an occurrence grid. * 将重复事件展开为 occurrence 网格。
* Returns [{ date: 'YYYY-MM-DD', skipped: boolean }]. * 返回 [{ date: 'YYYY-MM-DD', skipped: boolean }]
*/ */
export function expandOccurrences(ev) { export function expandOccurrences(ev) {
// Non-recurring: single occurrence // 非重复事件: 单个 occurrence
if (!ev.rrule) { if (!ev.rrule) {
return [{ date: ev.dtstartDate, skipped: false }] return [{ date: ev.dtstartDate, skipped: false }]
} }

View File

@@ -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 * useCalendar.js 提取为纯函数, 使 store 专注于状态管理。
* focused on state management. The grid-snapping logic reuses * 网格对齐逻辑复用 recur.js 的 intervalDays (步长的唯一来源)。
* intervalDays from recur.js (single source of truth for step size).
* *
* @param {object} ev - the event to split (must have a non-null rrule) * @param {object} ev - 待拆分的事件 (必须有非空 rrule)
* @param {string} splitDate - 'YYYY-MM-DD', must be within the event's range * @param {string} splitDate - 'YYYY-MM-DD', 必须在事件范围内
* @returns {[object, object]|null} [partA, partB] or null if the date is * @returns {[object, object]|null} [partA, partB], 日期超出范围或
* out of range or the event is not recurring. * 事件非重复时返回 null。
*/ */
import { intervalDays } from './recur.js' import { intervalDays } from './recur.js'
import { cloneEvent } from './event-factory.js' import { cloneEvent } from './event-factory.js'
import { genUUID } from './uuid.js' import { genUUID } from './uuid.js'
import { toDate, toISODate, addDays, daysBetween } from './date.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) { function snapToGrid(startDate, targetDate, stepDays, untilDate) {
const start = toDate(startDate) const start = toDate(startDate)
let snap = toDate(targetDate) let snap = toDate(targetDate)

View File

@@ -1,17 +1,17 @@
// JS getDay() index: 0=Sunday .. 6=Saturday // JS getDay() 索引: 0=周日 .. 6=周六
export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] 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 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 } 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) { export function bydayFromDate(date) {
return DOW_EN[date.getDay()] return DOW_EN[date.getDay()]
} }
/** Return the Chinese weekday label for a JS Date. */ /** 返回 JS Date 对应的中文星期标签。 */
export function dowLabel(date) { export function dowLabel(date) {
return DOW[date.getDay()] return DOW[date.getDay()]
} }