Tech · PRACTICAL GUIDE
Export Excel Task Completion Dates From Saved Workbook History
If your XLSX retains assigned-task history, you can extract its effective completion events and UTC times. This local exporter also tells you when a file cannot supply that information.

Excel comment exports can miss an entry labelled Completed Task. A comment's creation time, the last reply time and the time a task reached completion answer different questions. Substituting one for another can put a plausible but wrong date in your report.
A saved XLSX can contain a separate document-task history. The program below checks that saved data, connects tasks to threaded-comment IDs and exports the events it can verify. If the necessary history is absent, the result says so. It does not invent a resolution date from a comment's done flag or promise that every Excel version or exporter retains task history.
1. Check which kind of date you need
Use this guide when a comment or replies export leaves out an assigned task's completion information and you want to inspect the saved workbook. If you only need the text, authors, replies and open/resolved status of ordinary comments, an existing exporter such as CommentPulse may already provide the table you need.
Microsoft defines CommentThreaded.Date as the local time the comment was added. The two answers to the original Stack Overflow question either use the last reply as a proxy or examine the ordinary threaded-comment XML and find a done flag. A last reply can precede a later completion action. Neither result establishes the time of that later task event.
The method here has a narrower input: an actual document task and its saved History. A completed ordinary comment without that data has no recoverable task completion time through this method. A task timestamp is also not proof of a particular UI button click or a complete account-wide audit trail.
2. Keep a saved workbook copy
Save or download a fresh copy of the workbook you are authorised to read, in XLSX format. Let that save finish before running the exporter. Keep the original and inspect the copy. The output reflects the data in that file; it cannot include a later cloud change that was never saved into the copy.
Do not convert the file through another spreadsheet service just to prepare it for this check: the available history is whatever the resulting file actually preserves. An old export, a file without assigned tasks or a producer that omitted task history can legitimately yield no usable task record.
The program reads a ZIP-based XLSX package and follows its internal relationships. Sheet names and comment parts are linked by those relationships, not by guessing that sheet1 belongs to threadedComment1. It never sends the workbook to a website or calls your Microsoft account.
3. Save the complete local exporter
Use Python 3.12 or later and a plain-text editor. Save the complete program below as export_excel_task_events.py. It uses the Python standard library, so this procedure does not require a pip package, API token or an Excel macro.
The following copy block is a single-line form of the complete program. Its escaped line breaks and indentation preserve Python's original layout when copied from this page. Save it as text exactly as shown; visual wrapping in your editor is fine. Do not add quotation marks around the block.
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")
The input is a workbook you already have permission to read. The script only reads that file and writes its own reports. Keep those reports with the original saved copy so the source-part, task and event identifiers remain useful for checking the result.
4. Run it into a new output folder
Place the program beside your saved workbook, or use full paths. Open a terminal in that folder. On a system where Python3 is available as python3, run the following command; on Windows, use py -3 in place of python3 if that is your Python launcher.
python3 export_excel_task_events.py "review-copy.xlsx" --out "task-report"
Use a new output-folder name for each saved workbook version. Wait for the success summary before treating the reports as complete. If the program reports invalid or unsupported data, follow that error instead of treating a partial or empty result as a completed export.
The new folder contains events.csv, tasks.csv, data.json and summary.txt. The two CSV files are the event and task tables; summary.txt gives the counts and interpretation limits. data.json retains the original field strings for machine use. The next sections explain which dates are meaningful and how to distinguish a missing date from an incomplete task.
5. Read the task result before copying a date
Start with tasks.csv. Each row identifies its source_part and task_id, and shows anchor_id, sheet and cell when the comment association is available. Check status first: ok means an association was found. missing_history, missing_anchor, unmatched_anchor or ambiguous_anchor needs attention before you attach a timestamp to a particular worksheet comment.
For a successfully linked task, progress_percent describes the state obtained by replaying its effective history. completed_at_utc and completed_event_id identify the effective Progress event that began its current completed state. A later effective progress value below 100 clears that current-completion field. Repeating 100 without becoming incomplete does not create a new completion transition.
deleted is separate. A task can retain completion history while being marked deleted. Do not silently discard that flag or present a deleted task as an active assignment. Likewise, a blank current-completion field is not a statement that the task was never completed earlier.
Keep the raw source-part, task and event identifiers beside the date in downstream reports. Those identifiers let you trace a result back to the saved package, and prevent an event ID reused in a different task history from being treated as the same event.
6. Inspect the effective events in XML order
events.csv preserves ordinal, the original time_utc_raw string and event_id, alongside event_type, actor attribution and task/comment references. is_completion_transition=True identifies an effective Progress change from below 100 to 100. undone=True marks records excluded from the final effective history; those rows remain visible for checking. The CSV writes these booleans as True/False; JSON uses true/false.
Evaluate the XML sequence, not a sort by timestamp. Microsoft specifies that a later effective Undo can exclude an earlier event, and that an Undo can itself be undone. After those exclusions, the remaining events are replayed in their stored order. A maximum date or the last reply cannot replace that process.
Consider a synthetic sequence: Create; Progress 100; Progress 0; Progress 100; Undo the second completion; Undo that Undo. The final effective history includes the second completion again. Its Progress event supplies the retained completion-event time. The later Undo-of-Undo time is not relabelled as a new Progress completion.
This report describes the final effective history in the saved file. It does not reconstruct every moment when the UI might once have displayed complete, and it does not name every reduction in progress a click on Reopen. Those are different claims.
7. Understand unavailable and invalid results
No reachable task history: the file may contain ordinary comments, no assigned tasks, an older saved state or an export that omitted task data. Inspect a current original XLSX copy if one exists. The exporter cannot recover absent events from a done flag.
missing_history: a task is present but its usable event sequence is absent or empty. missing_anchor: no usable comment reference was found. unmatched_anchor: a reference exists but no corresponding threaded comment was found through the workbook relationships. ambiguous_anchor: there is no task-level reference and event references disagree. An unlinked task can still have event times; that does not identify which worksheet comment they belong to.
When a task-level anchor is absent and all available event anchors agree, this program uses that single reference for association. Otherwise it retains the missing or ambiguous result. Do not guess a worksheet by matching comment text or filename numbers.
Invalid event data is a separate error. Duplicate IDs within the same task history, an Undo that does not target an earlier permitted event, a malformed timestamp or an out-of-range progress value must not produce a guessed date. Keep the copy and the reported error. Obtain another valid saved file if available; hand-editing the history merely to pass the check would change the evidence.
If the history is unavailable and you need a dependable completion log for future work, record future completion times in an explicit field or another workflow you control. That creates new records; it does not reconstruct missing past times.
8. Import as text and check the export
Import the CSV files into your spreadsheet with identifier and timestamp columns treated as text. This keeps the stored strings visible without automatic date or numeric conversion. A quoted CSV field may contain a comma or a line break, so count parsed records rather than physical text lines.
The CSV reports prefix formula-like text with an apostrophe, including leading =, +, - or @ after whitespace. data.json retains the unprefixed original field values. Use those JSON values when exact original text matters; do not remove the prefix and then open untrusted text as a formula. Within details_json, the event XML is parsed and reserialized, so namespace prefixes and XML formatting are not original-byte copies.
Compare the task/event counts in the summary with the parsed tables. Check at least one known task against its sheet/cell and event identifiers, then check every missing or unmatched status before using the dates. Filter completion transitions using both the transition flag and the record's effective/undone status.
Keep the original saved workbook, all report files and the exact program together. If the workbook later changes, run a new export into a new folder and compare the two snapshots. Do not overwrite an earlier report and then describe it as the record from the earlier file.
9. What was verified
On Python 3.12.14, the normal program passed 26 synthetic ZIP/workbook tests. An independent reviewer ran 8 separately written tests, including a comparison against a recursive Undo method across 64 generated histories. The single-line copy form above was decoded back to the exact normal source and passed the 26-case suite again, including its CLI check; those fixtures used the official document-task relationship URI. These are synthetic file tests, not Excel GUI, Windows/macOS or real user-workbook tests.
Checks covered relationship-based sheet/comment mapping, event-ID scope, no-history and anchor outcomes, Undo-of-Undo, repeated completion, later incomplete progress, deletion, CSV/JSON text handling, unchanged input bytes and refusal to overwrite an existing output folder. The script limits each XML part it reads to 32 MiB, keeps parsed data in memory and does not support encrypted workbook packages. No capacity test on a large production workbook was performed.
Microsoft's task-format documentation supports the stored ID, time, progress and Undo semantics. CommentPulse independently documents its own existing exporter and its task-data limitation. The program and its checks assemble those pieces into a conditional local extraction process; they do not establish that every Excel installation writes the same history or that the original questioner's missing events are present.
