diff --git a/addons_extensions/universal_attachment_preview/__init__.py b/addons_extensions/universal_attachment_preview/__init__.py new file mode 100644 index 000000000..19240f4ea --- /dev/null +++ b/addons_extensions/universal_attachment_preview/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/__manifest__.py b/addons_extensions/universal_attachment_preview/__manifest__.py new file mode 100644 index 000000000..475f79af2 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/__manifest__.py @@ -0,0 +1,28 @@ +{ + "name": "Universal Attachment Preview", + "version": "1.0.0", + "summary": "Add a View (eye) button to attachments and preview any file (images, pdf, video, audio, office via conversion).", + "description": "Universal previewer for attachments. Uses LibreOffice for office conversion to PDF.", + "author": "You", + "license": "AGPL-3", + "category": "Tools", + "depends": ["base", "web", "mail", "documents", "onlyoffice_odoo"], # documents optional but helpful + "data": [ + # "security/ir.model.access.csv", + # "views/preview_wizard_views.xml", + # "views/assets.xml", + ], + 'assets': { + 'web.assets_backend': [ + # "universal_attachment_preview/static/src/css/attachment_preview.css", + "universal_attachment_preview/static/src/xml/binary_field_inherit.xml", + "universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.xml", + "universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.js", + "universal_attachment_preview/static/src/js/binary_file_preview.js", + ], + }, + + + "installable": True, + "application": False, +} diff --git a/addons_extensions/universal_attachment_preview/controllers/__init__.py b/addons_extensions/universal_attachment_preview/controllers/__init__.py new file mode 100644 index 000000000..deec4a8b8 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/controllers/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/controllers/main.py b/addons_extensions/universal_attachment_preview/controllers/main.py new file mode 100644 index 000000000..643dc90ca --- /dev/null +++ b/addons_extensions/universal_attachment_preview/controllers/main.py @@ -0,0 +1,316 @@ +# file: my_module/controllers/main.py +import base64 +import tempfile +import subprocess +import os +import re +import markupsafe + +import json + +from odoo import http +from odoo.http import request +import logging +from odoo.addons.onlyoffice_odoo.controllers.controllers import Onlyoffice_Connector +from odoo.addons.onlyoffice_odoo.utils import config_utils, file_utils, jwt_utils, url_utils + +_logger = logging.getLogger(__name__) +_mobile_regex = r"android|avantgo|playbook|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\\/|plucker|pocket|psp|symbian|treo|up\\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino" # noqa: E501 +import mimetypes + +# --------------------------------------------------------- +# Reliable MIME → EXT mapping for Office, PDF, others +# --------------------------------------------------------- +OFFICE_MAPPING = { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template": ".dotx", + + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template": ".xltx", + + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow": ".ppsx", + "application/vnd.openxmlformats-officedocument.presentationml.template": ".potx", + + "application/pdf": ".pdf", +} + +def _get_extension_from_mimetype(mimetype): + if not mimetype: + return None + + # 1️⃣ Exact match for Office formats + if mimetype in OFFICE_MAPPING: + return OFFICE_MAPPING[mimetype] + + # 2️⃣ Try Python's builtin guess + ext = mimetypes.guess_extension(mimetype) + if ext: + return ext + + # 3️⃣ Fallback + return None + + + +class AttachmentPreview(http.Controller): + + + @http.route("/lookup_or_create/attachment", type="json", auth="user") + def lookup_or_create_attachment(self, model, res_id, field): + record = request.env[model].sudo().browse(int(res_id)) + if not record.exists(): + return {"error": "Record not found"} + + # Check existing attachment + attach = request.env["ir.attachment"].sudo().search([ + ("res_model", "=", model), + ("res_id", "=", int(res_id)), + ("res_field", "=", field) + ], limit=1) + + # Create attachment if missing + if not attach: + binary_data = getattr(record, field) + if not binary_data: + return {"error": "Binary field empty"} + + filename = record._fields[field].string or "file.bin" + + attach = request.env["ir.attachment"].sudo().create({ + "name": filename, + "datas": binary_data, + "res_model": model, + "res_id": int(res_id), + "res_field": field, + "type": "binary", + }) + + return { + "attachment_id": attach.id, + "name": attach.name, + "mimetype": attach.mimetype, + "url": f"/web/content/{attach.id}?download=false", + } + + def convert_office_to_pdf(self,data, ext): + """Convert office files to PDF using LibreOffice.""" + with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as f: + f.write(data) + input_path = f.name + + output_path = input_path.replace(f".{ext}", ".pdf") + + subprocess.run([ + "libreoffice", + "--headless", + "--convert-to", "pdf", + "--outdir", os.path.dirname(input_path), + input_path + ], check=True) + + with open(output_path, "rb") as f: + pdf_data = f.read() + + return pdf_data + + + @http.route("/universal_preview/get_file",type="json",auth="user",methods=["POST"],) + def get_file(self, model, res_id, field): + """ + Input JSON: + { + "model": "res.partner", + "res_id": 12, + "field": "x_document" + } + """ + # model = model + # res_id = res_id + # field = field + + record = request.env[model].sudo().browse(res_id) + if not record.exists(): + return {"error": "Record not found"} + + attachment = request.env["ir.attachment"].sudo().search([ + ("res_model", "=", model), + ("res_id", "=", res_id), + ("res_field", "=", field) + ], limit=1) + + if not attachment: + return {"error": "Attachment not found"} + + mimetype = attachment.mimetype + file_data = base64.b64decode(attachment.datas) + filename = attachment.name.lower() + + # office → pdf conversion + office_map = { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx", + "application/vnd.ms-powerpoint": "ppt", + } + + ext = office_map.get(mimetype) + + if ext: + try: + pdf_data = self.convert_office_to_pdf(file_data, ext) + return { + "filename": filename.replace(f".{ext}", ".pdf"), + "mimetype": "application/pdf", + "data": base64.b64encode(pdf_data).decode() + } + except Exception as e: + return {"error": f"Conversion failed: {str(e)}"} + + # return original file + return { + "filename": filename, + "mimetype": mimetype, + "data": attachment.datas, + } + +class OnlyofficeConnector(Onlyoffice_Connector): + + @http.route("/onlyoffice/editor/", auth="public", type="http", website=True) + def render_editor(self, attachment_id, access_token=None, readonly=False): + _logger.info("GET /onlyoffice/editor/%s", attachment_id) + attachment = self.get_attachment(attachment_id) + if not attachment: + _logger.warning("GET /onlyoffice/editor/%s - attachment not found", attachment_id) + return request.not_found() + + attachment.validate_access(access_token) + + if attachment.res_model == "documents.document": + document = request.env["documents.document"].browse(int(attachment.res_id)) + self._check_document_access(document) + + data = attachment.read(["id", "checksum", "public", "name", "access_token"])[0] + filename = data["name"] + + can_read = attachment.has_access("read") and file_utils.can_view(filename) + if readonly: + can_read = attachment.has_access("read") + can_write = not readonly and (attachment.has_access("write") and file_utils.can_edit(filename)) + + if not can_read: + _logger.warning("GET /onlyoffice/editor/%s - no read access", attachment_id) + raise Exception("cant read") + + _logger.info("GET /onlyoffice/editor/%s - success", attachment_id) + return request.render( + "onlyoffice_odoo.onlyoffice_editor", self.prepare_editor_values(attachment, access_token, can_write) + ) + # + # def prepare_editor_values(self, attachment, access_token, can_write): + # import pdb + # pdb.set_trace() + # _logger.info("prepare_editor_values - attachment: %s", attachment.id) + # data = attachment.read(["id", "checksum", "public", "name", "access_token", "mimetype"])[0] + # + # filename = data.get("name") or "" + # mimetype = attachment.mimetype or "" + # + # # Extract current extension (if exists) + # current_ext = "" + # if "." in filename: + # current_ext = "." + filename.split(".")[-1].lower() + # + # # Compute correct extension from mimetype + # correct_ext = _get_extension_from_mimetype(mimetype) + # + # # ----------------------------------------------------- + # # FIX #1: filename has NO extension → add correct ext + # # FIX #2: filename has WRONG extension → replace it + # # ----------------------------------------------------- + # if not correct_ext: + # # fallback for unknown mimetypes + # correct_ext = ".bin" + # + # if not filename or "." not in filename: + # # No extension → set new name + # new_name = f"{attachment.res_field}_{attachment.id}{correct_ext}" + # attachment.write({"name": new_name}) + # data["name"] = new_name + # + # else: + # # Has an extension → check if it matches + # if current_ext != correct_ext: + # base = filename.rsplit(".", 1)[0] + # new_name = f"{base}{correct_ext}" + # attachment.write({"name": new_name}) + # data["name"] = new_name + # else: + # # extension is correct + # data["name"] = filename + # + # # Continue your original logic without changes + # key = str(data["id"]) + str(data["checksum"]) + # docserver_url = config_utils.get_doc_server_public_url(request.env) + # odoo_url = config_utils.get_base_or_odoo_url(request.env) + # + # filename = self.filter_xss(data["name"]) + # + # security_token = jwt_utils.encode_payload( + # request.env, {"id": request.env.user.id}, config_utils.get_internal_jwt_secret(request.env) + # ) + # security_token = security_token.decode("utf-8") if isinstance(security_token, bytes) else security_token + # access_token = access_token.decode("utf-8") if isinstance(access_token, bytes) else access_token + # + # path_part = ( + # str(data["id"]) + # + "?oo_security_token=" + # + security_token + # + ("&access_token=" + access_token if access_token else "") + # + "&shardkey=" + # + key + # ) + # + # document_type = file_utils.get_file_type(filename) + # is_mobile = bool(re.search(_mobile_regex, request.httprequest.headers.get("User-Agent"), re.IGNORECASE)) + # + # root_config = { + # "width": "100%", + # "height": "100%", + # "type": "mobile" if is_mobile else "desktop", + # "documentType": document_type, + # "document": { + # "title": filename, + # "url": odoo_url + "onlyoffice/file/content/" + path_part, + # "fileType": file_utils.get_file_ext(filename), + # "key": key, + # "permissions": {}, + # }, + # "editorConfig": { + # "lang": request.env.user.lang, + # "user": {"id": str(request.env.user.id), "name": request.env.user.name}, + # "customization": {}, + # }, + # } + # + # if can_write: + # root_config["editorConfig"]["callbackUrl"] = odoo_url + "onlyoffice/editor/callback/" + path_part + # + # if attachment.res_model != "documents.document": + # root_config["editorConfig"]["mode"] = "edit" if can_write else "view" + # root_config["document"]["permissions"]["edit"] = can_write + # else: + # root_config = self.get_documents_permissions(attachment, can_write, root_config) + # + # if jwt_utils.is_jwt_enabled(request.env): + # root_config["token"] = jwt_utils.encode_payload(request.env, root_config) + # + # _logger.info("prepare_editor_values - success: %s", attachment.id) + # return { + # "docTitle": filename, + # "docIcon": f"/onlyoffice_odoo/static/description/editor_icons/{document_type}.ico", + # "docApiJS": docserver_url + "web-apps/apps/api/documents/api.js", + # "editorConfig": markupsafe.Markup(json.dumps(root_config)), + # } diff --git a/addons_extensions/universal_attachment_preview/models/__init__.py b/addons_extensions/universal_attachment_preview/models/__init__.py new file mode 100644 index 000000000..e27e92b67 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/models/__init__.py @@ -0,0 +1 @@ +from . import ir_attachment \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/models/ir_attachment.py b/addons_extensions/universal_attachment_preview/models/ir_attachment.py new file mode 100644 index 000000000..b604aebb4 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/models/ir_attachment.py @@ -0,0 +1,88 @@ +from odoo import models +import mimetypes +import base64 +import io +import zipfile + + +class IrAttachment(models.Model): + _inherit = "ir.attachment" + + def _detect_mimetype(self, datas): + # direct copy of the above function + try: + raw = base64.b64decode(datas) + except: + return "application/octet-stream" + + + # office zip check + if raw.startswith(b"PK"): + try: + z = zipfile.ZipFile(io.BytesIO(raw)) + names = z.namelist() + + if "ppt/presentation.xml" in names: + return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + if "word/document.xml" in names: + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + if "xl/workbook.xml" in names: + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + return "application/zip" + except: + pass + + if raw.startswith(b"%PDF-"): + return "application/pdf" + + return "application/octet-stream" + + def _get_extension_from_mimetype(self, mimetype): + mapping = { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/pdf": ".pdf", + "application/zip": ".zip", + } + return mapping.get(mimetype, False) + + def write(self, vals): + res = super().write(vals) + + # ---------------------------------------------------- + # 1) Fix incorrect mimetype (ex: .pptx → zip) + # ---------------------------------------------------- + if "datas" in vals or "mimetype" in vals: + for rec in self: + datas = vals.get("datas", rec.datas) + mimetype = self._detect_mimetype(datas) + + # Update mimetype if wrong + if mimetype != rec.mimetype: + rec.sudo().write({"mimetype": mimetype}) + + # ---------------------------------------------------- + # 2) Fix the filename extension based on correct mimetype + # ---------------------------------------------------- + # Detect extension from mimetype + correct_ext = self._get_extension_from_mimetype(mimetype) or ".bin" + + filename = rec.name or "" + current_ext = "" + if "." in filename: + current_ext = "." + filename.split(".")[-1].lower() + + # CASE 1: No filename or no extension + if not filename or "." not in filename: + new_name = f"{rec.res_field or 'file'}_{rec.id}{correct_ext}" + rec.sudo().write({"name": new_name}) + + # CASE 2: Wrong extension → Replace it + elif current_ext != correct_ext: + base = filename.rsplit(".", 1)[0] + new_name = f"{base}{correct_ext}" + rec.sudo().write({"name": new_name}) + + return res + diff --git a/addons_extensions/universal_attachment_preview/models/preview_wizard.py b/addons_extensions/universal_attachment_preview/models/preview_wizard.py new file mode 100644 index 000000000..c9872efa2 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/models/preview_wizard.py @@ -0,0 +1,8 @@ +# models/preview_wizard.py +from odoo import models, fields, api + +class AttachmentPreviewWizard(models.TransientModel): + _name = 'attachment.preview.wizard' + _description = 'Attachment Preview Wizard' + + attachment_id = fields.Many2one('ir.attachment', string='Attachment', required=True) diff --git a/addons_extensions/universal_attachment_preview/security/ir.model.access.csv b/addons_extensions/universal_attachment_preview/security/ir.model.access.csv new file mode 100644 index 000000000..1e29bc0da --- /dev/null +++ b/addons_extensions/universal_attachment_preview/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_attachment_preview,access_attachment_preview,model_attachment_preview_wizard,base.group_user,1,0,0,0 diff --git a/addons_extensions/universal_attachment_preview/services/__init__.py b/addons_extensions/universal_attachment_preview/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/addons_extensions/universal_attachment_preview/services/file_converter.py b/addons_extensions/universal_attachment_preview/services/file_converter.py new file mode 100644 index 000000000..5d5a11486 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/services/file_converter.py @@ -0,0 +1,43 @@ +import base64 +from odoo import models, api +import subprocess +import tempfile +import os +import logging +_logger = logging.getLogger(__name__) + + + +class AttachmentConverter(models.AbstractModel): + _name = 'ir.attachment.converter' + _description = 'Attachment Converter' + + @api.model + def convert_to_pdf(self, attachment): + if attachment._get_preview_type() != 'office': + return False + + with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as doc_file: + doc_file.write(base64.b64decode(attachment.datas)) + doc_path = doc_file.name + + pdf_path = doc_path + '.pdf' + + try: + # Using LibreOffice for conversion + subprocess.run([ + 'libreoffice', '--headless', '--convert-to', 'pdf', + '--outdir', os.path.dirname(doc_path), doc_path + ], check=True) + + with open(pdf_path, 'rb') as pdf_file: + return pdf_file.read() + except Exception as e: + _logger.error(f"Conversion failed: {e}") + return False + finally: + # Clean up temp files + if os.path.exists(doc_path): + os.unlink(doc_path) + if os.path.exists(pdf_path): + os.unlink(pdf_path) \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.js b/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.js new file mode 100644 index 000000000..e8c6cc92e --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.js @@ -0,0 +1,53 @@ +/** @odoo-module **/ + +import { Dialog } from "@web/core/dialog/dialog"; +import { Component } from "@odoo/owl"; + +export class AttachmentPreviewPopup extends Component { + static template = "universal_attachment_preview.PopupPreview"; + + // FIX: Register Dialog here + static components = { Dialog }; + + static props = { + attachmentId: Number, + url: String, + filename: String, + mimetype: String, + dialogClass: String, + close: Function, + }; + + setup() { + this.currentViewer = "onlyoffice"; // default viewer + } + + // Google Docs Viewer + get googleDocsUrl() { + return `https://docs.google.com/viewer?url=${encodeURIComponent(this.props.url)}&embedded=true`; + } + + // Microsoft Office Viewer + get microsoftOfficeUrl() { + return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(this.props.url)}`; + } + + // OnlyOffice Viewer + get onlyofficeUrl() { +// return `/onlyoffice/editor/148908?readonly=True`; + let onlyOfficeLink = `/onlyoffice/editor/${this.props.attachmentId}?readonly=true`; + console.log(onlyOfficeLink); + return onlyOfficeLink; + } + + get currentViewerUrl() { + if (this.currentViewer === "onlyoffice") return this.onlyofficeUrl; + if (this.currentViewer === "google") return this.googleDocsUrl; + if (this.currentViewer === "microsoft") return this.microsoftOfficeUrl; + } + + switchViewer(viewer) { + this.currentViewer = viewer; + this.render(); // update iframe + } +} diff --git a/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.xml b/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.xml new file mode 100644 index 000000000..9f0fdabcf --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/attachment_preview_popup/attachment_preview_popup.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + Download + +
+
+ + + + + + +
+ + +

+ This file does not Support Preview, Kindly Download Instead. +

+
+
+
+ +
diff --git a/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css b/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css new file mode 100644 index 000000000..a358f9b05 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/css/attachment_preview.css @@ -0,0 +1,30 @@ +/* Make dialog fill most of the screen */ +.o_dialog_container.o_fullscreen_preview { + width: 95vw !important; + max-width: 95vw !important; + height: 90vh !important; +} + +/* Remove padding from body */ +.o_fullscreen_preview .modal-body { + padding: 0 !important; + height: calc(90vh - 50px); /* subtract header height */ +} +/* Ensure wrapper fills height */ +.o_fullscreen_preview_body { + height: 100%; + width: 100%; +} + +/* Full-size iframe */ +.o_preview_frame { + width: 100%; + height: 100%; + border: none; +} + +.o_fullscreen_preview iframe { + width: 100%; + height: 100%; + border: none; +} diff --git a/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js b/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js new file mode 100644 index 000000000..6037e77e7 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/js/binary_file_preview.js @@ -0,0 +1,60 @@ +/** @odoo-module **/ + +import { BinaryField, ListBinaryField } from "@web/views/fields/binary/binary_field"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { AttachmentPreviewPopup } from "@universal_attachment_preview/attachment_preview_popup/attachment_preview_popup"; +import { rpc } from "@web/core/network/rpc"; + +patch(BinaryField.prototype, { + setup() { + super.setup(); + this.dialog = useService("dialog"); + this.rpc = rpc; + }, + + async onPreviewFile(ev) { + ev.stopPropagation(); + ev.preventDefault(); + + const data = this.getDownloadData(); + if (!data || !data.model || !data.id || !data.field) { + return; + } + + // 1️⃣ Convert Binary → Attachment (or fetch existing) + const result = await this.rpc("/lookup_or_create/attachment", { + model: data.model, + res_id: data.id, + field: data.field, + }); + + if (!result || !result.attachment_id) { + alert("Could not generate preview file."); + return; + } + + // 📄 Correct file name + let filename = data.filename; + if (result.name && !/^file($|[_\.])/i.test(result.name)) { + filename = result.name; + } + + + // 2️⃣ Open preview dialog with attachment details + this.dialog.add(AttachmentPreviewPopup, { + attachmentId: result.attachment_id, + filename: filename, + mimetype: result.mimetype, + url: result.url, + dialogClass: "o_fullscreen_preview", + }); + }, +}); + +// Patch list view binary +patch(ListBinaryField.prototype, { + onPreviewFile(ev) { + return BinaryField.prototype.onPreviewFile.call(this, ev); + }, +}); diff --git a/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js b/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js new file mode 100644 index 000000000..55ec5ca1e --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/js/binary_preview.js @@ -0,0 +1,263 @@ +/** @odoo-module **/ + +import { BinaryField, ListBinaryField } from "@web/views/fields/binary/binary_field"; +import { patch } from "@web/core/utils/patch"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { rpc } from "@web/core/network/rpc"; +import { Component } from "@odoo/owl"; + +// --------------------------- +// PREVIEW COMPONENT +// --------------------------- +export class PreviewContent extends Component { + setup() { + console.log("[PreviewContent] Setup called with props:", this.props); + // Log the actual props we need + console.log("[PreviewContent] URL:", this.props.url); + console.log("[PreviewContent] Direct URL:", this.props.directUrl); + console.log("[PreviewContent] Absolute URL:", this.getAbsoluteUrl()); + console.log("[PreviewContent] Filename:", this.props.filename); + console.log("[PreviewContent] Mimetype:", this.props.mimetype); + } + + static template = owl.xml/* xml */` +
+ + + + + + + + + + + + + + + + + +
+ +

Preview Not Available

+

This file type cannot be previewed directly.

+

Mimetype:

+ + Download File + +
+
+
+ `; + + static props = { + url: String, + directUrl: String, + filename: String, + mimetype: { type: String, optional: true }, + }; + + static defaultProps = { + mimetype: "", + }; + + get isImage() { + console.log("[PreviewContent] Checking if image, mimetype:", this.props.mimetype); + const result = this.props.mimetype && this.props.mimetype.startsWith("image/"); + console.log("[PreviewContent] isImage result:", result); + return result; + } + + get isPDF() { + console.log("[PreviewContent] Checking if PDF, mimetype:", this.props.mimetype); + const result = this.props.mimetype === "application/pdf"; + console.log("[PreviewContent] isPDF result:", result); + return result; + } + + get isVideo() { + console.log("[PreviewContent] Checking if video, mimetype:", this.props.mimetype); + const result = this.props.mimetype && this.props.mimetype.startsWith("video/"); + console.log("[PreviewContent] isVideo result:", result); + return result; + } + + get isDocument() { + console.log("[PreviewContent] Checking if document, mimetype:", this.props.mimetype); + const docTypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain' + ]; + const result = docTypes.includes(this.props.mimetype); + console.log("[PreviewContent] isDocument result:", result); + return result; + } + + get isOther() { + console.log("[PreviewContent] Checking if other type"); + const result = !this.isImage && !this.isPDF && !this.isVideo && !this.isDocument; + console.log("[PreviewContent] isOther result:", result); + return result; + } + + getAbsoluteUrl() { + // Convert relative URL to absolute URL + const absoluteUrl = window.location.origin + this.props.directUrl; + console.log("[PreviewContent] Absolute URL:", absoluteUrl); + return absoluteUrl; + } + + getGoogleDocsUrl() { + const absoluteUrl = this.getAbsoluteUrl(); + const url = `https://docs.google.com/viewer?url=${encodeURIComponent(absoluteUrl)}&embedded=true`; + console.log("[PreviewContent] Google Docs URL:", url); + return url; + } +} + +// REGISTER COMPONENT +console.log("[DEBUG] Registering PreviewContent component"); +registry.category("components").add("PreviewContent", PreviewContent); +console.log("[DEBUG] PreviewContent component registered successfully"); + +// Create a wrapper component for the dialog +class PreviewDialog extends Component { + setup() { + console.log("[PreviewDialog] Setup called with props:", this.props); + // Extract the actual file props from the dialog options + this.fileProps = this.props.props || {}; + console.log("[PreviewDialog] Extracted file props:", this.fileProps); + } + + static template = owl.xml/* xml */` + + `; + static components = { PreviewContent }; + static props = { + title: String, + props: Object, + size: String, + close: Function, + }; +} + +// Register the wrapper component +console.log("[DEBUG] Registering PreviewDialog component"); +registry.category("components").add("PreviewDialog", PreviewDialog); +console.log("[DEBUG] PreviewDialog component registered successfully"); + +// --------------------------- +// PATCH BINARY FIELD +// --------------------------- +console.log("[DEBUG] Patching BinaryField"); +patch(BinaryField.prototype, { + setup() { + console.log("[BinaryField] Setup called"); + super.setup(); + this.rpc = rpc; + this.dialog = useService("dialog"); + console.log("[BinaryField] Services initialized:", { rpc: !!this.rpc, dialog: !!this.dialog }); + }, + + async onPreviewFile(ev) { + console.log("[BinaryField] onPreviewFile triggered"); + ev.stopPropagation(); + + const data = this.getDownloadData(); + console.log("[BinaryField] Download data:", data); + + // Check if we have the required data + if (!data || !data.model || !data.id || !data.field) { + console.error("[BinaryField] Missing required data:", data); + alert("Error: Missing file information"); + return; + } + + // Construct the correct URL for the file content + const directUrl = `/web/content?model=${data.model}&id=${data.id}&field=${data.field}&filename=${encodeURIComponent(data.filename || '')}`; + console.log("[BinaryField] Direct URL constructed:", directUrl); + + // Create a blob URL from the file content + try { + console.log("[BinaryField] Fetching file..."); + const response = await fetch(directUrl); + console.log("[BinaryField] Response status:", response.status); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + console.log("[BinaryField] Blob created, size:", blob.size, "type:", blob.type); + + const url = URL.createObjectURL(blob); + console.log("[BinaryField] Object URL created:", url); + + // Prepare the props for the PreviewContent component + const previewProps = { + url, // Blob URL for direct preview in the browser + directUrl, // Direct server URL for Google Docs Viewer and PDF + filename: data.filename || 'Unknown', + mimetype: blob.type || data.mimetype || 'application/octet-stream' + }; + + console.log("[BinaryField] Preview props:", previewProps); + console.log("[BinaryField] About to open dialog..."); + + // Use the wrapper component class directly + this.dialog.add(PreviewDialog, { + title: `Preview: ${data.filename || 'File'}`, + props: previewProps, + size: "extra-large", + technical: false, // Ensure the dialog is visible + }); + console.log("[BinaryField] Dialog opened successfully"); + } catch (error) { + console.error("[BinaryField] Error fetching file:", error); + alert("Could not preview the file. Please try downloading it instead."); + // Fallback to download + console.log("[BinaryField] Falling back to download"); + window.open(directUrl + '&download=true', '_blank'); + } + }, + + _base64ToBlob(base64Data, mimeType) { + console.log("[BinaryField] Converting base64 to blob, type:", mimeType); + const byteCharacters = atob(base64Data); + const byteArrays = []; + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512); + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + byteArrays.push(new Uint8Array(byteNumbers)); + } + const blob = new Blob(byteArrays, { type: mimeType }); + console.log("[BinaryField] Blob created from base64, size:", blob.size); + return blob; + }, +}); + +console.log("[DEBUG] BinaryField patched successfully"); + +console.log("[DEBUG] Patching ListBinaryField"); +patch(ListBinaryField.prototype, { + onPreviewFile(ev) { + console.log("[ListBinaryField] onPreviewFile triggered, delegating to BinaryField"); + return BinaryField.prototype.onPreviewFile.call(this, ev); + }, +}); +console.log("[DEBUG] ListBinaryField patched successfully"); \ No newline at end of file diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml new file mode 100644 index 000000000..e72f51820 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_field_inherit.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml new file mode 100644 index 000000000..071afb6d1 --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_file_preview.xml @@ -0,0 +1,9 @@ + + + +
+

Hello World

+

Popup loaded successfully!

+
+
+
diff --git a/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml b/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml new file mode 100644 index 000000000..7e709e72e --- /dev/null +++ b/addons_extensions/universal_attachment_preview/static/src/xml/binary_preview_template.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + +
+

Preview not available. Download the file to view it locally.

+
+
+ + +