From 361cc383841117884a99565588e09497f41c19e1 Mon Sep 17 00:00:00 2001 From: timeTable2 dev Date: Mon, 13 Jul 2026 18:37:58 +0800 Subject: [PATCH] feat: add central store with snapshot undo/redo --- src/composables/useCalendar.js | 213 +++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/composables/useCalendar.js diff --git a/src/composables/useCalendar.js b/src/composables/useCalendar.js new file mode 100644 index 0000000..0f8d841 --- /dev/null +++ b/src/composables/useCalendar.js @@ -0,0 +1,213 @@ +import { reactive, computed } from 'vue' +import { parseICS, serializeICS } from '../lib/ical-io.js' +import { organize as organizeEvents } from '../lib/organize.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 ? JSON.parse(JSON.stringify(state.meta)) : null, + events: JSON.parse(JSON.stringify(state.events)), + 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 ? JSON.parse(JSON.stringify(snap.meta)) : null + state.events = JSON.parse(JSON.stringify(snap.events)) + 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 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 today = new Date() + const y = today.getFullYear() + const m = String(today.getMonth() + 1).padStart(2, '0') + const d = String(today.getDate()).padStart(2, '0') + const ev = { + uid: crypto.randomUUID(), + summary: '新日程', + location: '', + description: '', + dtstartDate: `${y}-${m}-${d}`, + dtstartTime: '09:00', + dtendTime: '10:00', + tzid: 'UTC', + rrule: null, + exdates: [], + _raw: { dtstamp: new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' }, + } + 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 + + // Snap splitDate to the next grid point >= splitDate + const step = ev.rrule.freq === 'WEEKLY' ? 7 * (ev.rrule.interval || 1) : (ev.rrule.interval || 1) + const start = new Date(ev.dtstartDate + 'T00:00:00') + let snap = new Date(splitDate + 'T00:00:00') + // Advance snap to the next grid point on or after splitDate + while (snap < start || ((snap - start) / (24 * 3600 * 1000)) % step !== 0) { + if (snap < start) { snap = new Date(start); break } + snap.setDate(snap.getDate() + 1) + if (snap > new Date(ev.rrule.untilDate + 'T00:00:00')) return // out of range + } + const snapStr = `${snap.getFullYear()}-${String(snap.getMonth() + 1).padStart(2, '0')}-${String(snap.getDate()).padStart(2, '0')}` + + // Part A: rrule.untilDate = day before snap (previous grid point) + const prevGrid = new Date(snap) + prevGrid.setDate(prevGrid.getDate() - step) + const prevStr = `${prevGrid.getFullYear()}-${String(prevGrid.getMonth() + 1).padStart(2, '0')}-${String(prevGrid.getDate()).padStart(2, '0')}` + + pushHistory() + + const partA = JSON.parse(JSON.stringify(ev)) + partA.rrule = { ...ev.rrule, untilDate: prevStr } + partA.exdates = ev.exdates.filter((d) => d < splitDate) + + const partB = JSON.parse(JSON.stringify(ev)) + partB.uid = crypto.randomUUID() + partB.dtstartDate = snapStr + partB.exdates = ev.exdates.filter((d) => d >= splitDate) + + 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, + organize, + updateEvent, + deleteEvent, + addEvent, + splitEvent, + toggleOccurrence, + undo, + redo, + serialize, + markSaved, + } +}