diff --git a/README.md b/README.md index f559b24..bea46b6 100644 --- a/README.md +++ b/README.md @@ -252,10 +252,10 @@ PDF reports (`--pdf`) are an optional extra — install with `pip install 'maigr ### Examples ```bash -# make HTML, PDF, and Xmind8 reports +# make HTML, PDF, and XMind reports maigret user --html maigret user --pdf -maigret user --xmind #Output not compatible with xmind 2022+ +maigret user --xmind # legacy XML with a manifest for XMind 2022+ readers # machine-readable exports maigret user --json ndjson # newline-delimited JSON (also: --json simple) diff --git a/README.zh-CN.md b/README.zh-CN.md index 57bd532..2a3aed7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -256,10 +256,10 @@ PDF 报告(`--pdf`)是可选扩展 —— 通过 `pip install 'maigret[pdf]'` ### 示例 ```bash -# 生成 HTML、PDF、XMind 8 报告 +# 生成 HTML、PDF 和 XMind 报告 maigret user --html maigret user --pdf -maigret user --xmind # 与 XMind 2022+ 不兼容 +maigret user --xmind # 传统 XML 格式,并包含 XMind 2022+ 阅读器所需的清单 # 机器可读的导出格式 maigret user --json ndjson # 行分隔 JSON(也支持 --json simple) diff --git a/docs/source/command-line-options.rst b/docs/source/command-line-options.rst index 2b68e88..40aac5a 100644 --- a/docs/source/command-line-options.rst +++ b/docs/source/command-line-options.rst @@ -191,8 +191,8 @@ usernames). ``-H``, ``--html`` - Generate an HTML report file (general report on all usernames). -``-X``, ``--xmind`` - Generate an XMind 8 mindmap (one report per -username). +``-X``, ``--xmind`` - Generate a legacy XML XMind mindmap with a manifest for +modern readers (one report per username). ``-C``, ``--csv`` - Generate a CSV report (one report per username). diff --git a/docs/source/locale/zh_CN/LC_MESSAGES/command-line-options.po b/docs/source/locale/zh_CN/LC_MESSAGES/command-line-options.po index e114d8f..af8c0a7 100644 --- a/docs/source/locale/zh_CN/LC_MESSAGES/command-line-options.po +++ b/docs/source/locale/zh_CN/LC_MESSAGES/command-line-options.po @@ -380,11 +380,11 @@ msgid "" "usernames)." msgstr "``-H``\\ 、\\ ``--html`` —— 生成 HTML 报告文件(覆盖所有用户名的整体报告)。" -#: ../../source/command-line-options.rst:193 fea6b4f5114d4fe98b73734d509805a1 +#: ../../source/command-line-options.rst:194 fea6b4f5114d4fe98b73734d509805a1 msgid "" -"``-X``, ``--xmind`` - Generate an XMind 8 mindmap (one report per " -"username)." -msgstr "``-X``\\ 、\\ ``--xmind`` —— 生成 XMind 8 思维导图(每个用户名生成一份)。" +"``-X``, ``--xmind`` - Generate a legacy XML XMind mindmap with a manifest" +" for modern readers (one report per username)." +msgstr "``-X``\\ 、\\ ``--xmind`` —— 生成包含新版阅读器所需清单的传统 XML XMind 思维导图(每个用户名生成一份)。" #: ../../source/command-line-options.rst:196 54fae6515d004e25b4d634d4047b2c7e msgid "``-C``, ``--csv`` - Generate a CSV report (one report per username)." diff --git a/maigret/maigret.py b/maigret/maigret.py index eb41024..607811d 100755 --- a/maigret/maigret.py +++ b/maigret/maigret.py @@ -496,7 +496,10 @@ def setup_arguments_parser(settings: Settings): action="store_true", dest="xmind", default=settings.xmind_report, - help="Generate an XMind 8 mindmap report (one report per username).", + help=( + "Generate a legacy XML XMind mindmap with a manifest for modern " + "readers (one report per username)." + ), ) report_group.add_argument( "-P", diff --git a/maigret/report.py b/maigret/report.py index cbea7c3..9e10dd2 100644 --- a/maigret/report.py +++ b/maigret/report.py @@ -6,9 +6,12 @@ import json import logging import os import socket +import tempfile +import zipfile from datetime import datetime from typing import Dict, Any from urllib.parse import urlparse +from xml.etree import ElementTree import xmind # type: ignore[import-untyped] from dateutil.tz import gettz @@ -798,10 +801,112 @@ def generate_json_report(username: str, results: dict, file, report_type): """ -XMIND 8 Functions +XMIND Functions """ +_XMIND_MANIFEST_PATH = "META-INF/manifest.xml" +_XMIND_MANIFEST_NAMESPACE = "urn:xmind:xmap:xmlns:manifest:1.0" +_XMIND_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) + + +def _xmind_member_media_type(name): + if name.endswith('/'): + return '' + if name.endswith('.xml'): + return 'text/xml' + if name.endswith('.json'): + return 'application/json' + return 'application/octet-stream' + + +def _build_xmind_manifest(member_names): + ElementTree.register_namespace('', _XMIND_MANIFEST_NAMESPACE) + root = ElementTree.Element( + f'{{{_XMIND_MANIFEST_NAMESPACE}}}manifest', {'password-hint': ''} + ) + for name in member_names: + ElementTree.SubElement( + root, + f'{{{_XMIND_MANIFEST_NAMESPACE}}}file-entry', + { + 'full-path': name, + 'media-type': _xmind_member_media_type(name), + }, + ) + return ElementTree.tostring(root, encoding='utf-8', xml_declaration=True) + + +def _validate_xmind_archive(filename, expected_names): + with zipfile.ZipFile(filename) as archive: + invalid_member = archive.testzip() + if invalid_member is not None: + raise ValueError(f'Invalid XMind ZIP member: {invalid_member}') + + names = archive.namelist() + if names != expected_names: + raise ValueError('Rewritten XMind archive has unexpected members') + if names.count(_XMIND_MANIFEST_PATH) != 1: + raise ValueError('Rewritten XMind archive must contain one manifest') + + manifest = ElementTree.fromstring(archive.read(_XMIND_MANIFEST_PATH)) + entry_tag = f'{{{_XMIND_MANIFEST_NAMESPACE}}}file-entry' + manifest_names = [ + entry.attrib.get('full-path') for entry in manifest.findall(entry_tag) + ] + if manifest_names != expected_names: + raise ValueError('XMind manifest does not list every archive member') + + +def _normalize_xmind_archive(filename): + """Atomically add the manifest required by current XMind readers.""" + archive_path = os.fspath(filename) + archive_dir = os.path.dirname(os.path.abspath(archive_path)) + archive_mode = os.stat(archive_path).st_mode & 0o7777 + temporary_fd, temporary_path = tempfile.mkstemp( + prefix=f'.{os.path.basename(archive_path)}.', + suffix='.tmp', + dir=archive_dir, + ) + os.close(temporary_fd) + + try: + with zipfile.ZipFile(archive_path) as source: + members = [ + member + for member in source.infolist() + if member.filename != _XMIND_MANIFEST_PATH + ] + expected_names = [member.filename for member in members] + expected_names.append(_XMIND_MANIFEST_PATH) + manifest = _build_xmind_manifest(expected_names) + + with zipfile.ZipFile(temporary_path, mode='w') as target: + target.comment = source.comment + for member in members: + target.writestr(member, source.read(member)) + + manifest_info = zipfile.ZipInfo( + _XMIND_MANIFEST_PATH, date_time=_XMIND_ZIP_EPOCH + ) + manifest_info.compress_type = zipfile.ZIP_DEFLATED + manifest_info.create_system = 3 + manifest_info.external_attr = 0o100644 << 16 + target.writestr(manifest_info, manifest) + + os.chmod(temporary_path, archive_mode) + _validate_xmind_archive(temporary_path, expected_names) + with open(temporary_path, 'rb') as temporary_file: + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, archive_path) + except BaseException: + try: + os.unlink(temporary_path) + except FileNotFoundError: + pass + raise + + def save_xmind_report(filename, username, results): if os.path.exists(filename): os.remove(filename) @@ -809,6 +914,7 @@ def save_xmind_report(filename, username, results): sheet = workbook.getPrimarySheet() design_xmind_sheet(sheet, username, results) xmind.save(workbook, path=filename) + _normalize_xmind_archive(filename) def add_xmind_subtopic(userlink, k, v, supposed_data): diff --git a/tests/test_report.py b/tests/test_report.py index 10c6d71..52d5e31 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -6,8 +6,10 @@ import os import subprocess import sys import textwrap +import zipfile import pytest from io import StringIO +from xml.etree import ElementTree import xmind # type: ignore[import-untyped] from jinja2 import Template @@ -34,12 +36,12 @@ from maigret.report import ( _is_safe_report_image_url, _pdf_report_link_callback, _BLANK_IMAGE_PATH, + _normalize_xmind_archive, ) from maigret.errors import CheckError from maigret.result import MaigretCheckResult, MaigretCheckStatus from maigret.sites import MaigretSite - GOOD_RESULT = MaigretCheckResult('', '', '', MaigretCheckStatus.CLAIMED) BAD_RESULT = MaigretCheckResult('', '', '', MaigretCheckStatus.AVAILABLE) @@ -457,6 +459,125 @@ def test_save_xmind_report(): ) +def test_xmind_report_has_complete_manifest_and_valid_zip(tmp_path): + filename = tmp_path / 'unicode-report.xmind' + + save_xmind_report(filename, '测试-Élodie', EXAMPLE_RESULTS) + + with zipfile.ZipFile(filename) as archive: + assert archive.testzip() is None + names = archive.namelist() + assert names.count('META-INF/manifest.xml') == 1 + manifest = ElementTree.fromstring(archive.read('META-INF/manifest.xml')) + + manifest_namespace = 'urn:xmind:xmap:xmlns:manifest:1.0' + assert manifest.tag == f'{{{manifest_namespace}}}manifest' + assert manifest.attrib == {'password-hint': ''} + namespace = {'manifest': manifest_namespace} + entries = manifest.findall('manifest:file-entry', namespace) + assert [entry.attrib['full-path'] for entry in entries] == names + assert all('media-type' in entry.attrib for entry in entries) + assert all( + entry.attrib['media-type'] == 'text/xml' + for entry in entries + if entry.attrib['full-path'].endswith('.xml') + ) + + workbook = xmind.load(str(filename)) + data = workbook.getPrimarySheet().getData() + assert data['title'] == '测试-Élodie Analysis' + assert data['topic']['title'] == '测试-Élodie' + assert data['topic']['topics'][1]['title'] == 'test_tag' + + +def test_xmind_normalization_is_idempotent_and_preserves_members(tmp_path): + filename = tmp_path / 'report.xmind' + save_xmind_report(filename, 'test', EXAMPLE_RESULTS) + with zipfile.ZipFile(filename, mode='a') as archive: + archive.comment = b'Maigret XMind archive' + + with zipfile.ZipFile(filename) as archive: + original_comment = archive.comment + original_members = [ + ( + archive.read(info), + info.filename, + info.compress_type, + info.date_time, + info.comment, + info.extra, + info.create_system, + info.create_version, + info.extract_version, + info.internal_attr, + info.external_attr, + info.flag_bits, + ) + for info in archive.infolist() + if info.filename != 'META-INF/manifest.xml' + ] + + _normalize_xmind_archive(filename) + normalized_once = filename.read_bytes() + _normalize_xmind_archive(filename) + + with zipfile.ZipFile(filename) as archive: + assert archive.testzip() is None + assert archive.namelist().count('META-INF/manifest.xml') == 1 + normalized_comment = archive.comment + normalized_members = [ + ( + archive.read(info), + info.filename, + info.compress_type, + info.date_time, + info.comment, + info.extra, + info.create_system, + info.create_version, + info.extract_version, + info.internal_attr, + info.external_attr, + info.flag_bits, + ) + for info in archive.infolist() + if info.filename != 'META-INF/manifest.xml' + ] + + assert normalized_comment == original_comment + assert normalized_members == original_members + assert filename.read_bytes() == normalized_once + + +def test_xmind_report_regeneration_drops_obsolete_archive_members(tmp_path): + filename = tmp_path / 'report.xmind' + save_xmind_report(filename, 'first', EXAMPLE_RESULTS) + with zipfile.ZipFile(filename, mode='a') as archive: + archive.writestr('obsolete.txt', b'stale report data') + + save_xmind_report(filename, 'second', EXAMPLE_RESULTS) + + with zipfile.ZipFile(filename) as archive: + assert 'obsolete.txt' not in archive.namelist() + assert archive.testzip() is None + workbook = xmind.load(str(filename)) + assert workbook.getPrimarySheet().getData()['topic']['title'] == 'second' + + +def test_xmind_normalization_failure_is_atomic(tmp_path, monkeypatch): + filename = tmp_path / 'report.xmind' + save_xmind_report(filename, 'test', EXAMPLE_RESULTS) + original = filename.read_bytes() + + monkeypatch.setattr(zipfile.ZipFile, 'testzip', lambda self: 'content.xml') + + with pytest.raises(ValueError, match='content.xml'): + _normalize_xmind_archive(filename) + + assert filename.read_bytes() == original + assert list(tmp_path.iterdir()) == [filename] + + def test_save_xmind_report_broken(): filename = 'report_test.xmind' save_xmind_report(filename, 'test', BROKEN_RESULTS)