Document Preview

This commit is contained in:
Pranay 2025-12-03 10:19:21 +05:30
parent bfd7890cbc
commit c93d208990
22 changed files with 1144 additions and 0 deletions

View File

@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@ -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,
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -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/<int:attachment_id>", 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)),
# }

View File

@ -0,0 +1 @@
from . import ir_attachment

View File

@ -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

View File

@ -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)

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_attachment_preview access_attachment_preview model_attachment_preview_wizard base.group_user 1 0 0 0

View File

@ -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)

View File

@ -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
}
}

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="universal_attachment_preview.PopupPreview">
<Dialog title="props.filename" size="'lg'">
<t t-if="props.mimetype.endsWith('pdf')">
<iframe t-att-src="props.url"
style="width:100%; height:500px;"
frameborder="0">
</iframe>
</t>
<t t-elif="props.mimetype.startsWith('image')">
<img t-att-src="props.url"
style="max-width:100%; max-height:80vh;" />
</t>
<t t-elif="
props.mimetype.startsWith('audio') or
props.mimetype.endsWith('mp3') or
props.mimetype.endsWith('wav') or
props.mimetype.endsWith('ogg') or
props.filename.endsWith('.mp3') or
props.filename.endsWith('.wav') or
props.filename.endsWith('.ogg')
">
<audio controls="controls" style="width:100%; margin-top:10px;">
<source t-att-src="props.url"/>
</audio>
</t>
<t t-elif="props.mimetype.startsWith('video') or props.mimetype.endsWith('mp4') or props.mimetype.endsWith('stream')">
<video class="o-FileViewer-view" style="width:100%; height:auto; max-height:80vh;" t-on-click.stop="" controls="controls">
<source t-att-src="props.url" />
</video>
</t>
<t t-elif="
props.mimetype.startsWith('application/vnd.ms-') or
props.mimetype.startsWith('application/vnd.openxmlformats-officedocument.') or
props.filename.endsWith('.doc') or
props.filename.endsWith('.docx') or
props.filename.endsWith('.xls') or
props.filename.endsWith('.xlsx') or
props.filename.endsWith('.ppt') or
props.filename.endsWith('.pptx')
">
<div class="o_document_preview">
<iframe t-att-src="currentViewerUrl"
style="width:100%; height:500px;"
frameborder="0">
</iframe>
<div class="text-center mt-2">
<button class="btn btn-secondary mr-2"
t-att-class="{active: currentViewer === 'onlyoffice'}"
t-on-click="() => this.switchViewer('onlyoffice')">
OnlyOffice
</button>
<button class="btn btn-secondary mr-2"
t-att-class="{active: currentViewer === 'google'}"
t-on-click="() => this.switchViewer('google')">
Google Docs
</button>
<button class="btn btn-secondary mr-2"
t-att-class="{active: currentViewer === 'microsoft'}"
t-on-click="() => this.switchViewer('microsoft')">
Microsoft Office
</button>
<a t-att-href="props.url" target="_blank" class="btn btn-primary">
<i class="fa fa-download"/>
Download
</a>
</div>
</div>
<!-- <iframe t-att-src="'https://docs.google.com/viewer?url=' + encodeURIComponent(props.url) + '&amp;embedded=true'"-->
<!-- style="width:100%; height:500px;"-->
<!-- frameborder="0">-->
<!-- </iframe>-->
<!-- Alternative using Microsoft Office Online -->
<!-- <iframe t-att-src="'https://view.officeapps.live.com/op/view.aspx?src=' + encodeURIComponent(props.url)"
style="width:100%; height:500px;"
frameborder="0">
</iframe> -->
</t>
<t t-else="">
<p class="text-muted mt-2">
This file does not Support Preview, Kindly Download Instead.
</p>
</t>
</Dialog>
</t>
</templates>

View File

@ -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;
}

View File

@ -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);
},
});

View File

@ -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 */`
<div class="preview-container" style="width:100%; height:100%;">
<t t-if="isImage">
<img t-att-src="props.url" style="width:100%; height:auto; max-height:80vh;"/>
</t>
<t t-if="isPDF">
<iframe t-att-src="getAbsoluteUrl()" style="width:100%; height:80vh; border:none;"></iframe>
</t>
<t t-if="isVideo">
<video style="width:100%; height:auto; max-height:80vh;" controls="controls">
<source t-att-src="props.url"/>
</video>
</t>
<t t-if="isDocument">
<iframe t-att-src="getGoogleDocsUrl()" style="width:100%; height:80vh; border:none;"></iframe>
</t>
<t t-if="isOther">
<div class="text-center p-4">
<i class="fa fa-file-o fa-5x text-muted mb-3"></i>
<h4>Preview Not Available</h4>
<p class="text-muted">This file type cannot be previewed directly.</p>
<p class="text-muted">Mimetype: <t t-esc="props.mimetype || 'Unknown'"/></p>
<a t-att-href="props.url" download="download" class="btn btn-primary mt-2">
<i class="fa fa-download mr-1"/>Download File
</a>
</div>
</t>
</div>
`;
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 */`
<div class="modal-body" style="padding: 0;">
<PreviewContent url="fileProps.url" directUrl="fileProps.directUrl" filename="fileProps.filename" mimetype="fileProps.mimetype"/>
</div>
`;
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");

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<!-- Inherit BinaryField template -->
<t t-name="universal_attachment_preview.BinaryField" t-inherit="web.BinaryField" t-inherit-mode="extension">
<!-- Insert AFTER the trash icon -->
<xpath expr="//button[hasclass('o_clear_file_button')]" position="after">
<button
class="btn btn-link btn-sm lh-1 fa fa-eye o_preview_file_button"
data-tooltip="Preview"
aria-label="Preview"
t-on-click.prevent="onPreviewFile"
></button>
</xpath>
</t>
<t t-name="universal_attachment_preview.ListBinaryField" t-inherit="web.ListBinaryField" t-inherit-mode="extension">
<xpath expr="//button[hasclass('o_clear_file_button')]" position="after">
<button
class="btn btn-link btn-sm lh-1 fa fa-eye o_preview_file_button"
data-tooltip="Preview"
aria-label="Preview"
t-on-click.prevent="onPreviewFile"
></button>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="universal_attachment_preview.PopupPreview">
<div class="p-3">
<h3>Hello World</h3>
<p>Popup loaded successfully!</p>
</div>
</t>
</templates>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- PREVIEW POPUP WINDOW -->
<t t-name="universal_attachment_preview.FilePreviewDialog">
<Dialog title="Preview: [[ props.filename ]]">
<!-- AUTO HANDLING FOR ANY FILE TYPE -->
<t t-if="props.url.endsWith('.pdf')">
<iframe t-att-src="props.url"
style="width:100%; height:80vh; border:none;" />
</t>
<t t-elif="props.url.match(/\.(jpg|jpeg|png|gif|webp|bmp)$/i)">
<img t-att-src="props.url"
style="max-width:100%; max-height:80vh;" />
</t>
<t t-elif="props.url.match(/\.(mp4|webm|ogg)$/i)">
<video class="o-FileViewer-view w-75 h-75" style="width:100%; height:auto; max-height:80vh;" t-on-click.stop="" controls="controls">
<source t-att-src="props.url" />
</video>
</t>
<t t-else="">
<!-- DEFAULT: Non-previewable types -->
<iframe t-att-src="props.url"
style="width:100%; height:80vh; border:none;" />
<p class="text-muted mt-2">
If the file does not preview, it will download automatically.
</p>
</t>
</Dialog>
</t>
</templates>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="universal_attachment_preview.dialog">
<div class="modal-body" t-raw="props.content"></div>
</t>
</templates>

View File

@ -0,0 +1,30 @@
<templates id="template" xml:space="preserve">
<t t-name="ftp_attachment_preview.PreviewModal">
<div class="o_preview_container">
<t t-if="previewType == 'image'">
<img t-att-src="attachment.preview_url" class="img-fluid" alt="Preview"/>
</t>
<t t-elif="previewType == 'pdf'">
<iframe t-att-src="attachment.preview_url" class="w-100" style="height: 70vh;"/>
</t>
<t t-elif="previewType == 'video'">
<video id="preview_video" class="video-js vjs-default-skin" controls="" preload="auto" data-setup='{}'>
<source t-att-src="attachment.preview_url" t-att-type="attachment.mimetype"/>
Your browser does not support the video tag.
</video>
</t>
<t t-elif="previewType == 'text'">
<iframe t-att-src="attachment.preview_url" class="w-100" style="height: 70vh;"/>
</t>
<t t-elif="previewType == 'office'">
<iframe t-att-src="attachment.preview_url" class="w-100" style="height: 70vh;"/>
</t>
<t t-else="">
<div class="alert alert-warning">
Preview not available for this file type.
<a t-att-href="attachment.preview_url" class="btn btn-primary">Download</a>
</div>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- <template id="assets_backend" inherit_id="web.assets_backend" name="universal_preview_assets">-->
<!-- <xpath expr="." position="inside">-->
<!-- &lt;!&ndash; assets loaded via manifest assets key &ndash;&gt;-->
<!-- </xpath>-->
<!-- </template>-->
<template id="universal_attachment_preview_modal">
<div class="universal-preview-modal">
<t t-if="mimetype and mimetype.startswith('image/')">
<img t-att-src="file_url" style="max-width: 100%; max-height: 80vh;" />
</t>
<t t-elif="mimetype == 'application/pdf'">
<iframe t-att-src="file_url" style="width:100%; height:80vh;" frameborder="0"></iframe>
</t>
<t t-elif="mimetype.startswith('video/')">
<video controls="" style="width:100%; height:auto; max-height:80vh;">
<source t-att-src="file_url" t-att-type="mimetype"/>
Your browser does not support the video tag.
</video>
</t>
<t t-elif="mimetype.startswith('audio/')">
<audio controls="">
<source t-att-src="file_url" t-att-type="mimetype"/>
Your browser does not support the audio tag.
</audio>
</t>
<t t-elif="mimetype and mimetype.startswith('text/')">
<iframe t-att-src="file_url" style="width:100%; height:80vh;" frameborder="0"></iframe>
</t>
<t t-else="">
<div>
<p>Preview not available. <a t-att-href="file_url">Download</a> the file to view it locally.</p>
</div>
</t>
</div>
</template>
</odoo>