Compare commits
10 Commits
dc70a04b54
...
51461ff5f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51461ff5f2 | ||
|
|
b352c4e116 | ||
|
|
02cfdda900 | ||
|
|
e4f549f848 | ||
|
|
3af6b856e3 | ||
|
|
15b6f30896 | ||
|
|
8a1d547001 | ||
|
|
f52f5315e6 | ||
|
|
906679fc11 | ||
|
|
361cc38384 |
125
README.md
Normal file
125
README.md
Normal 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 model(ical.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.5(SFC,`<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` |
|
||||||
49
src/App.vue
49
src/App.vue
@@ -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>
|
||||||
|
|||||||
99
src/components/DropZone.vue
Normal file
99
src/components/DropZone.vue
Normal 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>
|
||||||
253
src/components/EventDetail.vue
Normal file
253
src/components/EventDetail.vue
Normal 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>
|
||||||
87
src/components/EventList.vue
Normal file
87
src/components/EventList.vue
Normal 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>
|
||||||
76
src/components/HeaderBar.vue
Normal file
76
src/components/HeaderBar.vue
Normal 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>
|
||||||
75
src/components/OccurrenceGrid.vue
Normal file
75
src/components/OccurrenceGrid.vue
Normal 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>
|
||||||
181
src/composables/useCalendar.js
Normal file
181
src/composables/useCalendar.js
Normal 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
59
src/lib/date.js
Normal 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
42
src/lib/event-factory.js
Normal 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))
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
50
src/lib/split.js
Normal 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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user