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>
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div style="font-family: sans-serif; padding: 40px;">
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<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>
|
||||
</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 { pad2, parseISODate } from './date.js'
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// 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) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function timeToISODate(t) {
|
||||
function icalTimeToISODate(t) {
|
||||
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
|
||||
}
|
||||
|
||||
function timeToHHMM(t) {
|
||||
function icalTimeToHHMM(t) {
|
||||
return `${pad2(t.hour)}:${pad2(t.minute)}`
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ function parseEvent(veventComp) {
|
||||
const exdates = []
|
||||
for (const prop of veventComp.getAllProperties('exdate')) {
|
||||
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') || '',
|
||||
location: veventComp.getFirstPropertyValue('location') || '',
|
||||
description: veventComp.getFirstPropertyValue('description') || '',
|
||||
dtstartDate: timeToISODate(start),
|
||||
dtstartTime: timeToHHMM(start),
|
||||
dtendTime: timeToHHMM(end),
|
||||
dtstartDate: icalTimeToISODate(start),
|
||||
dtstartTime: icalTimeToHHMM(start),
|
||||
dtendTime: icalTimeToHHMM(end),
|
||||
tzid,
|
||||
rrule,
|
||||
exdates,
|
||||
@@ -111,7 +109,9 @@ export function parseICS(text) {
|
||||
method: vcalendar.getFirstPropertyValue('method') || null,
|
||||
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || 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
|
||||
@@ -123,11 +123,6 @@ export function parseICS(text) {
|
||||
// ------------------------------------------------------------------ //
|
||||
// Helpers for serialization
|
||||
// ------------------------------------------------------------------ //
|
||||
function parseISODate(s) {
|
||||
const [y, m, d] = s.split('-').map(Number)
|
||||
return { year: y, month: m, day: d }
|
||||
}
|
||||
|
||||
function parseHHMM(s) {
|
||||
const [h, m] = s.split(':').map(Number)
|
||||
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.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
|
||||
|
||||
// Re-add VTIMEZONEs (clone jCal to avoid moving from original parent)
|
||||
for (const vtz of meta.vtimezones || []) {
|
||||
vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal))
|
||||
// Re-add VTIMEZONEs. meta.vtimezones stores raw jCal arrays (see parseICS),
|
||||
// so wrap each directly in a new Component (avoids moving the original).
|
||||
for (const vtzJCal of meta.vtimezones || []) {
|
||||
vcalendar.addSubcomponent(new ICAL.Component(vtzJCal))
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Collapses a flat list of per-occurrence events into RRULE + EXDATE.
|
||||
*/
|
||||
import { bydayFromDate } from './weekday.js'
|
||||
import { toDate, toISODate, addDays, daysBetween } from './date.js'
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Grouping
|
||||
@@ -13,11 +14,11 @@ import { bydayFromDate } from './weekday.js'
|
||||
* wall-clock time, duration (minutes), and weekday.
|
||||
*/
|
||||
export function seriesKey(ev) {
|
||||
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
|
||||
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
||||
const [eh, em] = ev.dtendTime.split(':').map(Number)
|
||||
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 gaps = []
|
||||
for (let i = 0; i < dates.length - 1; i++) {
|
||||
const a = new Date(dates[i] + 'T00:00:00')
|
||||
const b = new Date(dates[i + 1] + 'T00:00:00')
|
||||
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
|
||||
gaps.push(daysBetween(dates[i], dates[i + 1]))
|
||||
}
|
||||
let g = gaps[0]
|
||||
for (let i = 1; i < gaps.length; i++) {
|
||||
@@ -51,6 +50,25 @@ export function detectInterval(dateStrings) {
|
||||
// ------------------------------------------------------------------ //
|
||||
// Series fitting
|
||||
// ------------------------------------------------------------------ //
|
||||
const byDate = (a, b) => (a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0)
|
||||
|
||||
/**
|
||||
* Build the expected occurrence grid from first to last at the given interval.
|
||||
* Returns { expected: string[], missing: string[] } relative to present dates.
|
||||
*/
|
||||
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.
|
||||
* Returns { base, rrule, exdates } or null to decline.
|
||||
@@ -58,10 +76,8 @@ export function detectInterval(dateStrings) {
|
||||
export function fitSeries(evs) {
|
||||
if (evs.length < 2) return null
|
||||
|
||||
const sorted = [...evs].sort((a, b) =>
|
||||
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||||
)
|
||||
const starts = sorted.map(e => e.dtstartDate)
|
||||
const sorted = [...evs].sort(byDate)
|
||||
const starts = sorted.map((e) => e.dtstartDate)
|
||||
const interval = detectInterval(starts)
|
||||
if (!interval) return null
|
||||
|
||||
@@ -69,28 +85,14 @@ export function fitSeries(evs) {
|
||||
const last = sorted[sorted.length - 1]
|
||||
const present = new Set(starts)
|
||||
|
||||
// Build expected grid first..last step interval days
|
||||
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))
|
||||
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
|
||||
if (offGrid.length > 0) return null
|
||||
|
||||
// "Mostly regular": at least 2 occurrences and occurrences >= skips
|
||||
if (evs.length < 2 || missing.length >= evs.length) return null
|
||||
|
||||
// Build rrule
|
||||
const firstDate = new Date(first.dtstartDate + 'T00:00:00')
|
||||
const firstDate = toDate(first.dtstartDate)
|
||||
let freq, step, byday
|
||||
if (interval % 7 === 0) {
|
||||
freq = 'WEEKLY'
|
||||
@@ -127,10 +129,7 @@ export function organize(events) {
|
||||
let nFlat = 0
|
||||
|
||||
for (const evs of groups.values()) {
|
||||
// Sort by date
|
||||
evs.sort((a, b) =>
|
||||
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||||
)
|
||||
evs.sort(byDate)
|
||||
|
||||
const fit = fitSeries(evs)
|
||||
if (fit) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Recurrence grid expansion - port of ical_editor.py's occurrence generation.
|
||||
* 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. */
|
||||
export function intervalDays(ev) {
|
||||
@@ -11,12 +12,7 @@ export function intervalDays(ev) {
|
||||
return freq === 'WEEKLY' ? 7 * step : step
|
||||
}
|
||||
|
||||
function toISO(date) {
|
||||
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}`
|
||||
}
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
/**
|
||||
* Expand a recurring event into an occurrence grid.
|
||||
@@ -33,20 +29,18 @@ export function expandOccurrences(ev) {
|
||||
return [{ date: ev.dtstartDate, skipped: false }]
|
||||
}
|
||||
|
||||
const start = new Date(ev.dtstartDate + 'T00:00:00')
|
||||
const until = ev.rrule.untilDate
|
||||
? new Date(ev.rrule.untilDate + 'T00:00:00')
|
||||
: new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year
|
||||
? toDate(ev.rrule.untilDate)
|
||||
: new Date(toDate(ev.dtstartDate).getTime() + 365 * 24 * 3600 * 1000)
|
||||
|
||||
const exdateSet = new Set(ev.exdates || [])
|
||||
const occs = []
|
||||
const cur = new Date(start)
|
||||
let cur = ev.dtstartDate
|
||||
let count = 0
|
||||
|
||||
while (cur <= until && count < 500) {
|
||||
const iso = toISO(cur)
|
||||
occs.push({ date: iso, skipped: exdateSet.has(iso) })
|
||||
cur.setDate(cur.getDate() + step)
|
||||
while (cur <= toISODate(until) && count < MAX_OCCURRENCES) {
|
||||
occs.push({ date: cur, skipped: exdateSet.has(cur) })
|
||||
cur = addDays(cur, step)
|
||||
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