Compare commits

..

10 Commits

Author SHA1 Message Date
timeTable2 dev
51461ff5f2 fix: use JSON clone instead of structuredClone for Vue reactive proxies
structuredClone cannot clone Vue reactive Proxy objects (DataCloneError).
EventModel contains only plain data, so JSON round-trip is safe and
lossless. Verified: organize, toggle, undo, download all work correctly.
2026-07-13 20:13:19 +08:00
timeTable2 dev
b352c4e116 refactor: high-cohesion low-coupling cleanup across store and components
ical-io.js:
- Import pad2/parseISODate from date.js (remove local duplicates)
- Rename ICAL.Time helpers to icalTimeTo* for clarity

useCalendar.js:
- Delegate addEvent to createEvent() factory
- Delegate splitEvent to splitRecurringEvent() pure function
- Use cloneEvent() instead of JSON.parse(JSON.stringify())
- Add selectEvent() action (components no longer mutate state.selectedUid directly)

Components:
- EventList: use selectEvent action + toDate() from date.js
- HeaderBar: destructure loadFile at top level (was re-calling useCalendar in handler)
- EventDetail: import DOW from weekday.js (was inlining the array)
- OccurrenceGrid: use toDate() from date.js
2026-07-13 20:06:00 +08:00
timeTable2 dev
02cfdda900 refactor: extract date.js, event-factory.js, split.js; use shared date helpers in recur/organize
- date.js: single source of truth for ISO date formatting/arithmetic
  (toISODate, parseISODate, addDays, daysBetween, todayISO, nowCompactTimestamp)
- event-factory.js: createEvent/cloneEvent (structuredClone with JSON fallback)
- split.js: splitRecurringEvent pure function using intervalDays from recur.js
- recur.js: replaced local toISO() and inline Date math with date.js helpers
- organize.js: replaced inline date formatting and gap math with date.js helpers,
  extracted buildGrid() helper and byDate comparator
2026-07-13 20:01:10 +08:00
timeTable2 dev
e4f549f848 docs: add README 2026-07-13 18:53:39 +08:00
timeTable2 dev
3af6b856e3 fix: store VTIMEZONEs as raw jCal arrays for Vue reactivity safety
ICAL.Component instances break when wrapped by Vue's reactive proxy
(the .jCal getter returns undefined through the proxy, causing
serializeICS to crash with 'Cannot read properties of undefined').
Storing vtz.jCal (plain arrays) instead makes meta fully serializable
for both reactivity and JSON snapshot cloning.
2026-07-13 18:53:01 +08:00
timeTable2 dev
15b6f30896 feat: add EventDetail with edit form, occurrence grid, split 2026-07-13 18:43:28 +08:00
timeTable2 dev
8a1d547001 feat: add OccurrenceGrid component 2026-07-13 18:41:51 +08:00
timeTable2 dev
f52f5315e6 feat: add EventList and HeaderBar with organize/undo/download 2026-07-13 18:40:44 +08:00
timeTable2 dev
906679fc11 feat: add DropZone and App layout skeleton 2026-07-13 18:39:15 +08:00
timeTable2 dev
361cc38384 feat: add central store with snapshot undo/redo 2026-07-13 18:37:58 +08:00
14 changed files with 1147 additions and 66 deletions

125
README.md Normal file
View File

@@ -0,0 +1,125 @@
# timeTable2 - 纯前端 ICS 日历整理器
浏览器内解析、去重、编辑 ICS 日历文件,全程无需 Python 或后端服务。拖入 `.ics` → 整理去重 → 编辑 → 下载。
## 功能
- 📥 **拖入即用**:拖拽或选择 `.ics` 文件,浏览器内直接解析
- 🔁 **智能去重**:自动检测重复模式,将 61 个扁平事件合并为 5 个 RRULE 重复事件 + 3 个独立事件
- ✏️ **可视化编辑**:编辑标题/地点/时间/重复规则,点击网格切换排除日期
- ✂️ **拆分事件**:把一个重复事件按日期拆成前后两段(如后半学期换教室)
- **增删事件**:新增单次/重复事件,删除任意事件
-**撤销重做**:快照式 undo/redo最多 50 步
-**导出 ICS**:编辑完成下载整理后的 `.ics`,可直接导入日历应用
## 快速开始
```bash
# 安装依赖(使用 npmmirror 镜像加速,已配在 .npmrc
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 运行单元测试
npm test
```
浏览器打开 `http://localhost:5173`,拖入 `.ics` 文件即可。
## 使用流程
1. **导入**:拖入 `.ics` 文件(或点击选择),左侧显示所有原始事件
2. **去重**:点击顶栏「🔁 整理去重」,自动合并重复事件为 RRULE + EXDATE
3. **编辑**:点击左侧事件,右侧展开编辑表单
- 修改标题/地点/描述/时间
- 调整重复规则(频率/间隔/星期/重复到)
- 点击 occurrence 网格切换排除日期(绿色=包含,红色删除线=排除)
4. **拆分**(可选):点击「✂ 拆分此日程」,选日期,把重复事件拆成前后两段
5. **新增/删除**:点「➕ 新增日程」或「删除此日程」
6. **撤销**:「↶ 撤销」/「↷ 重做」最多 50 步
7. **下载**:点「⬇ 下载 ICS」导出整理后的 `.ics`(有未保存更改时会提示确认)
## 项目结构
```
timeTable2/
├── index.html Vite 入口
├── package.json
├── .npmrc npmmirror 镜像加速
├── vite.config.js
├── vitest.config.js
├── src/
│ ├── main.js 应用挂载
│ ├── App.vue 布局装配
│ ├── composables/
│ │ └── useCalendar.js 中心 store + 快照 undo/redo
│ ├── lib/
│ │ ├── weekday.js 星期常量与工具(统一 getDay 索引)
│ │ ├── ical-io.js ICS 文本 <-> plain modelical.js 封装)
│ │ ├── recur.js RRULE 网格展开
│ │ └── organize.js GCD 去重算法(移植自 Python 版)
│ └── components/
│ ├── HeaderBar.vue 顶栏:整理/撤销/重做/导入/下载
│ ├── DropZone.vue 拖拽/选择文件
│ ├── EventList.vue 侧栏事件列表
│ ├── EventDetail.vue 编辑表单 + 拆分
│ └── OccurrenceGrid.vue 排除日期网格
└── test/ Vitest 单元测试34 个)
├── golden-org.ics 测试用例61 事件的真实课表)
├── weekday.test.js
├── ical-io.test.js
├── recur.test.js
└── organize.test.js
```
### 三个核心 lib 模块
| 文件 | 作用 |
|------|------|
| `ical-io.js` | ICS 文本解析为普通对象、序列化回 ICS 文本ical.js 封装) |
| `organize.js` | GCD 间隔检测 + 系列分组 + EXDATE 生成(移植自 `ical_organizer.py` |
| `recur.js` | 从 RRULE + EXDATE 按需展开 occurrence 网格 |
## 算法
去重算法从 `timeTable/ical_organizer.py` 1:1 移植:
1. 按 (标题 + 地点 + 开始时间 + 时长 + 星期) 将事件分组
2. 计算组内相邻日期间隔的 **GCD**,识别:
- GCD = 7 → `FREQ=WEEKLY`(每周,含 `BYDAY`
- GCD = 1 → `FREQ=DAILY`(每天)
- GCD = 14 → `FREQ=WEEKLY;INTERVAL=2`(每两周)
3. 构建期望网格,网格上缺失的日期写入 `EXDATE`(如假期)
4. 不符合规律的组(跳过次数 ≥ 事件数)保留为独立事件
5. 合并后保留首个事件的原始 UID避免重新导入冲突
**验证**:对 `org.ics`61 事件)整理后输出 8 个事件5 重复 + 3 独立),与 Python 版 `ical_organizer.py --verify` 结果一致。下载的 ICS 用 Python `recurring-ical-events` 展开61 个 occurrence 与原始文件完全匹配。
## 技术栈
| 项 | 选择 |
|----|------|
| 框架 | Vue 3.5SFC`<script setup>` |
| 构建 | Vite 6 |
| ICS 库 | ical.js 2.x仅用于文本↔对象转换 |
| 状态管理 | composable 单例 store无 Pinia |
| 测试 | Vitest |
| 包源 | npmmirror.com中国镜像加速 |
## 与 timeTable 的关系
`timeTable`Python 版)使用 Flask + icalendar 库,需创建 venv 并启动本地服务器。
`timeTable2` 是纯前端等价实现:零安装、双击即用、算法行为一致。
| 对比 | timeTable (Python) | timeTable2 (前端) |
|------|--------------------|--------------------|
| 运行方式 | venv + Flask 服务器 | 浏览器直接打开 |
| 依赖 | icalendar, flask, recurring-ical-events | ical.js |
| 整理器 | `ical_organizer.py` 命令行 | 网页内「整理去重」按钮 |
| 编辑器 | `ical_editor.py` + `editor.html` | Vue3 组件 |
| 验证 | `recurring_ical_events` 展开 | 网页内 occurrence 网格 |
| 导出 | 保存到磁盘 / 下载 | 下载 `.ics` |

View File

@@ -1,9 +1,54 @@
<script setup> <script setup>
import { useCalendar } from './composables/useCalendar.js'
import DropZone from './components/DropZone.vue'
import HeaderBar from './components/HeaderBar.vue'
import EventList from './components/EventList.vue'
import EventDetail from './components/EventDetail.vue'
const { isLoaded } = useCalendar()
</script> </script>
<template> <template>
<div style="font-family: sans-serif; padding: 40px;"> <div class="app">
<header class="app-header">
<h1>📅 ICS 日程整理器</h1> <h1>📅 ICS 日程整理器</h1>
<p>脚手架就绪</p> <HeaderBar />
</header>
<main class="app-main">
<DropZone v-if="!isLoaded" />
<template v-else>
<EventList />
<div class="detail-area">
<EventDetail />
</div>
</template>
</main>
</div> </div>
</template> </template>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
background: #f5f6f8;
color: #222;
line-height: 1.5;
}
.app { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
background: #2c3e50;
color: #fff;
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-header h1 { font-size: 18px; font-weight: 600; }
.app-main { flex: 1; display: flex; overflow: hidden; }
.detail-area { flex: 1; overflow-y: auto; }
.empty {
text-align: center;
padding: 60px 20px;
color: #95a5a6;
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
const { loadFile } = useCalendar()
const dragOver = ref(false)
const error = ref('')
function onDrop(e) {
dragOver.value = false
error.value = ''
const file = e.dataTransfer.files[0]
if (!file) return
if (!file.name.toLowerCase().endsWith('.ics')) {
error.value = '请选择 .ics 文件'
return
}
loadFile(file).catch((err) => {
error.value = '解析失败:' + (err.message || err)
})
}
function onPick(e) {
error.value = ''
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => {
error.value = '解析失败:' + (err.message || err)
})
}
</script>
<template>
<div
class="dropzone"
:class="{ active: dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<div class="dropzone-content">
<div class="icon">📅</div>
<p class="hint">拖入 .ics 文件</p>
<label class="pick-btn">
点击选择文件
<input type="file" accept=".ics" @change="onPick" hidden />
</label>
<p v-if="error" class="error">{{ error }}</p>
</div>
</div>
</template>
<style scoped>
.dropzone {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #bdc3c7;
border-radius: 12px;
margin: 40px;
transition: all 0.2s;
}
.dropzone.active {
border-color: #3498db;
background: #eaf4fc;
}
.dropzone-content {
text-align: center;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
.hint {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 16px;
}
.pick-btn {
display: inline-block;
padding: 10px 24px;
background: #3498db;
color: #fff;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.15s;
}
.pick-btn:hover {
background: #2980b9;
}
.error {
color: #e74c3c;
font-size: 13px;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup>
import { ref, watch } from 'vue'
import { useCalendar } from '../composables/useCalendar.js'
import { DOW, DOW_EN } from '../lib/weekday.js'
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('')
function syncForm(ev) {
if (!ev) { form.value = {}; return }
form.value = {
summary: ev.summary || '',
location: ev.location || '',
description: ev.description || '',
dtstart_time: ev.dtstartTime || '',
dtend_time: ev.dtendTime || '',
is_recurring: ev.rrule !== null,
freq: ev.rrule?.freq || 'WEEKLY',
interval: ev.rrule?.interval || 1,
byday: ev.rrule?.byday || 'MO',
until_date: ev.rrule?.untilDate || '',
}
}
watch(selectedEvent, syncForm, { immediate: true })
function applyChanges() {
const ev = selectedEvent.value
if (!ev) return
const patch = {
summary: form.value.summary,
location: form.value.location,
description: form.value.description,
dtstartTime: form.value.dtstart_time,
dtendTime: form.value.dtend_time,
}
if (form.value.is_recurring) {
patch.rrule = {
freq: form.value.freq,
interval: Number(form.value.interval) || 1,
byday: form.value.freq === 'WEEKLY' ? form.value.byday : null,
untilDate: form.value.until_date || ev.rrule?.untilDate || '',
}
} else {
patch.rrule = null
patch.exdates = []
}
updateEvent(ev.uid, patch)
}
function onDelete() {
const ev = selectedEvent.value
if (!ev) return
if (confirm(`确定删除"${ev.summary}"`)) {
deleteEvent(ev.uid)
}
}
function onSplit() {
const ev = selectedEvent.value
if (!ev || !ev.rrule) return
if (!splitDate.value) {
alert('请选择拆分日期')
return
}
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
alert('拆分日期必须在开始日期和结束日期之间')
return
}
splitEvent(ev.uid, splitDate.value)
showSplit.value = false
splitDate.value = ''
}
function onToggleOcc(date) {
const ev = selectedEvent.value
if (!ev) return
toggleOccurrence(ev.uid, date)
}
</script>
<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>
<input type="text" v-model="form.summary" />
</div>
<div class="form-row">
<div class="form-group">
<label>开始时间</label>
<input type="time" v-model="form.dtstart_time" />
</div>
<div class="form-group">
<label>结束时间</label>
<input type="time" v-model="form.dtend_time" />
</div>
</div>
<div class="form-group">
<label>地点 (LOCATION)</label>
<input type="text" v-model="form.location" />
</div>
<div class="form-group">
<label>描述 (DESCRIPTION)</label>
<textarea v-model="form.description"></textarea>
</div>
<!-- RRULE -->
<div class="section-title">重复规则 (RRULE)</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.is_recurring" /> 启用重复
</label>
</div>
<div v-if="form.is_recurring">
<div class="form-row">
<div class="form-group">
<label>频率</label>
<select v-model="form.freq">
<option value="WEEKLY">每周 (WEEKLY)</option>
<option value="DAILY">每天 (DAILY)</option>
</select>
</div>
<div class="form-group">
<label>间隔 (INTERVAL)</label>
<input type="number" v-model.number="form.interval" min="1" style="width: 80px" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>重复到 (UNTIL)</label>
<input type="date" v-model="form.until_date" />
</div>
<div class="form-group" v-if="form.freq === 'WEEKLY'">
<label>星期 (BYDAY)</label>
<select v-model="form.byday">
<option v-for="(d, i) in DOW_EN" :key="d" :value="d">
{{ DOW[i] }}
</option>
</select>
</div>
</div>
</div>
<!-- Occurrence grid -->
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div>
<OccurrenceGrid
v-if="form.is_recurring && selectedEvent.rrule"
:event="selectedEvent"
@toggle="onToggleOcc"
/>
<!-- Toolbar -->
<div class="toolbar">
<button class="btn btn-primary" @click="applyChanges">应用更改</button>
<button
v-if="selectedEvent.rrule"
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" />
<button class="btn btn-primary" @click="onSplit">确认拆分</button>
<button class="btn btn-secondary" @click="showSplit = false">取消</button>
</div>
</div>
</template>
<style scoped>
.detail { padding: 24px 32px; }
.empty {
text-align: center;
padding: 60px 20px;
color: #95a5a6;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #2c3e50;
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 2px solid #ecf0f1;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #34495e;
margin-bottom: 5px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d5dbdb;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
}
.form-group textarea { resize: vertical; min-height: 60px; }
.form-row { display: flex; gap: 16px; }
.form-row .form-group { flex: 1; }
.toolbar {
display: flex;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #ecf0f1;
}
.btn {
padding: 7px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
}
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
.btn-secondary:hover { background: #d5dbdb; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.split-panel {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.split-panel p { width: 100%; font-size: 13px; color: #7f8c8d; margin-bottom: 8px; }
.split-panel input { padding: 6px 10px; border: 1px solid #d5dbdb; border-radius: 6px; }
</style>

View File

@@ -0,0 +1,87 @@
<script setup>
import { useCalendar } from '../composables/useCalendar.js'
import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js'
const { state, stats, addEvent, selectEvent } = useCalendar()
function dowFor(ev) {
return DOW[toDate(ev.dtstartDate).getDay()]
}
</script>
<template>
<div class="event-list">
<div class="stat">
{{ stats.total }} 个日程{{ stats.recurring }} 重复 / {{ stats.single }} 单次
</div>
<button class="add-btn" @click="addEvent"> 新增日程</button>
<div class="list">
<div
v-for="ev in state.events"
:key="ev.uid"
class="event-item"
:class="{ active: ev.uid === state.selectedUid }"
@click="selectEvent(ev.uid)"
>
<div class="title">
{{ ev.summary || '(无标题)' }}
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
{{ ev.rrule ? '重复' : '单次' }}
</span>
</div>
<div class="meta">
{{ dowFor(ev) }} {{ ev.dtstartTime }}{{ ev.dtendTime }}
<span v-if="ev.location"> · {{ ev.location }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.event-list {
width: 340px;
background: #fff;
border-right: 1px solid #e0e3e6;
display: flex;
flex-direction: column;
overflow: hidden;
}
.stat { font-size: 12px; color: #95a5a6; padding: 12px 16px 8px; }
.add-btn {
margin: 0 16px 8px;
padding: 8px;
border: 1px dashed #3498db;
background: #eaf4fc;
color: #2980b9;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.add-btn:hover { background: #d4eaf9; }
.list { flex: 1; overflow-y: auto; }
.event-item {
padding: 12px 16px;
border-bottom: 1px solid #eef0f2;
cursor: pointer;
transition: background 0.12s;
}
.event-item:hover { background: #f8f9fa; }
.event-item.active {
background: #eaf4fc;
border-left: 3px solid #3498db;
}
.title { font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 3px; }
.meta { font-size: 12px; color: #7f8c8d; }
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
}
.badge-recur { background: #e8f5e9; color: #2e7d32; }
.badge-single { background: #fff3e0; color: #e65100; }
</style>

View File

@@ -0,0 +1,76 @@
<script setup>
import { useCalendar } from '../composables/useCalendar.js'
import { ref } from 'vue'
const { state, canUndo, canRedo, organize, undo, redo, serialize, markSaved, loadFile } = useCalendar()
const fileInput = ref(null)
function onOrganize() {
const stats = organize()
if (stats && stats.series === 0) {
alert('未发现可合并的重复模式')
}
}
function onImport() {
fileInput.value?.click()
}
function onFilePicked(e) {
const file = e.target.files[0]
if (!file) return
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err)))
e.target.value = ''
}
function onDownload() {
if (state.dirty && !confirm('有未保存的更改,仍要下载?')) return
const text = serialize()
const blob = new Blob([text], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const base = state.fileName ? state.fileName.replace(/\.ics$/i, '') : 'calendar'
a.href = url
a.download = base + '.edited.ics'
a.click()
URL.revokeObjectURL(url)
markSaved()
}
</script>
<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-success" @click="onDownload" :disabled="state.events.length === 0">
下载 ICS
</button>
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改"></span>
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
</div>
</template>
<style scoped>
.header-actions { display: flex; gap: 10px; align-items: center; }
.btn {
padding: 7px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #2980b9; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover:not(:disabled) { background: #229954; }
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
.dirty-dot { color: #e74c3c; font-size: 16px; }
</style>

View File

@@ -0,0 +1,75 @@
<script setup>
import { computed } from 'vue'
import { DOW } from '../lib/weekday.js'
import { toDate } from '../lib/date.js'
import { expandOccurrences } from '../lib/recur.js'
const props = defineProps({
event: { type: Object, required: true },
})
const emit = defineEmits(['toggle'])
const occurrences = computed(() => expandOccurrences(props.event))
function dowFor(dateStr) {
return DOW[toDate(dateStr).getDay()]
}
</script>
<template>
<div>
<p class="hint">
点击日期切换"排除/包含"状态排除的日期红色删除线会写入 EXDATE
</p>
<div class="occ-grid">
<div
v-for="occ in occurrences"
:key="occ.date"
class="occ"
:class="occ.skipped ? 'skipped' : 'active'"
@click="emit('toggle', occ.date)"
>
{{ occ.date }}<br />
<span class="dow">{{ dowFor(occ.date) }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.hint {
font-size: 12px;
color: #7f8c8d;
margin-bottom: 8px;
}
.occ-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding: 4px;
}
.occ {
padding: 8px 10px;
border: 1px solid #d5dbdb;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
text-align: center;
transition: all 0.12s;
}
.occ:hover { border-color: #3498db; }
.occ.skipped {
background: #fce4ec;
border-color: #e74c3c;
color: #c0392b;
text-decoration: line-through;
}
.occ.active {
background: #e8f5e9;
border-color: #27ae60;
color: #1e7d32;
}
.dow { font-size: 10px; color: #95a5a6; }
</style>

View File

@@ -0,0 +1,181 @@
import { reactive, computed } from 'vue'
import { parseICS, serializeICS } from '../lib/ical-io.js'
import { organize as organizeEvents } from '../lib/organize.js'
import { createEvent, cloneEvent } from '../lib/event-factory.js'
import { splitRecurringEvent } from '../lib/split.js'
const MAX_HISTORY = 50
// Singleton reactive state
const state = reactive({
meta: null,
events: [],
selectedUid: null,
history: [],
redoStack: [],
fileName: null,
dirty: false,
})
// ------------------------------------------------------------------ //
// Snapshot / undo
// ------------------------------------------------------------------ //
function snapshot() {
return {
meta: state.meta ? cloneEvent(state.meta) : null,
events: state.events.map(cloneEvent),
selectedUid: state.selectedUid,
}
}
function pushHistory() {
state.history.push(snapshot())
if (state.history.length > MAX_HISTORY) state.history.shift()
state.redoStack = []
}
function restore(snap) {
state.meta = snap.meta ? cloneEvent(snap.meta) : null
state.events = snap.events.map(cloneEvent)
state.selectedUid = snap.selectedUid
}
// ------------------------------------------------------------------ //
// Actions
// ------------------------------------------------------------------ //
function loadFile(file) {
return file.text().then((text) => {
const parsed = parseICS(text)
state.meta = parsed.meta
state.events = parsed.events
state.selectedUid = null
state.history = []
state.redoStack = []
state.fileName = file.name
state.dirty = false
})
}
function selectEvent(uid) {
state.selectedUid = uid
}
function organize() {
if (state.events.length === 0) return
pushHistory()
const result = organizeEvents(state.events)
state.events = result.events
state.dirty = true
return result.stats
}
function updateEvent(uid, patch) {
pushHistory()
const ev = state.events.find((e) => e.uid === uid)
if (ev) {
Object.assign(ev, patch)
state.dirty = true
}
}
function deleteEvent(uid) {
pushHistory()
state.events = state.events.filter((e) => e.uid !== uid)
if (state.selectedUid === uid) state.selectedUid = null
state.dirty = true
}
function addEvent() {
pushHistory()
const ev = createEvent()
state.events.push(ev)
state.selectedUid = ev.uid
state.dirty = true
}
function splitEvent(uid, splitDate) {
const ev = state.events.find((e) => e.uid === uid)
if (!ev || !ev.rrule) return
const parts = splitRecurringEvent(ev, splitDate)
if (!parts) return
pushHistory()
const [partA, partB] = parts
const idx = state.events.findIndex((e) => e.uid === uid)
state.events.splice(idx, 1, partA, partB)
state.selectedUid = partB.uid
state.dirty = true
}
function toggleOccurrence(uid, date) {
pushHistory()
const ev = state.events.find((e) => e.uid === uid)
if (!ev) return
const i = ev.exdates.indexOf(date)
if (i >= 0) {
ev.exdates.splice(i, 1)
} else {
ev.exdates.push(date)
ev.exdates.sort()
}
state.dirty = true
}
function undo() {
if (state.history.length === 0) return
state.redoStack.push(snapshot())
restore(state.history.pop())
}
function redo() {
if (state.redoStack.length === 0) return
state.history.push(snapshot())
restore(state.redoStack.pop())
}
function serialize() {
return serializeICS({ meta: state.meta, events: state.events })
}
function markSaved() {
state.dirty = false
}
// ------------------------------------------------------------------ //
// Computed
// ------------------------------------------------------------------ //
const selectedEvent = computed(() =>
state.events.find((e) => e.uid === state.selectedUid) || null,
)
const canUndo = computed(() => state.history.length > 0)
const canRedo = computed(() => state.redoStack.length > 0)
const isLoaded = computed(() => state.events.length > 0)
const stats = computed(() => {
const nRecur = state.events.filter((e) => e.rrule !== null).length
return { total: state.events.length, recurring: nRecur, single: state.events.length - nRecur }
})
export function useCalendar() {
return {
state,
selectedEvent,
canUndo,
canRedo,
isLoaded,
stats,
loadFile,
selectEvent,
organize,
updateEvent,
deleteEvent,
addEvent,
splitEvent,
toggleOccurrence,
undo,
redo,
serialize,
markSaved,
}
}

59
src/lib/date.js Normal file
View File

@@ -0,0 +1,59 @@
/**
* 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.
*/
/** Pad a number to 2 digits with leading zero. */
export function pad2(n) {
return String(n).padStart(2, '0')
}
/** Convert a JS Date to 'YYYY-MM-DD' (local wall-clock). */
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). */
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'. */
export function toDate(isoDate) {
return new Date(isoDate + 'T00:00:00')
}
/** Add `days` days to an ISO date string, return new ISO string. */
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). */
export function daysBetween(isoA, isoB) {
return Math.round((toDate(isoB) - toDate(isoA)) / (24 * 3600 * 1000))
}
/** Today's date as 'YYYY-MM-DD' (local). */
export function todayISO() {
return toISODate(new Date())
}
/**
* Current UTC timestamp in compact RFC 5545 form (e.g. '20260711T085008Z').
* Used for DTSTAMP on new events.
*/
export function nowCompactTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
}
/** Format a JS Date's time as 'HH:MM'. */
export function toHHMM(date) {
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
}

42
src/lib/event-factory.js Normal file
View File

@@ -0,0 +1,42 @@
/**
* 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.
*/
import { todayISO, nowCompactTimestamp } from './date.js'
/**
* Create a new single-occurrence event with sensible defaults.
* @param {object} [overrides] - partial fields to override
* @returns {object} a fresh EventModel
*/
export function createEvent(overrides = {}) {
return {
uid: crypto.randomUUID(),
summary: '新日程',
location: '',
description: '',
dtstartDate: todayISO(),
dtstartTime: '09:00',
dtendTime: '10:00',
tzid: 'UTC',
rrule: null,
exdates: [],
_raw: { dtstamp: nowCompactTimestamp() },
...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.
*/
export function cloneEvent(ev) {
return JSON.parse(JSON.stringify(ev))
}

View File

@@ -1,4 +1,5 @@
import ICAL from 'ical.js' import ICAL from 'ical.js'
import { pad2, parseISODate } from './date.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Timezone registration (ical.js does not bundle IANA zones) // Timezone registration (ical.js does not bundle IANA zones)
@@ -13,17 +14,14 @@ function registerTimezones(vcalendar) {
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Helpers // ICAL.Time <-> plain string helpers
// (ICAL.Time exposes .year/.month/.day/.hour/.minute directly)
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function pad2(n) { function icalTimeToISODate(t) {
return String(n).padStart(2, '0')
}
function timeToISODate(t) {
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}` return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
} }
function timeToHHMM(t) { function icalTimeToHHMM(t) {
return `${pad2(t.hour)}:${pad2(t.minute)}` return `${pad2(t.hour)}:${pad2(t.minute)}`
} }
@@ -67,7 +65,7 @@ function parseEvent(veventComp) {
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()) {
exdates.push(timeToISODate(t)) exdates.push(icalTimeToISODate(t))
} }
} }
@@ -83,9 +81,9 @@ function parseEvent(veventComp) {
summary: veventComp.getFirstPropertyValue('summary') || '', summary: veventComp.getFirstPropertyValue('summary') || '',
location: veventComp.getFirstPropertyValue('location') || '', location: veventComp.getFirstPropertyValue('location') || '',
description: veventComp.getFirstPropertyValue('description') || '', description: veventComp.getFirstPropertyValue('description') || '',
dtstartDate: timeToISODate(start), dtstartDate: icalTimeToISODate(start),
dtstartTime: timeToHHMM(start), dtstartTime: icalTimeToHHMM(start),
dtendTime: timeToHHMM(end), dtendTime: icalTimeToHHMM(end),
tzid, tzid,
rrule, rrule,
exdates, exdates,
@@ -111,7 +109,9 @@ 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,
vtimezones: vcalendar.getAllSubcomponents('vtimezone'), // Store raw jCal arrays (not ICAL.Component instances) so the meta stays
// plain-serializable for Vue reactivity and JSON snapshot cloning.
vtimezones: vcalendar.getAllSubcomponents('vtimezone').map((vtz) => vtz.jCal),
} }
// Events // Events
@@ -123,11 +123,6 @@ export function parseICS(text) {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Helpers for serialization // Helpers for serialization
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
function parseISODate(s) {
const [y, m, d] = s.split('-').map(Number)
return { year: y, month: m, day: d }
}
function parseHHMM(s) { function parseHHMM(s) {
const [h, m] = s.split(':').map(Number) const [h, m] = s.split(':').map(Number)
return { hour: h, minute: m } return { hour: h, minute: m }
@@ -185,9 +180,10 @@ 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 (clone jCal to avoid moving from original parent) // Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS),
for (const vtz of meta.vtimezones || []) { // so wrap each directly in a new Component (avoids moving the original).
vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal)) for (const vtzJCal of meta.vtimezones || []) {
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
} }
for (const ev of events) { for (const ev of events) {

View File

@@ -3,6 +3,7 @@
* Collapses a flat list of per-occurrence events into RRULE + EXDATE. * Collapses a flat list of per-occurrence events into RRULE + EXDATE.
*/ */
import { bydayFromDate } from './weekday.js' import { bydayFromDate } from './weekday.js'
import { toDate, toISODate, addDays, daysBetween } from './date.js'
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Grouping // Grouping
@@ -13,11 +14,11 @@ import { bydayFromDate } from './weekday.js'
* wall-clock time, duration (minutes), and weekday. * wall-clock time, duration (minutes), and weekday.
*/ */
export function seriesKey(ev) { export function seriesKey(ev) {
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
const [sh, sm] = ev.dtstartTime.split(':').map(Number) const [sh, sm] = ev.dtstartTime.split(':').map(Number)
const [eh, em] = ev.dtendTime.split(':').map(Number) const [eh, em] = ev.dtendTime.split(':').map(Number)
const durationMin = (eh * 60 + em) - (sh * 60 + sm) const durationMin = (eh * 60 + em) - (sh * 60 + sm)
return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|') const weekday = toDate(ev.dtstartDate).getDay()
return [ev.summary, ev.location, ev.dtstartTime, durationMin, weekday].join('|')
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -37,9 +38,7 @@ export function detectInterval(dateStrings) {
const dates = [...dateStrings].sort() const dates = [...dateStrings].sort()
const gaps = [] const gaps = []
for (let i = 0; i < dates.length - 1; i++) { for (let i = 0; i < dates.length - 1; i++) {
const a = new Date(dates[i] + 'T00:00:00') gaps.push(daysBetween(dates[i], dates[i + 1]))
const b = new Date(dates[i + 1] + 'T00:00:00')
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
} }
let g = gaps[0] let g = gaps[0]
for (let i = 1; i < gaps.length; i++) { for (let i = 1; i < gaps.length; i++) {
@@ -51,6 +50,25 @@ export function detectInterval(dateStrings) {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Series fitting // 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.
*/
function buildGrid(firstDate, lastDate, interval, present) {
const expected = []
let cur = firstDate
while (cur <= lastDate) {
expected.push(cur)
cur = addDays(cur, interval)
}
const expectedSet = new Set(expected)
const missing = expected.filter((d) => !present.has(d))
const offGrid = [...present].filter((d) => !expectedSet.has(d))
return { expected, missing, offGrid }
}
/** /**
* Try to collapse events into one recurring event. * Try to collapse events into one recurring event.
* Returns { base, rrule, exdates } or null to decline. * Returns { base, rrule, exdates } or null to decline.
@@ -58,10 +76,8 @@ export function detectInterval(dateStrings) {
export function fitSeries(evs) { export function fitSeries(evs) {
if (evs.length < 2) return null if (evs.length < 2) return null
const sorted = [...evs].sort((a, b) => const sorted = [...evs].sort(byDate)
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, const starts = sorted.map((e) => e.dtstartDate)
)
const starts = sorted.map(e => e.dtstartDate)
const interval = detectInterval(starts) const interval = detectInterval(starts)
if (!interval) return null if (!interval) return null
@@ -69,28 +85,14 @@ export function fitSeries(evs) {
const last = sorted[sorted.length - 1] const last = sorted[sorted.length - 1]
const present = new Set(starts) const present = new Set(starts)
// Build expected grid first..last step interval days const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
const expected = []
const cur = new Date(first.dtstartDate + 'T00:00:00')
const lastDate = new Date(last.dtstartDate + 'T00:00:00')
while (cur <= lastDate) {
const y = cur.getFullYear()
const m = String(cur.getMonth() + 1).padStart(2, '0')
const d = String(cur.getDate()).padStart(2, '0')
expected.push(`${y}-${m}-${d}`)
cur.setDate(cur.getDate() + interval)
}
const expectedSet = new Set(expected)
const missing = expected.filter(d => !present.has(d))
const offGrid = starts.filter(d => !expectedSet.has(d))
if (offGrid.length > 0) return null if (offGrid.length > 0) return null
// "Mostly regular": at least 2 occurrences and occurrences >= skips // "Mostly regular": at least 2 occurrences and occurrences >= skips
if (evs.length < 2 || missing.length >= evs.length) return null if (evs.length < 2 || missing.length >= evs.length) return null
// Build rrule // Build rrule
const firstDate = new Date(first.dtstartDate + 'T00:00:00') const firstDate = toDate(first.dtstartDate)
let freq, step, byday let freq, step, byday
if (interval % 7 === 0) { if (interval % 7 === 0) {
freq = 'WEEKLY' freq = 'WEEKLY'
@@ -127,10 +129,7 @@ export function organize(events) {
let nFlat = 0 let nFlat = 0
for (const evs of groups.values()) { for (const evs of groups.values()) {
// Sort by date evs.sort(byDate)
evs.sort((a, b) =>
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
)
const fit = fitSeries(evs) const fit = fitSeries(evs)
if (fit) { if (fit) {

View File

@@ -2,6 +2,7 @@
* Recurrence grid expansion - port of ical_editor.py's occurrence generation. * Recurrence grid expansion - port of ical_editor.py's occurrence generation.
* occurrences are computed on-demand from exdates, never stored on the model. * occurrences are computed on-demand from exdates, never stored on the model.
*/ */
import { toISODate, toDate, addDays } from './date.js'
/** Interval in days for a recurring event. */ /** Interval in days for a recurring event. */
export function intervalDays(ev) { export function intervalDays(ev) {
@@ -11,12 +12,7 @@ export function intervalDays(ev) {
return freq === 'WEEKLY' ? 7 * step : step return freq === 'WEEKLY' ? 7 * step : step
} }
function toISO(date) { const MAX_OCCURRENCES = 500
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** /**
* Expand a recurring event into an occurrence grid. * Expand a recurring event into an occurrence grid.
@@ -33,20 +29,18 @@ export function expandOccurrences(ev) {
return [{ date: ev.dtstartDate, skipped: false }] return [{ date: ev.dtstartDate, skipped: false }]
} }
const start = new Date(ev.dtstartDate + 'T00:00:00')
const until = ev.rrule.untilDate const until = ev.rrule.untilDate
? new Date(ev.rrule.untilDate + 'T00:00:00') ? toDate(ev.rrule.untilDate)
: new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year : new Date(toDate(ev.dtstartDate).getTime() + 365 * 24 * 3600 * 1000)
const exdateSet = new Set(ev.exdates || []) const exdateSet = new Set(ev.exdates || [])
const occs = [] const occs = []
const cur = new Date(start) let cur = ev.dtstartDate
let count = 0 let count = 0
while (cur <= until && count < 500) { while (cur <= toISODate(until) && count < MAX_OCCURRENCES) {
const iso = toISO(cur) occs.push({ date: cur, skipped: exdateSet.has(cur) })
occs.push({ date: iso, skipped: exdateSet.has(iso) }) cur = addDays(cur, step)
cur.setDate(cur.getDate() + step)
count++ count++
} }

50
src/lib/split.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* 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).
*
* @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.
*/
import { intervalDays } from './recur.js'
import { cloneEvent } from './event-factory.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)
if (snap < start) snap = new Date(start)
while (daysBetween(startDate, toISODate(snap)) % stepDays !== 0) {
snap.setDate(snap.getDate() + 1)
if (snap > toDate(untilDate)) return null
}
return toISODate(snap)
}
export function splitRecurringEvent(ev, splitDate) {
if (!ev.rrule) return null
const { untilDate } = ev.rrule
if (splitDate <= ev.dtstartDate || splitDate > untilDate) return null
const step = intervalDays(ev)
const snapStr = snapToGrid(ev.dtstartDate, splitDate, step, untilDate)
if (!snapStr) return null
const prevStr = addDays(snapStr, -step)
const partA = cloneEvent(ev)
partA.rrule = { ...ev.rrule, untilDate: prevStr }
partA.exdates = ev.exdates.filter((d) => d < splitDate)
const partB = cloneEvent(ev)
partB.uid = crypto.randomUUID()
partB.dtstartDate = snapStr
partB.exdates = ev.exdates.filter((d) => d >= splitDate)
return [partA, partB]
}