Watch at Ease看見世界,讀懂生活。

科技 · 實用指南

從已儲存的活頁簿歷史匯出 Excel 任務完成日期

如果 XLSX 保留了指派任務的歷史紀錄,就能擷取其中仍然有效的完成事件與 UTC 時間。這個本機匯出程式也會明確指出,檔案何時無法提供這些資訊。

三張卡片分別呈現註解新增時間、註解執行緒的已解決狀態,以及帶有 UTC 時間的文件任務事件。已儲存且仍然有效、從低於 100 變為 100 的 Progress 轉換,才提供完成事件時間;若缺少任務歷史,就無法取得。
原創說明示意圖。CommentThreaded.Date 記錄註解新增時間,done 旗標則記錄註解執行緒的已解決狀態。要取得已儲存的任務完成時間,任務歷史中必須有仍然有效、從低於 100 變為 100 的 Progress 轉換。 Watch at Ease

匯出 Excel 註解時,可能會漏掉標示為 Completed Task 的項目。註解建立時間、最後一則回覆的時間,以及任務達到完成狀態的時間,回答的是不同問題。拿其中一個代替另一個,可能會讓報表出現看似合理、實際卻錯誤的日期。

已儲存的 XLSX 可能包含獨立的文件任務歷史。以下程式會檢查這些已儲存的資料,將任務與註解執行緒 ID 關聯,並匯出能夠驗證的事件。如果缺少必要的歷史紀錄,結果會明確說明。它不會從註解的 done 旗標推算出解決日期,也不保證每個 Excel 版本或匯出工具都會保留任務歷史。

1. 確認你需要哪一種日期

如果匯出的註解或回覆缺少指派任務的完成資訊,而你想檢查已儲存的活頁簿,就可以使用本指南。如果只需要一般註解的文字、作者、回覆,以及未解決/已解決狀態,CommentPulse 等現有匯出工具可能已能提供你需要的表格。

Microsoft 將 CommentThreaded.Date 定義為註解新增時的本地時間。原始 Stack Overflow 問題的兩則回答,一則以最後一則回覆的時間代替,另一則檢查一般註解執行緒的 XML,找到 done 旗標。最後一則回覆可能早於後續的任務完成操作。這兩種結果都無法確立後續任務事件的時間。

這裡的方法適用範圍較窄:必須有實際的文件任務,以及它已儲存的 History。一般註解即使已解決,只要沒有這些資料,就無法透過此方法還原任務完成時間。任務時間戳記也不能證明使用者曾按下某個特定介面按鈕,更不代表整個帳號完整的稽核軌跡。

2. 保留一份已儲存的活頁簿副本

將你有權讀取的活頁簿另存或下載一份最新的 XLSX 副本。請等儲存完成後,再執行匯出程式。保留原檔,並檢查副本。輸出反映的是該檔案中的資料;之後若有雲端變更未儲存進這份副本,輸出就不可能包含它。

不要只是為了這項檢查,就先透過另一個試算表服務轉換檔案:能使用的歷史紀錄,取決於轉換後檔案實際保留了什麼。舊的匯出檔、沒有指派任務的檔案,或產生檔案時省略了任務歷史,都可能合理地得到沒有可用任務紀錄的結果。

程式讀取以 ZIP 為基礎的 XLSX 套件,並沿著內部關聯尋找資料。工作表名稱與註解部分是透過這些關聯連接,而不是猜測 sheet1 對應 threadedComment1。程式不會將活頁簿傳送到網站,也不會呼叫你的 Microsoft 帳號。

3. 儲存完整的本機匯出程式

請使用 Python 3.12 或更新版本,以及純文字編輯器。將下方完整程式儲存為 export_excel_task_events.py。它使用 Python 標準函式庫,因此本流程不需要 pip 套件、API 權杖或 Excel 巨集。

下方可複製區塊是完整程式的單行形式。從本頁複製時,其中經跳脫處理的換行與縮排會保留 Python 原始版面。請完全按照顯示內容儲存為文字;編輯器自動折行顯示不影響使用。不要在整個區塊外再加上引號。

exec("#!/usr/bin/env python3\n\"\"\"Read saved XLSX document-task history; never infer completion from comments.\n\nUsage: python export_excel_task_events.py workbook.xlsx --out extracted_tasks\nOnly Python 3 standard-library modules are required. The source is never written.\n\"\"\"\nimport argparse, csv, datetime as dt, json, posixpath, re, sys, tempfile, zipfile\nfrom pathlib import Path\nfrom urllib.parse import unquote, urlsplit\nfrom xml.etree import ElementTree as ET\n\nTASK_NS = \"http://schemas.microsoft.com/office/tasks/2019/documenttasks\"\nEVENT_FIELDS = (\"source_part task_id ordinal event_id time_utc_raw event_type \"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"percent_complete undo_target undone anchor_id sheet cell \"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"actor_id actor_name actor_provider details_json is_completion_transition\").split()\nTASK_FIELDS = (\"source_part task_id anchor_id sheet cell status progress_percent \"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"completed_at_utc completed_event_id deleted\").split()\nMAX_XML = 32 * 1024 * 1024\nTIME = re.compile(r\"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?\\Z\")\nOPERATIONS = {\"Create\", \"Assign\", \"Unassign\", \"UnassignAll\", \"SetTitle\",\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"Schedule\", \"Progress\", \"Priority\", \"Delete\", \"Undelete\", \"Undo\"}\n\ndef local(tag):\n\x20\x20\x20\x20return tag.rsplit(\"}\", 1)[-1]\n\ndef required(element, key, context):\n\x20\x20\x20\x20value = element.get(key, \"\")\n\x20\x20\x20\x20if not value.strip():\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"{context}: missing {key}\")\n\x20\x20\x20\x20return value\n\ndef anchor(element):\n\x20\x20\x20\x20child = element.find(f\"{{{TASK_NS}}}Anchor/{{{TASK_NS}}}Comment\")\n\x20\x20\x20\x20return child.get(\"id\", \"\") if child is not None else \"\"\n\ndef utc_time(value):\n\x20\x20\x20\x20if not TIME.fullmatch(value):\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Invalid task event datetime: {value!r}\")\n\x20\x20\x20\x20parsed = dt.datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n\x20\x20\x20\x20if parsed.utcoffset() not in (None, dt.timedelta(0)):\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Task event time must be UTC: {value!r}\")\n\x20\x20\x20\x20if value.endswith(\"Z\"):\n\x20\x20\x20\x20\x20\x20\x20\x20return value\n\x20\x20\x20\x20return (value[:-6] if re.search(r\"[+-]00:00$\", value) else value) + \"Z\"\n\ndef resolve(owner, target):\n\x20\x20\x20\x20uri = urlsplit(target)\n\x20\x20\x20\x20if uri.scheme or uri.netloc or uri.query:\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Invalid internal relationship target: {target!r}\")\n\x20\x20\x20\x20path = unquote(uri.path)\n\x20\x20\x20\x20if not path:\n\x20\x20\x20\x20\x20\x20\x20\x20return owner\x20\x20# A fragment-only relationship refers to this part.\n\x20\x20\x20\x20part = posixpath.normpath(path.lstrip(\"/\") if path.startswith(\"/\") else\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20posixpath.join(posixpath.dirname(owner), path))\n\x20\x20\x20\x20if part in (\".\", \"..\") or part.startswith(\"../\") or \"\\\\\" in part or \"\\x00\" in part:\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Relationship escapes the package: {target!r}\")\n\x20\x20\x20\x20return part\n\nclass Package:\n\x20\x20\x20\x20def __init__(self, archive):\n\x20\x20\x20\x20\x20\x20\x20\x20self.archive, self.names, self.graph, self.kinds = archive, set(), {}, {}\n\x20\x20\x20\x20\x20\x20\x20\x20for entry in archive.infolist():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if entry.flag_bits \u0026 1:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(\"Encrypted ZIP entries are not supported\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if entry.is_dir():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20name = entry.filename\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if name in self.names:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Duplicate ZIP entry: {name}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if name.startswith((\"/\", \"../\")) or name == \"..\" or posixpath.normpath(name) != name or \"\\\\\" in name:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Invalid ZIP part name: {name}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20self.names.add(name)\n\x20\x20\x20\x20\x20\x20\x20\x20pending, reached = [\"\"], set()\n\x20\x20\x20\x20\x20\x20\x20\x20while pending:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20owner = pending.pop()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if owner in reached:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20reached.add(owner)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20relfile = (posixpath.join(posixpath.dirname(owner), \"_rels\",\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20posixpath.basename(owner) + \".rels\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if owner else \"_rels/.rels\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20relations = {}\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if relfile in self.names:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for rel in self.xml(relfile):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if local(rel.tag) != \"Relationship\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20rid = required(rel, \"Id\", relfile)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if rid in relations:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Duplicate relationship ID in {relfile}: {rid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20kind = required(rel, \"Type\", relfile).rsplit(\"/\", 1)[-1].lower()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20target = required(rel, \"Target\", relfile)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if rel.get(\"TargetMode\") == \"External\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20relations[rid] = (kind, None)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20target = resolve(owner, target)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if target and target not in self.names:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Missing relationship target: {target}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20relations[rid] = (kind, target)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20self.kinds.setdefault(target, set()).add(kind)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20pending.append(target)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20self.graph[owner] = relations\n\x20\x20\x20\x20\x20\x20\x20\x20self.reached = reached\n\n\x20\x20\x20\x20def xml(self, name):\n\x20\x20\x20\x20\x20\x20\x20\x20if self.archive.getinfo(name).file_size > MAX_XML:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"XML part exceeds 32 MiB: {name}\")\n\x20\x20\x20\x20\x20\x20\x20\x20data = self.archive.read(name)\n\x20\x20\x20\x20\x20\x20\x20\x20if b\"\u003c!DOCTYPE\" in data or b\"\u003c!ENTITY\" in data:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"DTD/entity declarations are not supported: {name}\")\n\x20\x20\x20\x20\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20return ET.fromstring(data)\n\x20\x20\x20\x20\x20\x20\x20\x20except ET.ParseError as error:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Invalid XML in {name}: {error}\") from error\n\n\x20\x20\x20\x20def comment_locations(self):\n\x20\x20\x20\x20\x20\x20\x20\x20books = [p for kind, p in self.graph[\"\"].values() if kind == \"officedocument\" and p]\n\x20\x20\x20\x20\x20\x20\x20\x20if len(books) != 1:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(\"Expected one package-root officeDocument relationship\")\n\x20\x20\x20\x20\x20\x20\x20\x20book, comments = books[0], {}\n\x20\x20\x20\x20\x20\x20\x20\x20root = self.xml(book)\n\x20\x20\x20\x20\x20\x20\x20\x20if local(root.tag) != \"workbook\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(\"The package is not an XLSX workbook\")\n\x20\x20\x20\x20\x20\x20\x20\x20for sheet in root.iter():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if local(sheet.tag) != \"sheet\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20rid = next((v for k, v in sheet.attrib.items() if k.endswith(\"}id\")), \"\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20kind, part = self.graph[book].get(rid, (\"\", None))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if kind != \"worksheet\" or not part:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for relkind, target in self.graph[part].values():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if target and relkind in (\"threadedcomment\", \"threadedcomments\"):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for comment in self.xml(target):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if local(comment.tag) != \"threadedComment\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20cid = required(comment, \"id\", target)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if cid in comments:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Duplicate threaded-comment ID: {cid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20comments[cid] = (sheet.get(\"name\", \"\"), comment.get(\"ref\", \"\"),\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20comment.get(\"parentId\", \"\"))\n\x20\x20\x20\x20\x20\x20\x20\x20def location(cid, seen):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if cid in seen:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Cyclic comment parent IDs: {cid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20sheet, cell, parent = comments[cid]\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if not cell and parent in comments:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20return location(parent, seen | {cid})\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20return sheet, cell\n\x20\x20\x20\x20\x20\x20\x20\x20return {cid: location(cid, set()) for cid in comments}\n\n\x20\x20\x20\x20def task_parts(self):\n\x20\x20\x20\x20\x20\x20\x20\x20parts = {p for p in self.reached if \"documenttasks\" in self.kinds.get(p, set())}\n\x20\x20\x20\x20\x20\x20\x20\x20if \"[Content_Types].xml\" in self.names:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for item in self.xml(\"[Content_Types].xml\"):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if local(item.tag) == \"Override\" and \"documenttasks\" in item.get(\"ContentType\", \"\").lower():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20part = resolve(\"\", item.get(\"PartName\", \"\"))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if part in self.reached:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20parts.add(part)\n\x20\x20\x20\x20\x20\x20\x20\x20return sorted(parts)\n\ndef parse_task(task, source, locations):\n\x20\x20\x20\x20tid = required(task, \"id\", source)\n\x20\x20\x20\x20history = task.find(f\"{{{TASK_NS}}}History\")\n\x20\x20\x20\x20nodes = [] if history is None else list(history.findall(f\"{{{TASK_NS}}}Event\"))\n\x20\x20\x20\x20aid = anchor(task)\n\x20\x20\x20\x20candidates = {anchor(e) for e in nodes if anchor(e)}\n\x20\x20\x20\x20if not aid and len(candidates) == 1:\n\x20\x20\x20\x20\x20\x20\x20\x20aid = next(iter(candidates))\n\x20\x20\x20\x20status = (\"ok\" if aid in locations else \"unmatched_anchor\" if aid else\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"ambiguous_anchor\" if candidates else \"missing_anchor\")\n\x20\x20\x20\x20sheet, cell = locations.get(aid, (\"\", \"\"))\n\x20\x20\x20\x20state = dict(zip(TASK_FIELDS, [source, tid, aid, sheet, cell, status, \"\", \"\", \"\", False]))\n\x20\x20\x20\x20if not nodes:\n\x20\x20\x20\x20\x20\x20\x20\x20state[\"status\"] = \"missing_history\"\n\x20\x20\x20\x20\x20\x20\x20\x20return [], state\n\x20\x20\x20\x20events, positions, normalized = [], {}, []\n\x20\x20\x20\x20for ordinal, node in enumerate(nodes, 1):\n\x20\x20\x20\x20\x20\x20\x20\x20eid = required(node, \"id\", f\"Task {tid} event {ordinal}\")\n\x20\x20\x20\x20\x20\x20\x20\x20if eid in positions:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Duplicate event ID in task {tid}: {eid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20raw = required(node, \"time\", f\"Event {eid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20normalized.append(utc_time(raw))\n\x20\x20\x20\x20\x20\x20\x20\x20actors = node.findall(f\"{{{TASK_NS}}}Attribution\")\n\x20\x20\x20\x20\x20\x20\x20\x20operations = [c for c in node if c.tag.startswith(\"{\" + TASK_NS + \"}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20and local(c.tag) in OPERATIONS]\n\x20\x20\x20\x20\x20\x20\x20\x20if len(actors) != 1 or len(operations) > 1:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Event {eid}: expected one Attribution and at most one operation\")\n\x20\x20\x20\x20\x20\x20\x20\x20actor = actors[0]\n\x20\x20\x20\x20\x20\x20\x20\x20actor_values = [required(actor, k, f\"Event {eid} Attribution\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for k in (\"userId\", \"userName\", \"userProvider\")]\n\x20\x20\x20\x20\x20\x20\x20\x20op = operations[0] if operations else None\n\x20\x20\x20\x20\x20\x20\x20\x20kind, percent, undo = local(op.tag) if op is not None else \"Extension\", \"\", \"\"\n\x20\x20\x20\x20\x20\x20\x20\x20if kind == \"Progress\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20text = required(op, \"percentComplete\", f\"Event {eid}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if not re.fullmatch(r\"\\+?\\d+\", text) or not 0 \u003c= int(text) \u003c= 100:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Event {eid}: percentComplete must be an integer from 0 to 100\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20percent = int(text)\n\x20\x20\x20\x20\x20\x20\x20\x20if kind == \"Undo\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20undo = required(op, \"id\", f\"Event {eid} Undo\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if undo not in positions or events[positions[undo]][\"event_type\"] == \"Create\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Event {eid}: Undo must target an earlier non-Create event\")\n\x20\x20\x20\x20\x20\x20\x20\x20positions[eid] = ordinal - 1\n\x20\x20\x20\x20\x20\x20\x20\x20event_aid = anchor(node) or aid\n\x20\x20\x20\x20\x20\x20\x20\x20event_sheet, event_cell = locations.get(event_aid, (\"\", \"\"))\n\x20\x20\x20\x20\x20\x20\x20\x20detail = json.dumps({\"attributes\": node.attrib, \"xml\": ET.tostring(node, encoding=\"unicode\")}, ensure_ascii=False)\n\x20\x20\x20\x20\x20\x20\x20\x20values = [source, tid, ordinal, eid, raw, kind, percent, undo, False,\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20event_aid, event_sheet, event_cell] + actor_values + [detail, False]\n\x20\x20\x20\x20\x20\x20\x20\x20events.append(dict(zip(EVENT_FIELDS, values)))\n\x20\x20\x20\x20if events[0][\"event_type\"] != \"Create\" or sum(e[\"event_type\"] == \"Create\" for e in events) != 1:\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Task {tid}: history must start with its only Create event\")\n\x20\x20\x20\x20for event in reversed(events):\n\x20\x20\x20\x20\x20\x20\x20\x20if event[\"event_type\"] == \"Undo\" and not event[\"undone\"]:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20events[positions[event[\"undo_target\"]]][\"undone\"] = True\n\x20\x20\x20\x20progress = 0\n\x20\x20\x20\x20for event, time in zip(events, normalized):\n\x20\x20\x20\x20\x20\x20\x20\x20if event[\"undone\"]:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20continue\n\x20\x20\x20\x20\x20\x20\x20\x20kind = event[\"event_type\"]\n\x20\x20\x20\x20\x20\x20\x20\x20if kind == \"Progress\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20new = event[\"percent_complete\"]\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if progress \u003c 100 and new == 100:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20state[\"completed_at_utc\"], state[\"completed_event_id\"] = time, event[\"event_id\"]\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20event[\"is_completion_transition\"] = True\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if new \u003c 100:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20state[\"completed_at_utc\"], state[\"completed_event_id\"] = \"\", \"\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20progress = new\n\x20\x20\x20\x20\x20\x20\x20\x20elif kind in (\"Delete\", \"Undelete\"):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20state[\"deleted\"] = kind == \"Delete\"\n\x20\x20\x20\x20state[\"progress_percent\"] = progress\n\x20\x20\x20\x20return events, state\n\ndef extract(path):\n\x20\x20\x20\x20events, tasks, task_ids = [], [], set()\n\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20with zipfile.ZipFile(path, \"r\") as archive:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20package = Package(archive)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20locations = package.comment_locations()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for source in package.task_parts():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20root = package.xml(source)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if root.tag != f\"{{{TASK_NS}}}Tasks\":\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Unexpected Tasks root/namespace in {source}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for task in root.findall(f\"{{{TASK_NS}}}Task\"):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20rows, state = parse_task(task, source, locations)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if state[\"task_id\"] in task_ids:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Duplicate task ID: {state['task_id']}\")\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20task_ids.add(state[\"task_id\"])\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20events.extend(rows)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20tasks.append(state)\n\x20\x20\x20\x20except (zipfile.BadZipFile, RuntimeError, NotImplementedError, OSError, KeyError) as error:\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Cannot read XLSX package: {error}\") from error\n\x20\x20\x20\x20return events, tasks\n\ndef csv_safe(value):\n\x20\x20\x20\x20if isinstance(value, str) and (re.match(r\"\\s*[=+@-]\", value) or value.startswith((\"\\t\", \"\\r\", \"\\n\"))):\n\x20\x20\x20\x20\x20\x20\x20\x20return \"'\" + value\n\x20\x20\x20\x20return value\n\ndef export(path, outdir):\n\x20\x20\x20\x20events, tasks = extract(path)\n\x20\x20\x20\x20destination = Path(outdir)\n\x20\x20\x20\x20names = (\"events.csv\", \"tasks.csv\", \"data.json\", \"summary.txt\")\n\x20\x20\x20\x20if destination.exists():\n\x20\x20\x20\x20\x20\x20\x20\x20raise ValueError(f\"Output directory already exists: {destination}; choose a new --out directory\")\n\x20\x20\x20\x20destination.mkdir(parents=True)\n\x20\x20\x20\x20summary = (f\"Tasks: {len(tasks)}\\nEvents: {len(events)}\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"Source workbook opened read-only. Only package-root-reachable task parts included.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"No completion time inferred from done flags, replies or last-edit timestamps.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"completed_at_utc is the current effective task progress transition to 100%; deletion is separate.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"undone reflects the final saved history, not every past UI state.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"is_completion_transition marks each effective progress change from below 100 to 100.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"Missing history/anchors are explicit; unmatched tasks can still have task event times.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"CSV formula-like text is prefixed with an apostrophe; data.json preserves original field values.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"Raw event datetime is preserved; timezone-free task times are interpreted as UTC per MS-OTASKXML.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"No task rows means no reachable stored task records, not that no comments were resolved.\\n\"\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"Limits: XML parts \u003c=32 MiB; encrypted files and malformed/ambiguous histories fail.\\n\")\n\x20\x20\x20\x20with tempfile.TemporaryDirectory(dir=destination) as temporary:\n\x20\x20\x20\x20\x20\x20\x20\x20temporary = Path(temporary)\n\x20\x20\x20\x20\x20\x20\x20\x20for name, fields, rows in ((\"events.csv\", EVENT_FIELDS, events), (\"tasks.csv\", TASK_FIELDS, tasks)):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20with (temporary / name).open(\"w\", newline=\"\", encoding=\"utf-8-sig\") as stream:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20writer = csv.DictWriter(stream, fieldnames=fields)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20writer.writeheader()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20writer.writerows({k: csv_safe(v) for k, v in row.items()} for row in rows)\n\x20\x20\x20\x20\x20\x20\x20\x20(temporary / \"data.json\").write_text(json.dumps({\"events\": events, \"tasks\": tasks}, ensure_ascii=False, indent=2), encoding=\"utf-8\")\n\x20\x20\x20\x20\x20\x20\x20\x20(temporary / \"summary.txt\").write_text(summary, encoding=\"utf-8\")\n\x20\x20\x20\x20\x20\x20\x20\x20for name in names:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20(temporary / name).replace(destination / name)\n\x20\x20\x20\x20return events, tasks\n\ndef main(argv=None):\n\x20\x20\x20\x20parser = argparse.ArgumentParser(description=__doc__)\n\x20\x20\x20\x20parser.add_argument(\"workbook\", help=\"Saved .xlsx workbook (read-only)\")\n\x20\x20\x20\x20parser.add_argument(\"--out\", required=True, help=\"New directory for the four output files\")\n\x20\x20\x20\x20args = parser.parse_args(argv)\n\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20events, tasks = export(args.workbook, args.out)\n\x20\x20\x20\x20except (ValueError, OSError) as error:\n\x20\x20\x20\x20\x20\x20\x20\x20parser.exit(2, f\"Error: {error}\\n\")\n\x20\x20\x20\x20print(f\"Exported {len(events)} events and {len(tasks)} tasks to {args.out}\")\n\x20\x20\x20\x20return 0\n\nif __name__ == \"__main__\":\n\x20\x20\x20\x20sys.exit(main())\n")

輸入檔案必須是你已經有權讀取的活頁簿。指令碼只會讀取該檔案,並寫出自己的報表。請將報表與原始已儲存副本放在一起,讓來源部分、任務及事件的識別碼,日後仍可用來核對結果。

4. 執行程式,輸出到新資料夾

將程式放在已儲存活頁簿所在的資料夾,或使用完整路徑。在該資料夾開啟終端機。如果系統以 python3 執行 Python3,請執行下方指令;在 Windows 上,如果你的 Python 啟動器是 py -3,請用它取代 python3。

python3 export_excel_task_events.py "review-copy.xlsx" --out "task-report"

每個已儲存的活頁簿版本,都請使用新的輸出資料夾名稱。等到顯示成功摘要後,才能將報表視為已完整產生。如果程式回報資料無效或不支援,請依錯誤訊息處理,不要把不完整或空白的結果當成匯出完成。

新資料夾中會有 events.csv、tasks.csv、data.json 與 summary.txt。兩個 CSV 檔案分別是事件表與任務表;summary.txt 提供數量與解讀限制。data.json 保留原始欄位字串,供程式處理使用。接下來會說明哪些日期有意義,以及如何區分日期缺失與任務未完成。

5. 複製日期前,先讀懂任務結果

先查看 tasks.csv。每列都列出 source_part 與 task_id;如果能建立註解關聯,也會顯示 anchor_id、sheet 與 cell。請先檢查 status:ok 表示已找到關聯。若為 missing_history、missing_anchor、unmatched_anchor 或 ambiguous_anchor,請先釐清,再將時間戳記歸屬到特定工作表註解。

對於成功建立關聯的任務,progress_percent 描述的是依序重播仍然有效的歷史紀錄後,得到的狀態。completed_at_utc 與 completed_event_id 指向讓任務進入目前完成狀態的有效 Progress 事件。如果後續仍然有效的進度值低於 100,目前完成時間的欄位就會被清空。如果期間沒有變回未完成,只是再次記錄 100,不會產生新的完成轉換。

deleted 是另一個獨立狀態。任務即使被標示為已刪除,仍可能保有完成歷史。不要略去這個旗標而不加說明,也不要將已刪除的任務呈現為仍然有效的指派。同樣地,目前完成時間欄位空白,並不表示這個任務過去從未完成。

在後續報表中,請將原始的來源部分、任務與事件識別碼保留在日期旁邊。這些識別碼能讓你將結果追溯回已儲存的套件,也能避免將不同任務歷史中重複使用的事件 ID 誤認為同一個事件。

6. 依 XML 順序檢查仍然有效的事件

events.csv 保留 ordinal、原始 time_utc_raw 字串與 event_id,也包含 event_type、事件執行者資訊,以及任務/註解參照。is_completion_transition=True 表示仍然有效、從低於 100 變為 100 的 Progress 變更。undone=True 標示已從最終有效歷史排除的紀錄;這些列仍會保留,方便核對。CSV 將這些布林值寫成 True/False;JSON 則使用 true/false。

請依 XML 序列判定,不要改成依時間戳記排序。Microsoft 規定,後續仍然有效的 Undo 可以排除較早的事件,而 Undo 本身也可以被復原。完成這些排除後,再依儲存順序重播其餘事件。最大的日期值或最後一則回覆,都不能取代這個過程。

以這組合成序列為例:Create;Progress 100;Progress 0;Progress 100;以 Undo 復原第二次完成;再以 Undo 復原該次 Undo。最終有效歷史會重新包含第二次完成,其 Progress 事件提供保留下來的完成事件時間。較晚發生的 Undo-of-Undo 時間,不會被重新標示為新的 Progress 完成時間。

這份報表描述的是已儲存檔案中,最終仍然有效的歷史紀錄。它不會重建介面可能曾顯示完成的每一個時刻,也不會將每一次進度降低都稱為按下 Reopen。這些是不同的主張。

7. 了解資料不可用與資料無效的結果

找不到可沿關聯存取的任務歷史:檔案可能只有一般註解、沒有指派任務,或是較舊的儲存狀態,也可能是匯出時省略了任務資料。如果有最新的原始 XLSX 副本,請檢查該副本。匯出程式無法從 done 旗標還原不存在的事件。

missing_history:任務存在,但可用的事件序列不存在或為空。missing_anchor:找不到可用的註解參照。unmatched_anchor:參照存在,但沿著活頁簿關聯找不到對應的註解執行緒。ambiguous_anchor:沒有任務層級的參照,而且各事件的參照不一致。未建立關聯的任務仍可能有事件時間;但這些時間無法指出它們屬於哪一則工作表註解。

如果缺少任務層級的錨點,而所有可用的事件錨點都一致,程式就會使用那個唯一參照建立關聯。否則,會保留缺失或有歧義的結果。不要靠比對註解文字或檔名中的數字,猜測它屬於哪張工作表。

事件資料無效是另一種錯誤。同一任務歷史內重複的 ID、未指向較早且允許復原之事件的 Undo、格式錯誤的時間戳記,或超出範圍的進度值,都不應產生猜測出來的日期。請保留副本與回報的錯誤。如果有其他有效的已儲存檔案,可改用該檔案;只為了通過檢查而手動編輯歷史,會改變證據本身。

如果歷史紀錄不可用,而未來工作需要可靠的完成紀錄,請將之後的完成時間記錄在明確的欄位,或你能掌控的另一套流程中。這樣做會建立新紀錄,並不會重建過去缺失的時間。

8. 以文字匯入,並核對匯出結果

將 CSV 匯入試算表時,請將識別碼與時間戳記欄位視為文字。這能保留儲存的字串,避免自動轉換成日期或數值。以引號包住的 CSV 欄位可能包含逗號或換行,因此請計算解析後的紀錄數,不要直接計算文字行數。

CSV 報表會在類似公式的文字前加上單引號,包括略過開頭空白後,以 =、+、- 或 @ 開始的文字。data.json 則保留未加前綴的原始欄位值。如果需要完全一致的原始文字,請使用這些 JSON 值;不要移除前綴後,再將不受信任的文字當成公式開啟。details_json 中的事件 XML 經過解析與重新序列化,因此命名空間前綴與 XML 格式,並不是原始位元組的完整複本。

將摘要中的任務/事件數量與解析後的表格比對。至少用一個已知任務,核對其工作表/儲存格與事件識別碼;使用日期前,也要檢查每個缺失或未配對狀態。篩選完成轉換時,請同時使用轉換旗標,以及該筆紀錄是否仍然有效/已被復原的狀態。

請一起保留原始已儲存活頁簿、所有報表檔案,以及實際使用的完整程式。如果活頁簿之後變更,請重新匯出到新資料夾,再比較兩份快照。不要覆寫先前的報表,卻仍將它描述為較早那份檔案的紀錄。

9. 已驗證的範圍

在 Python 3.12.14 上,一般形式的程式通過了 26 個合成 ZIP/活頁簿測試。另有一位獨立審查者執行 8 個另行撰寫的測試,其中包含針對 64 組產生的歷史紀錄,與遞迴 Undo 方法進行比較。上方的單行複製形式也已解碼還原為完全相同的一般原始碼,並再次通過 26 個案例的測試組合,包括命令列介面檢查;這些測試資料使用官方的文件任務關聯 URI。這些都是合成檔案測試,並非 Excel 圖形介面、Windows/macOS,或真實使用者活頁簿的測試。

檢查涵蓋依關聯對應工作表/註解、事件 ID 的有效範圍、無歷史紀錄及各種錨點結果、Undo-of-Undo、重複完成、之後進度變回未完成、刪除、CSV/JSON 文字處理、輸入檔案位元組不變,以及拒絕覆寫已存在的輸出資料夾。指令碼將每個讀取的 XML 部分限制為 32 MiB,將解析後的資料保留在記憶體中,且不支援加密的活頁簿套件。未以大型實際工作活頁簿進行容量測試。

Microsoft 的任務格式文件支持這些已儲存 ID、時間、進度與 Undo 的語義。CommentPulse 也獨立記載了自身現有匯出工具及任務資料方面的限制。這個程式與檢查流程將上述資料組合為一套有條件適用的本機擷取程序;它們不能證明每一套 Excel 安裝都會寫入相同歷史,也不能證明原始提問者缺少的事件確實存在於檔案中。

六個合成事件依 XML 順序排列:Create、Progress 100、Progress 0、Progress 100、Undo 事件 4,以及 Undo 事件 5。事件 5 被復原,讓事件 4 恢復生效。目前進度為 100%,保留下來的完成事件時間是事件 4 的 09:30 UTC。
使用合成的已儲存歷史資料製作的原創示意圖。復原 Undo 會讓事件 4 恢復生效。Progress 事件 2、3、4 仍然有效;目前進度為 100%,最近一次仍然有效的完成時間由事件 4 提供,為 09:30 UTC。09:50 UTC 的事件 6 是 Undo,因此完成事件時間仍為 09:30 UTC。 Watch at Ease