mirror of
https://github.com/soxoj/maigret.git
synced 2026-09-08 02:47:41 +02:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user