Watch at Ease세상을 읽고, 일상의 답을 찾다.

기술 · 실용 가이드

저장된 통합 문서 기록에서 Excel 작업 완료 시각 내보내기

XLSX 파일에 할당된 작업의 기록이 남아 있다면, 현재 유효한 완료 이벤트와 UTC 시각을 추출할 수 있습니다. 이 로컬 내보내기 프로그램은 파일에서 해당 정보를 얻을 수 없는 경우도 알려 줍니다.

세 카드가 메모 추가 시각, 메모 스레드의 해결 상태, UTC 시각이 있는 문서 작업 이벤트를 구분합니다. 저장된 유효한 Progress 값이 100 미만에서 100으로 바뀌는 전환에서 완료 이벤트 시각을 얻을 수 있습니다. 작업 기록이 없으면 해당 시각을 확인할 수 없습니다.
직접 제작한 설명 일러스트입니다. CommentThreaded.Date는 메모가 추가된 시각을 기록하고, done 플래그는 메모 스레드의 해결 상태를 기록합니다. 저장된 작업 완료 시각을 얻으려면 작업 기록에 유효한 Progress 값이 100 미만에서 100으로 바뀌는 전환이 있어야 합니다. Watch at Ease

Excel 메모를 내보낼 때 Completed Task(완료된 작업)라는 항목이 빠질 수 있습니다. 메모를 작성한 시각, 마지막 답글 시각, 작업이 완료된 시각은 서로 다른 정보를 나타냅니다. 하나를 다른 것 대신 사용하면 그럴듯하지만 잘못된 날짜가 보고서에 들어갈 수 있습니다.

저장된 XLSX 파일에는 별도의 문서 작업 기록이 들어 있을 수 있습니다. 아래 프로그램은 저장된 데이터를 확인하고, 작업을 스레드형 메모 ID와 연결한 뒤 확인 가능한 이벤트를 내보냅니다. 필요한 기록이 없으면 결과에 그 사실을 표시합니다. 메모의 done 플래그로 해결 시각을 만들어 내거나, 모든 Excel 버전과 내보내기 도구가 작업 기록을 보존한다고 보장하지 않습니다.

1. 필요한 시각이 무엇인지 확인하기

메모나 답글을 내보낸 결과에 할당된 작업의 완료 정보가 빠져 있고, 저장된 통합 문서를 직접 확인하려는 경우에 이 가이드를 사용하세요. 일반 메모의 본문, 작성자, 답글, 미해결/해결됨 상태만 필요하다면 CommentPulse 같은 기존 내보내기 도구로 원하는 표를 얻을 수도 있습니다.

Microsoft는 CommentThreaded.Date를 메모가 추가된 현지 시각으로 정의합니다. 원래 Stack Overflow 질문에 달린 두 답변은 마지막 답글을 대용값으로 사용하거나, 일반 스레드형 메모의 XML을 살펴 done 플래그를 확인합니다. 마지막 답글을 남긴 뒤 나중에 작업을 완료할 수도 있습니다. 어느 방법도 그 이후에 발생한 작업 이벤트의 시각을 입증하지는 못합니다.

이 방법에는 더 구체적인 입력 데이터가 필요합니다. 실제 문서 작업과 그 작업에 저장된 History가 있어야 합니다. 일반 메모가 해결됨으로 표시되어 있어도 이 데이터가 없으면 이 방법으로 작업 완료 시각을 복구할 수 없습니다. 또한 작업의 타임스탬프만으로 특정 UI 버튼을 클릭했다거나 계정 전체의 감사 기록이 완전하다고 입증할 수는 없습니다.

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. 새 출력 폴더로 내보내기

프로그램을 저장한 통합 문서와 같은 폴더에 두거나 전체 경로를 사용하세요. 해당 폴더에서 터미널을 엽니다. Python 3을 python3 명령으로 실행할 수 있는 시스템에서는 다음 명령을 실행하세요. Windows에서 Python 실행기로 py -3을 사용한다면 python3 대신 py -3을 입력하세요.

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는 유효한 Progress 값이 100 미만에서 100으로 바뀌었음을 나타냅니다. undone=True는 최종 유효 기록에서 제외된 이벤트를 뜻하며, 확인할 수 있도록 해당 행도 남겨 둡니다. CSV에는 이 불리언 값이 True/False로, JSON에는 true/false로 기록됩니다.

타임스탬프로 정렬한 순서가 아니라 XML에 저장된 순서로 평가하세요. Microsoft 규정에 따르면 나중에 나온 유효한 Undo가 이전 이벤트를 제외할 수 있고, Undo 자체도 다시 취소할 수 있습니다. 이 제외 처리를 마친 뒤 남은 이벤트를 저장된 순서대로 재생합니다. 가장 늦은 시각이나 마지막 답글로 이 과정을 대신할 수는 없습니다.

합성 예시로 Create, Progress 100, Progress 0, Progress 100, 두 번째 완료를 대상으로 한 Undo, 그 Undo를 대상으로 한 Undo가 차례로 있다고 가정해 보세요. 최종 유효 기록에는 두 번째 완료가 다시 포함됩니다. 유지되는 완료 이벤트 시각은 그 Progress 이벤트의 시각입니다. 나중에 Undo를 취소한 시각을 새로운 Progress 완료 시각으로 바꾸어 표시하지 않습니다.

이 보고서는 저장된 파일의 최종 유효 기록을 설명합니다. UI에 한때 완료로 표시되었을 수 있는 모든 순간을 재구성하지 않으며, 진행률이 낮아진 모든 경우를 Reopen 버튼 클릭이라고 부르지도 않습니다. 그런 사실을 확인하려면 다른 근거가 필요합니다.

7. 확인할 수 없는 결과와 잘못된 결과 이해하기

관계를 따라 접근할 수 있는 작업 기록이 없는 경우: 파일에 일반 메모만 있거나, 할당된 작업이 없거나, 이전 저장 상태가 들어 있거나, 내보내기 과정에서 작업 데이터가 빠졌을 수 있습니다. 최신 원본 XLSX 사본이 있다면 그것을 확인하세요. 내보내기 프로그램은 done 플래그에서 누락된 이벤트를 복구할 수 없습니다.

missing_history는 작업은 있지만 사용할 수 있는 이벤트 시퀀스가 없거나 비어 있다는 뜻입니다. missing_anchor는 사용할 수 있는 메모 참조를 찾지 못했다는 뜻입니다. unmatched_anchor는 참조는 있지만 통합 문서 관계를 따라가도 대응하는 스레드형 메모를 찾지 못했다는 뜻입니다. ambiguous_anchor는 작업 수준의 참조가 없고 이벤트별 참조가 서로 다르다는 뜻입니다. 연결되지 않은 작업에도 이벤트 시각은 있을 수 있지만, 그 시각이 어느 워크시트 메모에 속하는지는 알 수 없습니다.

작업 수준의 anchor가 없고 확인 가능한 이벤트 anchor가 모두 일치하면 프로그램은 그 하나의 참조를 사용해 연결합니다. 그렇지 않으면 누락 또는 모호함을 나타내는 결과를 유지합니다. 메모 본문이나 파일 이름의 번호를 맞춰 워크시트를 추측하지 마세요.

잘못된 이벤트 데이터는 별도의 오류입니다. 같은 작업 기록 안에서 ID가 중복되거나, Undo가 허용되는 이전 이벤트를 가리키지 않거나, 타임스탬프 형식이 잘못되었거나, 진행률이 범위를 벗어난 경우에 추측한 날짜를 출력해서는 안 됩니다. 사본과 오류 메시지를 보관하세요. 가능하다면 유효하게 저장된 다른 파일을 확보하세요. 검사를 통과시키기 위해 기록을 직접 편집하면 근거 자체가 바뀝니다.

기록을 확인할 수 없고 앞으로의 작업에 신뢰할 수 있는 완료 기록이 필요하다면, 향후 완료 시각을 명시적인 필드나 직접 관리하는 다른 작업 절차에 기록하세요. 이것은 새로운 기록을 만드는 방법이며, 누락된 과거 시각을 재구성하는 방법은 아닙니다.

8. 텍스트로 가져온 뒤 내보내기 결과 확인하기

CSV 파일을 스프레드시트로 가져올 때 식별자와 타임스탬프 열을 텍스트로 처리하세요. 그러면 날짜나 숫자로 자동 변환되지 않고 저장된 문자열을 그대로 확인할 수 있습니다. 따옴표로 묶인 CSV 필드에는 쉼표나 줄바꿈이 들어갈 수 있으므로, 텍스트 파일의 물리적인 줄 수 대신 파싱한 레코드 수를 세세요.

CSV 보고서는 수식처럼 보이는 텍스트 앞에 작은따옴표를 붙입니다. 공백 뒤 첫 문자가 =, +, - 또는 @인 경우도 포함합니다. data.json은 이 접두 문자를 붙이지 않은 원래 필드 값을 보존합니다. 원문이 정확히 필요할 때는 JSON 값을 사용하세요. 접두 문자를 제거한 뒤 신뢰할 수 없는 텍스트를 수식으로 열지 마세요. details_json 안의 이벤트 XML은 파싱 후 다시 직렬화되므로, 네임스페이스 접두사와 XML 서식은 원본 바이트 그대로의 복사본이 아닙니다.

요약에 나온 작업 및 이벤트 개수를 파싱한 표와 비교하세요. 알고 있는 작업을 적어도 하나 골라 sheet/cell과 이벤트 식별자가 맞는지 확인한 뒤, 날짜를 사용하기 전에 모든 누락 또는 미일치 status를 확인하세요. 완료 전환을 필터링할 때는 전환 플래그와 해당 레코드의 유효 여부 또는 undone 상태를 함께 사용하세요.

확인에 사용한 원래 저장 통합 문서, 모든 보고서 파일, 정확히 같은 프로그램을 함께 보관하세요. 통합 문서가 나중에 바뀌면 새 폴더로 다시 내보내고 두 시점의 결과를 비교하세요. 이전 보고서를 덮어쓴 뒤 이전 파일에서 얻은 기록이라고 설명하지 마세요.

9. 검증한 내용

Python 3.12.14에서 일반 형태의 프로그램은 합성 ZIP/통합 문서 테스트 26개를 통과했습니다. 독립 검토자는 별도로 작성한 테스트 8개를 실행했으며, 여기에는 생성한 기록 64개를 재귀적 Undo 방식과 비교하는 검사가 포함됩니다. 위의 한 줄 복사용 형태를 디코딩했을 때 일반 소스와 정확히 일치했고, CLI 검사를 포함한 같은 26개 테스트를 다시 통과했습니다. 이 테스트 파일들은 공식 문서 작업 관계 URI를 사용했습니다. 이는 합성 파일 테스트이며, Excel GUI, Windows/macOS 또는 실제 사용자 통합 문서 테스트는 아닙니다.

검사는 관계를 통한 시트/메모 연결, 이벤트 ID의 유효 범위, 기록이 없는 경우와 anchor 관련 결과, Undo의 Undo, 반복 완료, 이후의 미완료 진행률, 삭제, CSV/JSON 텍스트 처리, 입력 바이트 불변, 기존 출력 폴더 덮어쓰기 거부를 다뤘습니다. 스크립트는 읽는 XML 구성 요소 하나당 크기를 32 MiB로 제한하고 파싱한 데이터를 메모리에 유지하며, 암호화된 통합 문서 패키지는 지원하지 않습니다. 대용량 실무 통합 문서에 대한 용량 테스트는 수행하지 않았습니다.

Microsoft의 작업 형식 문서는 저장된 ID, 시각, 진행률, Undo의 의미를 뒷받침합니다. CommentPulse는 별도로 자체 내보내기 도구와 그 도구의 작업 데이터 관련 한계를 문서화하고 있습니다. 이 프로그램과 검증은 이러한 내용을 조합하여 조건이 맞을 때 로컬에서 데이터를 추출하는 절차를 제공합니다. 모든 Excel 설치 환경이 같은 기록을 저장한다거나, 원래 질문자가 찾지 못한 이벤트가 실제 파일에 남아 있다는 사실까지 입증하지는 않습니다.

XML 순서의 합성 이벤트 6개는 Create, Progress 100, Progress 0, Progress 100, 이벤트 4를 대상으로 한 Undo, 이벤트 5를 대상으로 한 Undo입니다. 이벤트 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