Fix XMind reports for modern readers (#2930)

* fix(report): add XMind manifest for modern readers

* docs: document modern XMind reader compatibility
This commit is contained in:
Julio César Suástegui
2026-08-10 22:54:35 +02:00
committed by GitHub
parent e468267146
commit 34a1669974
7 changed files with 243 additions and 13 deletions
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -256,10 +256,10 @@ PDF 报告(`--pdf`)是可选扩展 —— 通过 `pip install 'maigret[pdf]'`
### 示例
```bash
# 生成 HTML、PDFXMind 8 报告
# 生成 HTML、PDFXMind 报告
maigret user --html
maigret user --pdf
maigret user --xmind # XMind 2022+ 不兼容
maigret user --xmind # 传统 XML 格式,并包含 XMind 2022+ 阅读器所需的清单
# 机器可读的导出格式
maigret user --json ndjson # 行分隔 JSON(也支持 --json simple)
+2 -2
View File
@@ -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).
@@ -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)."
+4 -1
View File
@@ -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",
+107 -1
View File
@@ -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):
+122 -1
View File
@@ -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)