談談學校行事曆的管理事務,溪洲行事曆雖然早就不印紙本改提供電子檔,而且在智慧型手機普及之後也都有行動版行事曆了(Google日曆共用),但也就是因為數位化,學校行事曆這件事就開始跟資訊組扯上關係。溪洲行事曆是每年寒暑假由行政人員共同編寫電子檔再由教務主任彙整,所以是多人共筆的方式產生。也就是因為多人共筆,所以容易造成格式紊亂的情況,有的人在活動事項前會加流水號排序,有的人沒有這麼做,光是日期的寫法有人寫1/1、0101、01-01、1月1日、一月1日,再加上有的同事會寫上星期標示就出現了全形半形(一)、(一)符號不一的問題。還有一種最令人不解的情況,會有一部分活動或工作會以符號標示但卻沒有排定日期的,這應該要記在備忘錄而不是行事曆吧…

每當學期初校務會議通過學校行事後,我就開始或多或少的把重要行事寫到學校共用的Google日曆中,早先幾年幾乎會全上Google,好處是校網可以一起用,也有幾位同事會一起把自己業務的重要期程做在Google日曆中,但活動數量多要做這件事就心煩,再加上最近這2-3年實在是心有餘而力不足,眼睛也吃不消,這事太累人了。不論是使用匯入csv還是線上輸入,都少不了需要逐筆填寫來符合Google日曆格式的過程,於是就越做越…懶了。
但是今年暑假期間發現,小老闆很認真地接手了這項工作,也簡單聊了一下做這件事的痛,小老闆很正向的看待這事,覺得做這件事可以自己順便做好校對工作,但我自己知道做這些很辛苦,所以萌生了打掉Google日曆的想法~喔!不是,說錯了,是萌生了優化工作流程的想法…
首先,各處行政同仁共筆編輯這工作少不了,但是得要求大家遵循一個標準,不要讓格式太亂,所以一開始其實想弄個線上系統讓大家輸入之後由系統自動調整為統一格式,再下載成Google日曆標準的csv格式就可以匯入,but,就是這個but…我覺得大家一定還是寧可編輯原本的電子檔就好,因為我猜那些很多沒有表訂日期的活動其實就是前一年留下的舊資料,如果改成線上編輯,變成要每學期清空再重建資料,這點我承認有點違背人性~
退而求其次,維持大家原本的工作習慣不增加負擔,行事曆內容上到Google這一段很多人本來就認為與自己無關的,其實也與資訊組無關啦。既然這條路走不通就換個想法,AI教我做了一個word-csv.exe的轉換程式,可是在封裝時發現需要搭配python環境,我又不想到每個行政電腦去做這個環境,不過還好這條路沒斷,換個方向思考改做在線上,結果效果意料之外的好…

線上工具轉出的品質在經過調校後可以接受,不過converter其實被我打掉重練了好多次,篩選過濾機制定太嚴會沒有幾筆資料能匯出,太寬鬆又會留下很多空值,但基本上還是要傾向寬鬆,因為匯出資料後空值都集中在最後,框選起來del倒是不麻煩。底下是學校100學年度第二學期的行事曆電子檔,因為格式五花八門,所以很適合拿來測試調校轉換器,最後調整的結果很滿意,篩選過濾後能匯出147筆活動,沒有留下活動名稱為空值的項目,雖然沒有核對被篩除的活動是否正確,但這畢竟不是需要100%完美的工作,這樣能匯入我覺得就輕鬆很多了。

過濾篩選的機制拆分成數個檔案分工,紀錄一下內容…
parseWordFile,這個檔案主要是告訴converter認識上傳word文件的表格,表格會有三欄,第 1 欄類別是處室單位,第 2 欄、第 3 欄 就是AB兩週的活動內容…
<?php
require_once __DIR__ . '/../vendor/autoload.php';
function parseWordFile($filePath) {
$phpWord = \PhpOffice\PhpWord\IOFactory::load($filePath);
$events = [];
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getRows')) {
foreach ($element->getRows() as $row) {
$cells = $row->getCells();
if (count($cells) >= 3) {
$category = trim(collectText($cells[0]->getElements()));
$subject1 = trim(collectText($cells[1]->getElements()));
$subject2 = trim(collectText($cells[2]->getElements()));
if ($category !== '' && $subject1 !== '') {
$events[] = ['category' => $category, 'content' => $subject1];
}
if ($category !== '' && $subject2 !== '') {
$events[] = ['category' => $category, 'content' => $subject2];
}
}
}
}
}
}
return $events;
}
function collectText($elements) {
$text = '';
foreach ($elements as $e) {
if (method_exists($e, 'getText')) {
$text .= $e->getText();
}
}
return $text;
}
parseDate這個檔案負責的功能就是 從文字中抽取日期並標準化成 YYYY-MM-DD
格式,透過這個檔案處理日期正規化…
單日活動的格式:MM/DD
跨日活動:MM/DD–MM/DD
單日活動:9/5
補0顯示為09-05
只有一個開始或結束日期視為整日活動,一個活動如果有2筆日期則區分為開始與結束日期,例如9/25-9/27匯出成為(start_date = 2025-09-25
/end_date = 2025-09-27
)
<?php
function parseDate($text, $year) {
$result = ['start_date' => '', 'end_date' => ''];
// 支援 MM/DD 或 MM/DD–MM/DD
if (preg_match('/(\d{1,2})\/(\d{1,2})(?:\s*[–\-]\s*(\d{1,2})\/(\d{1,2}))?/', $text, $m)) {
$month1 = str_pad($m[1], 2, '0', STR_PAD_LEFT);
$day1 = str_pad($m[2], 2, '0', STR_PAD_LEFT);
$result['start_date'] = $year . '-' . $month1 . '-' . $day1;
if (!empty($m[3]) && !empty($m[4])) {
$month2 = str_pad($m[3], 2, '0', STR_PAD_LEFT);
$day2 = str_pad($m[4], 2, '0', STR_PAD_LEFT);
$result['end_date'] = $year . '-' . $month2 . '-' . $day2;
}
}
// Debug 輸出
error_log("[parseDate] text={$text} => " . json_encode($result, JSON_UNESCAPED_UNICODE));
return $result;
}
splitEvents這個檔案的用途就是 把一個字串裡的多個活動事件切開,方便後續逐筆處理,負責的工作包含清除字串前後的符號與空格,同時移除因為符號而產生的空值
<?php
function splitEvents($content) {
$parts = preg_split('/[\n;;]+/u', $content);
return array_filter(array_map('trim', $parts));
}
Description主要是幫我抓處室分類,表格第1欄為各處室,Google日曆匯入格式有一個description的活動描述或說明欄位,這邊在學校行事曆上不會有能對應的資料內容,所以就讓description欄位去抓表格第一欄分類的處室名稱,另外,沒有任何日期或時間的活動則不保留,有時間但沒有日期的活動也不保留。
<?php
function buildDescription($category, $timeInfo) {
$desc = "分類:" . $category;
if (!empty($timeInfo['allDay']) && $timeInfo['allDay'] === true) {
$desc .= "\n(全天事件)";
}
return $desc;
}
function normalizeEvent($summary, $category, $dateInfo, $timeInfo) {
$startDate = $dateInfo['start_date'] ?? '';
$endDate = $dateInfo['end_date'] ?? '';
$startTime = $timeInfo['start_time'] ?? '';
$endTime = $timeInfo['end_time'] ?? '';
// 完全沒有日期與時間 → 移除
if ($startDate === '' && $endDate === '' && $startTime === '' && $endTime === '') {
return null;
}
// 有時間但沒有日期 → 移除
if ($startDate === '' && $endDate === '' && ($startTime !== '' || $endTime !== '')) {
return null;
}
if ($endDate === '' && $startDate !== '') {
$endDate = $startDate;
}
return [
'Subject' => $summary,
'Category' => $category,
'Start Date' => $startDate,
'Start Time' => $startTime,
'End Date' => $endDate,
'End Time' => $endTime,
'Description' => buildDescription($category, $timeInfo),
'Location' => ''
];
}
parseTime的作用就是 從文字中抽取時間資訊,並判斷是否為全日事件。如果只找到一個時間則視為開始時間,結束時間留空,如果完全沒有時間格式就視為全天事件。
<?php
function parseTime($text) {
$result = ['start_time' => '', 'end_time' => '', 'allDay' => false];
if (preg_match('/(\d{1,2}:\d{2})\s*[–\-]\s*(\d{1,2}:\d{2})/', $text, $m)) {
$result['start_time'] = $m[1];
$result['end_time'] = $m[2];
} elseif (preg_match('/(\d{1,2}:\d{2})/', $text, $m)) {
$result['start_time'] = $m[1];
} else {
$result['allDay'] = true;
}
return $result;
}
沒花多少時間也很輕鬆地就把轉檔工具搭建完成了,正當心情愉快地認為這樣就完美了的同時,心裡又延伸了一點其他想法,於是又跑去問了魔鏡。魔鏡阿,魔鏡~Google日曆有沒有能跟本地端同步內容的方法?結果魔鏡的回答是”有”,而且看我想要單向同步還是雙向同步都可以做到!在大概有了一點觀念之後,原本愉快的心情瞬間沉到谷底,因為有好多東西是之前沒有接觸過也沒有概念的都需要克服…Google API、Oauth、callback、token、fetch、webhook、watch…還有一些有的沒的id與secret…等有做出來的話再寫下一篇吧。