From f42787355e34a67ae24b8370404678b398a42aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Alan=20Ramos=20Rodr=C3=ADguez?= Date: Mon, 24 Aug 2026 11:34:09 -0600 Subject: [PATCH] [ADD] attachment_preview_xml: preview XML attachments as a tree Odoo stores XML attachments as text/plain for users without write access on views, so the standard viewer renders them as raw text. Machine-generated XML (CFDI, UBL, EDI) comes in a single line, which makes that preview unreadable. Render XML attachments client-side as an indented, collapsible tree with highlighted tags, attributes and values. No server-side conversion and no extra software involved. Malformed or oversized files fall back to raw text. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NYJ2yGZfDKKKEW3KxuG2wh --- attachment_preview_xml/README.rst | 103 ++++ attachment_preview_xml/__init__.py | 0 attachment_preview_xml/__manifest__.py | 26 + attachment_preview_xml/pyproject.toml | 3 + attachment_preview_xml/readme/CONTRIBUTORS.md | 1 + attachment_preview_xml/readme/DESCRIPTION.md | 12 + attachment_preview_xml/readme/USAGE.md | 7 + .../static/description/index.html | 451 ++++++++++++++++++ .../static/src/attachment_model_patch.esm.js | 36 ++ .../static/src/file_viewer_patch.esm.js | 11 + .../static/src/file_viewer_patch.xml | 8 + .../static/src/xml_viewer.esm.js | 102 ++++ .../static/src/xml_viewer.scss | 69 +++ .../static/src/xml_viewer.xml | 64 +++ .../static/tests/xml_viewer_tests.esm.js | 42 ++ attachment_preview_xml/tests/__init__.py | 1 + attachment_preview_xml/tests/test_js.py | 16 + 17 files changed, 952 insertions(+) create mode 100644 attachment_preview_xml/README.rst create mode 100644 attachment_preview_xml/__init__.py create mode 100644 attachment_preview_xml/__manifest__.py create mode 100644 attachment_preview_xml/pyproject.toml create mode 100644 attachment_preview_xml/readme/CONTRIBUTORS.md create mode 100644 attachment_preview_xml/readme/DESCRIPTION.md create mode 100644 attachment_preview_xml/readme/USAGE.md create mode 100644 attachment_preview_xml/static/description/index.html create mode 100644 attachment_preview_xml/static/src/attachment_model_patch.esm.js create mode 100644 attachment_preview_xml/static/src/file_viewer_patch.esm.js create mode 100644 attachment_preview_xml/static/src/file_viewer_patch.xml create mode 100644 attachment_preview_xml/static/src/xml_viewer.esm.js create mode 100644 attachment_preview_xml/static/src/xml_viewer.scss create mode 100644 attachment_preview_xml/static/src/xml_viewer.xml create mode 100644 attachment_preview_xml/static/tests/xml_viewer_tests.esm.js create mode 100644 attachment_preview_xml/tests/__init__.py create mode 100644 attachment_preview_xml/tests/test_js.py diff --git a/attachment_preview_xml/README.rst b/attachment_preview_xml/README.rst new file mode 100644 index 00000000000..1ac1bbc7c87 --- /dev/null +++ b/attachment_preview_xml/README.rst @@ -0,0 +1,103 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +====================== +Attachment Preview XML +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:095b175f3873369b496e37ba321526fcc1ce380e320b1b7fa1bedd395120c548 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github + :target: https://github.com/OCA/knowledge/tree/17.0/attachment_preview_xml + :alt: OCA/knowledge +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/knowledge-17-0/knowledge-17-0-attachment_preview_xml + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/knowledge&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Preview XML attachments as a collapsible tree instead of a wall of text. + +Odoo stores XML attachments with the ``text/plain`` mimetype for any +user without write access on views, so the standard file viewer renders +them as raw text. Machine-generated XML (electronic invoices such as +CFDI or UBL, bank statements, EDI messages) usually comes in a single +line, which makes that preview unreadable. + +This module renders XML attachments with their hierarchy: indented +nodes, highlighted tags, attributes and values, and every branch can be +collapsed or expanded. Everything happens in the browser, no conversion +service and no extra software on the server. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Attach an XML file to any record with a chatter, or send it in a + message. +2. Click on the attachment thumbnail. +3. The file opens in the standard Odoo file viewer as a tree. Click a + node to collapse or expand it. + +Files that are not well-formed XML (or bigger than 5 MB) fall back to +the raw text preview. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Jarsa + +Contributors +------------ + +- Jesús Alan Ramos Rodríguez + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/knowledge `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/attachment_preview_xml/__init__.py b/attachment_preview_xml/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/attachment_preview_xml/__manifest__.py b/attachment_preview_xml/__manifest__.py new file mode 100644 index 00000000000..ed3d9b8fbb1 --- /dev/null +++ b/attachment_preview_xml/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Attachment Preview XML", + "summary": "Preview XML attachments as a collapsible tree instead of raw text", + "version": "17.0.1.0.0", + "category": "Knowledge Management", + "website": "https://github.com/OCA/knowledge", + "author": "Jarsa, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["mail"], + "assets": { + "web.assets_backend": [ + "attachment_preview_xml/static/src/attachment_model_patch.esm.js", + "attachment_preview_xml/static/src/xml_viewer.esm.js", + "attachment_preview_xml/static/src/xml_viewer.xml", + "attachment_preview_xml/static/src/xml_viewer.scss", + "attachment_preview_xml/static/src/file_viewer_patch.esm.js", + "attachment_preview_xml/static/src/file_viewer_patch.xml", + ], + "web.qunit_suite_tests": [ + "attachment_preview_xml/static/tests/**/*.js", + ], + }, + "installable": True, +} diff --git a/attachment_preview_xml/pyproject.toml b/attachment_preview_xml/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/attachment_preview_xml/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/attachment_preview_xml/readme/CONTRIBUTORS.md b/attachment_preview_xml/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..cbe0ec793d8 --- /dev/null +++ b/attachment_preview_xml/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Jesús Alan Ramos Rodríguez \<\> diff --git a/attachment_preview_xml/readme/DESCRIPTION.md b/attachment_preview_xml/readme/DESCRIPTION.md new file mode 100644 index 00000000000..d5563840e87 --- /dev/null +++ b/attachment_preview_xml/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +Preview XML attachments as a collapsible tree instead of a wall of text. + +Odoo stores XML attachments with the `text/plain` mimetype for any user +without write access on views, so the standard file viewer renders them as +raw text. Machine-generated XML (electronic invoices such as CFDI or UBL, +bank statements, EDI messages) usually comes in a single line, which makes +that preview unreadable. + +This module renders XML attachments with their hierarchy: indented nodes, +highlighted tags, attributes and values, and every branch can be collapsed +or expanded. Everything happens in the browser, no conversion service and no +extra software on the server. diff --git a/attachment_preview_xml/readme/USAGE.md b/attachment_preview_xml/readme/USAGE.md new file mode 100644 index 00000000000..823f1eb2d8f --- /dev/null +++ b/attachment_preview_xml/readme/USAGE.md @@ -0,0 +1,7 @@ +1. Attach an XML file to any record with a chatter, or send it in a message. +2. Click on the attachment thumbnail. +3. The file opens in the standard Odoo file viewer as a tree. Click a node to + collapse or expand it. + +Files that are not well-formed XML (or bigger than 5 MB) fall back to the raw +text preview. diff --git a/attachment_preview_xml/static/description/index.html b/attachment_preview_xml/static/description/index.html new file mode 100644 index 00000000000..bceb6315b19 --- /dev/null +++ b/attachment_preview_xml/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Attachment Preview XML

+ +

Beta License: AGPL-3 OCA/knowledge Translate me on Weblate Try me on Runboat

+

Preview XML attachments as a collapsible tree instead of a wall of text.

+

Odoo stores XML attachments with the text/plain mimetype for any +user without write access on views, so the standard file viewer renders +them as raw text. Machine-generated XML (electronic invoices such as +CFDI or UBL, bank statements, EDI messages) usually comes in a single +line, which makes that preview unreadable.

+

This module renders XML attachments with their hierarchy: indented +nodes, highlighted tags, attributes and values, and every branch can be +collapsed or expanded. Everything happens in the browser, no conversion +service and no extra software on the server.

+

Table of contents

+ +
+

Usage

+
    +
  1. Attach an XML file to any record with a chatter, or send it in a +message.
  2. +
  3. Click on the attachment thumbnail.
  4. +
  5. The file opens in the standard Odoo file viewer as a tree. Click a +node to collapse or expand it.
  6. +
+

Files that are not well-formed XML (or bigger than 5 MB) fall back to +the raw text preview.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Jarsa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/knowledge project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/attachment_preview_xml/static/src/attachment_model_patch.esm.js b/attachment_preview_xml/static/src/attachment_model_patch.esm.js new file mode 100644 index 00000000000..322de5c88c9 --- /dev/null +++ b/attachment_preview_xml/static/src/attachment_model_patch.esm.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {Attachment} from "@mail/core/common/attachment_model"; +import {patch} from "@web/core/utils/patch"; + +patch(Attachment.prototype, { + /** + * XML attachments are stored with the ``text/plain`` mimetype for any user + * without write access on views (see ``ir.attachment._check_contents``), + * so the extension is the only reliable hint most of the time. + */ + get isXml() { + return ( + Boolean(this.id) && + !this.uploading && + ((this.extension || "").toLowerCase() === "xml" || + ["text/xml", "application/xml"].includes(this.mimetype)) + ); + }, + /** + * XML files are rendered by our own viewer, not by the plain-text iframe. + * + * @override + */ + get isText() { + return super.isText && !this.isXml; + }, + /** + * @override + */ + get isViewable() { + return this.isXml || super.isViewable; + }, +}); diff --git a/attachment_preview_xml/static/src/file_viewer_patch.esm.js b/attachment_preview_xml/static/src/file_viewer_patch.esm.js new file mode 100644 index 00000000000..3b870d34ffd --- /dev/null +++ b/attachment_preview_xml/static/src/file_viewer_patch.esm.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {FileViewer} from "@web/core/file_viewer/file_viewer"; +import {XmlViewer} from "./xml_viewer.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(FileViewer, { + components: {...FileViewer.components, XmlViewer}, +}); diff --git a/attachment_preview_xml/static/src/file_viewer_patch.xml b/attachment_preview_xml/static/src/file_viewer_patch.xml new file mode 100644 index 00000000000..5dd2e2e29b8 --- /dev/null +++ b/attachment_preview_xml/static/src/file_viewer_patch.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/attachment_preview_xml/static/src/xml_viewer.esm.js b/attachment_preview_xml/static/src/xml_viewer.esm.js new file mode 100644 index 00000000000..a1b73782394 --- /dev/null +++ b/attachment_preview_xml/static/src/xml_viewer.esm.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {Component, onWillStart, onWillUpdateProps, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +// Above this size the file is shown as raw text: parsing (and rendering) a +// huge DOM in the browser is slower than what a preview is worth. +export const MAX_XML_SIZE = 5 * 1024 * 1024; + +function buildNode(element) { + const children = []; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + children.push(buildNode(child)); + } else if (child.nodeType === Node.COMMENT_NODE) { + const value = child.nodeValue.trim(); + if (value) { + children.push({type: "comment", value}); + } + } else if ( + child.nodeType === Node.TEXT_NODE || + child.nodeType === Node.CDATA_SECTION_NODE + ) { + const value = child.nodeValue.trim(); + if (value) { + children.push({type: "text", value}); + } + } + } + // Only nodes holding something else than text are collapsible; a node with + // text alone is rendered inline as text, like an editor. + const isBranch = children.some((child) => child.type !== "text"); + return { + type: "element", + tag: element.nodeName, + attrs: [...element.attributes].map((attr) => ({ + name: attr.name, + value: attr.value, + })), + children, + isBranch, + text: isBranch ? "" : children.map((child) => child.value).join(" "), + }; +} + +/** + * Turn an XML string into a plain-object tree the template can render. + * + * @param {String} text + * @returns {Object} root node + * @throws {Error} if the document is not well-formed XML + */ +export function parseXml(text) { + const doc = new DOMParser().parseFromString(text, "application/xml"); + if (doc.querySelector("parsererror") || !doc.documentElement) { + throw new Error("Malformed XML"); + } + return buildNode(doc.documentElement); +} + +export class XmlViewer extends Component { + static template = "attachment_preview_xml.XmlViewer"; + static props = {file: Object}; + + setup() { + this.state = useState({node: null, raw: "", loading: true}); + this.ui = useState(useService("ui")); + // The viewer keeps the same component alive when navigating between + // files, so the load is guarded against out-of-order responses. + this.loadId = 0; + onWillStart(() => this.loadFile(this.props.file)); + onWillUpdateProps((nextProps) => this.loadFile(nextProps.file)); + } + + async loadFile(file) { + const loadId = ++this.loadId; + Object.assign(this.state, {node: null, raw: "", loading: true}); + let text = ""; + try { + const response = await fetch(file.defaultSource); + text = await response.text(); + } catch { + text = ""; + } + if (loadId !== this.loadId) { + return; + } + try { + if (text.length > MAX_XML_SIZE) { + throw new Error("File too large to render as a tree"); + } + this.state.node = parseXml(text); + } catch { + // Not XML we can render (malformed, truncated, too big): fall back + // to the raw content instead of showing nothing at all. + this.state.raw = text; + } + this.state.loading = false; + } +} diff --git a/attachment_preview_xml/static/src/xml_viewer.scss b/attachment_preview_xml/static/src/xml_viewer.scss new file mode 100644 index 00000000000..e30c81059a8 --- /dev/null +++ b/attachment_preview_xml/static/src/xml_viewer.scss @@ -0,0 +1,69 @@ +// Copyright 2026 Jarsa +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +// The file viewer is a dark overlay, so the tree uses a dark code palette +// (the usual editor colors) instead of the backend light theme. +.o_xml_viewer { + background-color: #1e1e1e; + color: #d4d4d4; + font-family: $font-family-monospace; + font-size: 0.85rem; + line-height: 1.5; + // Long lines wrap inside the panel instead of pushing a horizontal + // scrollbar: the viewer is narrow and the bar is easy to miss. + // `anywhere` also breaks inside a single long token (base64 signatures). + overflow-wrap: anywhere; + + .o_xml_children { + margin-left: 0.6rem; + padding-left: 0.9rem; + border-left: 1px solid rgba(255, 255, 255, 0.15); + } + + summary { + cursor: pointer; + } + + // Wrapped lines hang under the tag name instead of restarting at the + // left margin, so the nesting stays readable once a line breaks. + // `details.o_xml_node` is left out on purpose: it would indent its whole + // subtree instead of just its own line. + summary, + div.o_xml_node, + .o_xml_comment { + padding-left: 1.2rem; + text-indent: -1.2rem; + } + + .o_xml_tag { + color: #569cd6; + } + + .o_xml_attr_name { + color: #9cdcfe; + } + + .o_xml_attr_value { + color: #ce9178; + } + + .o_xml_text { + color: #d4d4d4; + white-space: pre-wrap; + } + + .o_xml_comment { + color: #6a9955; + font-style: italic; + } + + .o_xml_viewer_raw { + // The backend styles `pre` with a light background, which clashes with + // the dark panel. + background-color: transparent; + border: 0; + color: inherit; + white-space: pre-wrap; + word-break: break-word; + } +} diff --git a/attachment_preview_xml/static/src/xml_viewer.xml b/attachment_preview_xml/static/src/xml_viewer.xml new file mode 100644 index 00000000000..921689c8693 --- /dev/null +++ b/attachment_preview_xml/static/src/xml_viewer.xml @@ -0,0 +1,64 @@ + + + + +
+
+ +
+
+            
+                
+            
+        
+
+ + + +
+ <> +
+ +
+
</>
+
+
<></> />
+
+
<!-- -->
+
+
+ + ="" + +
diff --git a/attachment_preview_xml/static/tests/xml_viewer_tests.esm.js b/attachment_preview_xml/static/tests/xml_viewer_tests.esm.js new file mode 100644 index 00000000000..8fb4459a5ac --- /dev/null +++ b/attachment_preview_xml/static/tests/xml_viewer_tests.esm.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ +/* global QUnit */ +/* Copyright 2026 Jarsa + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ + +import {parseXml} from "@attachment_preview_xml/xml_viewer.esm"; + +const CFDI = ` + + +Servicios de buses +`; + +QUnit.module("attachment_preview_xml", () => { + QUnit.test("parseXml builds a hierarchy out of a one-line CFDI", (assert) => { + const root = parseXml(CFDI); + assert.strictEqual(root.tag, "cfdi:Comprobante"); + assert.deepEqual( + root.attrs.map((attr) => attr.name), + ["xmlns:cfdi", "Total"] + ); + assert.ok(root.isBranch, "the root node is collapsible"); + assert.deepEqual( + root.children.map((child) => child.type), + ["comment", "element", "element"] + ); + + const [comment, emisor, conceptos] = root.children; + assert.strictEqual(comment.value, "emisor"); + assert.notOk(emisor.isBranch, "an empty node is not collapsible"); + assert.strictEqual(emisor.attrs[1].value, "ETN TURISTAR LUJO"); + + const concepto = conceptos.children[0]; + assert.notOk(concepto.isBranch, "a text-only node is not collapsible"); + assert.strictEqual(concepto.text, "Servicios de buses"); + }); + + QUnit.test("parseXml rejects malformed XML", (assert) => { + assert.throws(() => parseXml("")); + assert.throws(() => parseXml("not xml at all")); + }); +}); diff --git a/attachment_preview_xml/tests/__init__.py b/attachment_preview_xml/tests/__init__.py new file mode 100644 index 00000000000..c91c41ddcc3 --- /dev/null +++ b/attachment_preview_xml/tests/__init__.py @@ -0,0 +1 @@ +from . import test_js diff --git a/attachment_preview_xml/tests/test_js.py b/attachment_preview_xml/tests/test_js.py new file mode 100644 index 00000000000..3289b7f0e53 --- /dev/null +++ b/attachment_preview_xml/tests/test_js.py @@ -0,0 +1,16 @@ +# Copyright 2026 Jarsa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import odoo.tests + + +@odoo.tests.tagged("post_install", "-at_install") +class TestXmlViewerJs(odoo.tests.HttpCase): + def test_js(self): + """Run the QUnit suite of this module in a real browser.""" + self.browser_js( + "/web/tests?filter=attachment_preview_xml", + "", + "", + login="admin", + timeout=300, + )