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` 文件,浏览器内直接解析
|
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
||||||
- 🔁 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
|
- 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
|
||||||
- ✏️ 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
||||||
- ✂️ 拆分事件:把一个重复事件按日期拆成前后两段
|
- 拆分事件:把一个重复事件按日期拆成前后两段
|
||||||
- ➕ 增删事件:新增单次/重复事件,删除任意事件
|
- 增删事件:新增单次/重复事件,删除任意事件
|
||||||
- ↶ 撤销重做:快照式 undo/redo,最多 50 步
|
- 撤销重做:快照式 undo/redo,最多 50 步
|
||||||
- ⬇️ 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
- 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
点击选择文件
|
点击选择文件
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.Time。ical.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),
|
// 重新添加 VTIMEZONE。meta.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)
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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 }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user