dolibarr.filearchive/class/actions_filearchiv.class.php
data cbf04e6cc9 FileArchiv: Tile/Gallery view with toggle buttons
- Added formObjectOptions hook for document pages
- CSS/JS injected via DOMContentLoaded for proper timing
- Toggle between list and tile view (localStorage persisted)
- Lightbox for image gallery with keyboard navigation
- Fixed hook contexts (productdocuments, invoicedocuments, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 16:59:19 +01:00

1942 lines
57 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file class/actions_filearchiv.class.php
* \brief Hook actions for FileArchiv module - enhanced file display with gallery view
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php';
class ActionsFilearchiv extends CommonHookActions
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Errors
*/
public $errors = array();
/**
* @var array Hook results
*/
public $results = array();
/**
* @var string Returned HTML content (legacy)
*/
public $resPrint = '';
/**
* @var string Returned HTML content (used by HookManager for addreplace hooks)
*/
public $resprints = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
// Debug: Write to file when class is instantiated - use module dir for debugging
$debugFile = dirname(__FILE__) . '/../debug.log';
@file_put_contents($debugFile, date('Y-m-d H:i:s') . " === ActionsFilearchiv CONSTRUCTOR === PHP_SELF=" . $_SERVER['PHP_SELF'] . "\n", FILE_APPEND);
}
/**
* Hook called on document pages (formObjectOptions)
* Injects the gallery/tile view assets since resPrint is actually output here
*
* @param array $parameters Hook parameters
* @param CommonObject $object Object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior
*/
public function formObjectOptions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
// Only run on document pages
$context = $hookmanager->contextarray;
$isDocumentPage = false;
foreach ($context as $ctx) {
if (strpos($ctx, 'documents') !== false) {
$isDocumentPage = true;
break;
}
}
if (!$isDocumentPage || !isModEnabled('filearchiv')) {
return 0;
}
// Debug logging
$debugFile = dirname(__FILE__) . '/../debug.log';
@file_put_contents($debugFile, date('Y-m-d H:i:s') . " === formObjectOptions CALLED ===\n", FILE_APPEND);
@file_put_contents($debugFile, " context: " . implode(', ', $context) . "\n", FILE_APPEND);
// Inject the gallery assets - they will wait for DOMContentLoaded
$this->resprints = $this->getGalleryAssetsDeferred();
return 0;
}
/**
* Hook to add custom options/buttons to the file list
* Called in FormFile::list_of_documents after file list rendering
*
* @param array $parameters Hook parameters
* @param FormFile $object FormFile object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior, >0 = replace standard behavior
*/
public function formBuilddocLineOptions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
if (!isModEnabled('filearchiv')) {
return 0;
}
$filearray = isset($parameters['filearray']) ? $parameters['filearray'] : array();
$file = isset($parameters['file']) ? $parameters['file'] : array();
if (empty($file) || empty($file['name'])) {
return 0;
}
// Check if this is an image or PDF
$isImage = $this->isImageFile($file['name']);
$isPdf = $this->isPdfFile($file['name']);
if (!$isImage && !$isPdf) {
return 0;
}
// Get pinned status
$isPinned = $this->isFilePinned($file);
// Add pin button
$modulepart = isset($parameters['modulepart']) ? $parameters['modulepart'] : '';
$relativepath = isset($file['relativename']) ? $file['relativename'] : $file['name'];
$pinUrl = $_SERVER['PHP_SELF'] . '?' . $_SERVER['QUERY_STRING'];
$pinUrl .= '&action=filearchiv_togglepin';
$pinUrl .= '&filepath=' . urlencode($relativepath);
$pinUrl .= '&modulepart=' . urlencode($modulepart);
$pinUrl .= '&token=' . newToken();
$pinIcon = $isPinned ? 'fas fa-thumbtack' : 'far fa-thumbtack';
$pinTitle = $isPinned ? $langs->trans('Unpin') : $langs->trans('Pin');
$pinClass = $isPinned ? 'filearchiv-pinned' : 'filearchiv-unpinned';
$this->resprints = '<a href="' . $pinUrl . '" class="' . $pinClass . '" title="' . $pinTitle . '">';
$this->resprints .= '<i class="' . $pinIcon . '"></i>';
$this->resprints .= '</a> ';
return 0;
}
/**
* Hook called before showing the file list
* Used to inject CSS/JS only when needed and handle view mode switching
*
* @param array $parameters Hook parameters
* @param FormFile $object FormFile object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior, >0 = replace standard behavior
*/
public function showFilesList($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs, $user;
// Debug logging
$debugFile = dirname(__FILE__) . '/../debug.log';
@file_put_contents($debugFile, date('Y-m-d H:i:s') . " === showFilesList CALLED ===\n", FILE_APPEND);
@file_put_contents($debugFile, " modulepart: " . (isset($parameters['modulepart']) ? $parameters['modulepart'] : 'none') . "\n", FILE_APPEND);
@file_put_contents($debugFile, " filearray count: " . (isset($parameters['filearray']) ? count($parameters['filearray']) : 0) . "\n", FILE_APPEND);
if (!isModEnabled('filearchiv')) {
@file_put_contents($debugFile, " -> module not enabled\n", FILE_APPEND);
return 0;
}
$filearray = isset($parameters['filearray']) ? $parameters['filearray'] : array();
// Check if there are any images or PDFs
$hasMediaFiles = false;
foreach ($filearray as $file) {
if ($this->isImageFile($file['name']) || $this->isPdfFile($file['name'])) {
$hasMediaFiles = true;
break;
}
}
if (!$hasMediaFiles) {
@file_put_contents($debugFile, " -> no media files found\n", FILE_APPEND);
return 0;
}
@file_put_contents($debugFile, " -> injecting gallery assets\n", FILE_APPEND);
// Inject CSS and JS only when needed
$assets = $this->getInlineAssets($filearray, $parameters);
@file_put_contents($debugFile, " -> assets length: " . strlen($assets) . "\n", FILE_APPEND);
@file_put_contents($debugFile, " -> first 200 chars: " . substr($assets, 0, 200) . "\n", FILE_APPEND);
$this->resprints = $assets;
return 0;
}
/**
* Generate deferred gallery assets that wait for DOM
* Called from formObjectOptions which runs BEFORE the file table exists
*
* @return string HTML with CSS and deferred JS
*/
private function getGalleryAssetsDeferred()
{
$out = '';
// CSS - can be injected immediately
$out .= '<style id="filearchiv-styles">
/* FileArchiv View Toggle */
.filearchiv-view-toggle {
display: inline-flex;
gap: 2px;
margin-left: 10px;
vertical-align: middle;
}
.filearchiv-view-toggle button {
padding: 4px 8px;
border: 1px solid var(--colorborder, #ccc);
background: var(--colorbackbody, #fff);
cursor: pointer;
font-size: 14px;
color: var(--colortextlink, #333);
}
.filearchiv-view-toggle button:first-child {
border-radius: 3px 0 0 3px;
}
.filearchiv-view-toggle button:last-child {
border-radius: 0 3px 3px 0;
}
.filearchiv-view-toggle button.active {
background: var(--colortextlink, #0077b6);
color: #fff;
border-color: var(--colortextlink, #0077b6);
}
.filearchiv-view-toggle button:hover:not(.active) {
background: var(--colorbacklinepairhover, #f0f0f0);
}
/* Tile/Grid View */
.filearchiv-tiles-container {
display: none;
margin-top: 10px;
}
.filearchiv-tiles-container.active {
display: block;
}
.filearchiv-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 10px 0;
}
.filearchiv-tile {
border: 1px solid var(--colorborder, #ddd);
border-radius: 4px;
padding: 8px;
text-align: center;
background: var(--colorbackbody, #fff);
transition: box-shadow 0.2s, border-color 0.2s;
cursor: pointer;
}
.filearchiv-tile:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border-color: var(--colortextlink, #0077b6);
}
.filearchiv-tile .tile-preview {
width: 100%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 6px;
background: var(--colorbacklinepair1, #fafafa);
border-radius: 3px;
}
.filearchiv-tile .tile-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.filearchiv-tile .tile-preview .file-icon {
font-size: 42px;
color: var(--colortextlink, #666);
}
.filearchiv-tile .tile-name {
font-size: 11px;
word-break: break-word;
line-height: 1.3;
max-height: 2.6em;
overflow: hidden;
}
.filearchiv-tile .tile-meta {
font-size: 10px;
color: var(--colortexttitlenotab, #888);
margin-top: 3px;
}
/* Hide standard table when tiles active */
.filearchiv-list-hidden {
display: none !important;
}
/* Lightbox */
.filearchiv-lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
z-index: 10000;
justify-content: center;
align-items: center;
}
.filearchiv-lightbox.active {
display: flex;
}
.filearchiv-lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.filearchiv-lightbox-content img {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
}
.filearchiv-lightbox-close {
position: absolute;
top: -40px;
right: 0;
color: #fff;
font-size: 30px;
cursor: pointer;
}
.filearchiv-lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #fff;
font-size: 40px;
cursor: pointer;
padding: 20px;
}
.filearchiv-lightbox-prev { left: -60px; }
.filearchiv-lightbox-next { right: -60px; }
.filearchiv-lightbox-info {
position: absolute;
bottom: -35px;
left: 0;
right: 0;
text-align: center;
color: #fff;
}
</style>';
// JavaScript - must wait for DOM to find the table
$out .= '<script>
document.addEventListener("DOMContentLoaded", function() {
console.log("FileArchiv: DOMContentLoaded - initializing gallery");
// Find the file list table
var fileTable = document.getElementById("tablelines");
if (!fileTable) {
console.log("FileArchiv: No #tablelines found");
return;
}
console.log("FileArchiv: Found table #tablelines");
// Check if table has files (more than just header row)
var rows = fileTable.querySelectorAll("tr");
if (rows.length < 2) {
console.log("FileArchiv: Table has no file rows");
return;
}
// Find the wrapper div
var tableWrapper = fileTable.closest(".div-table-responsive-no-min") ||
fileTable.closest(".div-table-responsive") ||
fileTable.parentElement;
// Extract file data from table rows
var filesData = [];
var galleryData = [];
var imageIndex = 0;
rows.forEach(function(row, idx) {
if (idx === 0 || row.classList.contains("liste_titre")) return; // Skip header
var link = row.querySelector("a[href*=\'viewimage\'], a[href*=\'document.php\']");
if (!link) return;
var filename = link.textContent.trim() || link.getAttribute("title") || "";
var href = link.getAttribute("href") || "";
var isImage = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(filename);
var fileData = {
name: filename,
url: href,
isImage: isImage,
icon: isImage ? "fa-file-image" : "fa-file"
};
if (isImage) {
fileData.galleryIndex = imageIndex;
galleryData.push({
index: imageIndex,
name: filename,
path: href
});
imageIndex++;
}
filesData.push(fileData);
});
if (filesData.length === 0) {
console.log("FileArchiv: No files extracted from table");
return;
}
console.log("FileArchiv: Found " + filesData.length + " files, " + galleryData.length + " images");
// Create toggle buttons
var toggleHtml = \'<div class="filearchiv-view-toggle-wrapper" style="margin-bottom:8px; text-align:right;">\' +
\'<span class="filearchiv-view-toggle">\' +
\'<button type="button" class="btn-list" title="Listenansicht"><i class="fas fa-list"></i></button>\' +
\'<button type="button" class="btn-tiles" title="Kachelansicht"><i class="fas fa-th"></i></button>\' +
\'</span></div>\';
tableWrapper.insertAdjacentHTML("beforebegin", toggleHtml);
// Create tiles container
var tilesContainer = document.createElement("div");
tilesContainer.className = "filearchiv-tiles-container";
tilesContainer.id = "filearchiv-tiles";
var tilesHtml = \'<div class="filearchiv-tiles">\';
filesData.forEach(function(file, idx) {
var previewHtml = file.isImage
? \'<img src="\' + file.url + \'" alt="\' + file.name + \'" loading="lazy">\'
: \'<i class="fas \' + file.icon + \' file-icon"></i>\';
tilesHtml += \'<div class="filearchiv-tile" data-index="\' + idx + \'" data-url="\' + file.url + \'" data-isimage="\' + (file.isImage ? "1" : "0") + \'" data-gallery-index="\' + (file.galleryIndex !== undefined ? file.galleryIndex : -1) + \'">\' +
\'<div class="tile-preview">\' + previewHtml + \'</div>\' +
\'<div class="tile-name">\' + file.name + \'</div>\' +
\'</div>\';
});
tilesHtml += \'</div>\';
tilesContainer.innerHTML = tilesHtml;
tableWrapper.insertAdjacentElement("afterend", tilesContainer);
// Create lightbox
if (document.getElementById("filearchiv-lightbox")) return; // Already exists
var lightboxHtml = \'<div class="filearchiv-lightbox" id="filearchiv-lightbox">\' +
\'<div class="filearchiv-lightbox-content">\' +
\'<span class="filearchiv-lightbox-close">&times;</span>\' +
\'<span class="filearchiv-lightbox-nav filearchiv-lightbox-prev">&lsaquo;</span>\' +
\'<img id="filearchiv-lightbox-img" src="" alt="">\' +
\'<span class="filearchiv-lightbox-nav filearchiv-lightbox-next">&rsaquo;</span>\' +
\'<div class="filearchiv-lightbox-info"><span id="filearchiv-lightbox-counter"></span> - <span id="filearchiv-lightbox-name"></span></div>\' +
\'</div></div>\';
document.body.insertAdjacentHTML("beforeend", lightboxHtml);
var lightbox = document.getElementById("filearchiv-lightbox");
var lightboxImg = document.getElementById("filearchiv-lightbox-img");
var currentIndex = 0;
var currentView = localStorage.getItem("filearchiv_view") || "list";
window.FileArchivGallery = {
open: function(index) {
if (galleryData.length === 0) return;
currentIndex = index;
this.updateImage();
lightbox.classList.add("active");
document.body.style.overflow = "hidden";
},
close: function() {
lightbox.classList.remove("active");
document.body.style.overflow = "";
},
prev: function() {
currentIndex = (currentIndex - 1 + galleryData.length) % galleryData.length;
this.updateImage();
},
next: function() {
currentIndex = (currentIndex + 1) % galleryData.length;
this.updateImage();
},
updateImage: function() {
var item = galleryData[currentIndex];
lightboxImg.src = item.path;
document.getElementById("filearchiv-lightbox-counter").textContent = (currentIndex + 1) + " / " + galleryData.length;
document.getElementById("filearchiv-lightbox-name").textContent = item.name;
}
};
// Lightbox events
lightbox.querySelector(".filearchiv-lightbox-close").onclick = function() { FileArchivGallery.close(); };
lightbox.querySelector(".filearchiv-lightbox-prev").onclick = function() { FileArchivGallery.prev(); };
lightbox.querySelector(".filearchiv-lightbox-next").onclick = function() { FileArchivGallery.next(); };
lightbox.onclick = function(e) { if (e.target === lightbox) FileArchivGallery.close(); };
document.addEventListener("keydown", function(e) {
if (!lightbox.classList.contains("active")) return;
if (e.key === "Escape") FileArchivGallery.close();
if (e.key === "ArrowLeft") FileArchivGallery.prev();
if (e.key === "ArrowRight") FileArchivGallery.next();
});
// View toggle
function setView(view) {
currentView = view;
localStorage.setItem("filearchiv_view", view);
var btnList = document.querySelector(".filearchiv-view-toggle .btn-list");
var btnTiles = document.querySelector(".filearchiv-view-toggle .btn-tiles");
if (view === "tiles") {
btnList.classList.remove("active");
btnTiles.classList.add("active");
tableWrapper.classList.add("filearchiv-list-hidden");
tilesContainer.classList.add("active");
} else {
btnList.classList.add("active");
btnTiles.classList.remove("active");
tableWrapper.classList.remove("filearchiv-list-hidden");
tilesContainer.classList.remove("active");
}
}
document.querySelector(".filearchiv-view-toggle .btn-list").onclick = function() { setView("list"); };
document.querySelector(".filearchiv-view-toggle .btn-tiles").onclick = function() { setView("tiles"); };
// Tile click events
tilesContainer.querySelectorAll(".filearchiv-tile").forEach(function(tile) {
tile.onclick = function() {
var isImage = tile.dataset.isimage === "1";
var galleryIndex = parseInt(tile.dataset.galleryIndex);
if (isImage && galleryIndex >= 0) {
FileArchivGallery.open(galleryIndex);
} else {
window.open(tile.dataset.url, "_blank");
}
};
});
// Initialize view
setView(currentView);
console.log("FileArchiv: Gallery initialized");
});
</script>';
return $out;
}
/**
* Generate inline CSS and JS assets with toggle for tile view
*
* @param array $filearray Array of files
* @param array $parameters Hook parameters
* @return string HTML with inline CSS and JS
*/
private function getInlineAssets($filearray, $parameters)
{
global $conf, $langs;
$langs->load('filearchiv@filearchiv');
$modulepart = isset($parameters['modulepart']) ? $parameters['modulepart'] : '';
// Build file data for all files
$filesData = array();
$galleryData = array();
$imageIndex = 0;
foreach ($filearray as $file) {
$isImage = $this->isImageFile($file['name']);
$isPdf = $this->isPdfFile($file['name']);
$fileData = array(
'name' => $file['name'],
'size' => isset($file['size']) ? dol_print_size($file['size']) : '',
'date' => isset($file['date']) ? dol_print_date($file['date'], 'day') : '',
'url' => $this->getFileUrl($file, $modulepart),
'isImage' => $isImage,
'isPdf' => $isPdf,
'icon' => $this->getFileIcon($file['name']),
);
// Add thumbnail for images
if ($isImage) {
$fileData['thumb'] = $this->getThumbUrl($file, $modulepart);
$fileData['galleryIndex'] = $imageIndex;
$galleryData[] = array(
'index' => $imageIndex,
'name' => $file['name'],
'path' => $this->getFileUrl($file, $modulepart),
);
$imageIndex++;
}
$filesData[] = $fileData;
}
$out = '';
// CSS
$out .= '<style id="filearchiv-styles">
/* FileArchiv View Toggle */
.filearchiv-view-toggle {
display: inline-flex;
gap: 2px;
margin-left: 10px;
vertical-align: middle;
}
.filearchiv-view-toggle button {
padding: 4px 8px;
border: 1px solid var(--colorborder, #ccc);
background: var(--colorbackbody, #fff);
cursor: pointer;
font-size: 14px;
color: var(--colortextlink, #333);
}
.filearchiv-view-toggle button:first-child {
border-radius: 3px 0 0 3px;
}
.filearchiv-view-toggle button:last-child {
border-radius: 0 3px 3px 0;
}
.filearchiv-view-toggle button.active {
background: var(--colortextlink, #0077b6);
color: #fff;
border-color: var(--colortextlink, #0077b6);
}
.filearchiv-view-toggle button:hover:not(.active) {
background: var(--colorbacklinepairhover, #f0f0f0);
}
/* Tile/Grid View */
.filearchiv-tiles-container {
display: none;
margin-top: 10px;
}
.filearchiv-tiles-container.active {
display: block;
}
.filearchiv-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 10px 0;
}
.filearchiv-tile {
border: 1px solid var(--colorborder, #ddd);
border-radius: 4px;
padding: 8px;
text-align: center;
background: var(--colorbackbody, #fff);
transition: box-shadow 0.2s, border-color 0.2s;
cursor: pointer;
position: relative;
}
.filearchiv-tile:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
border-color: var(--colortextlink, #0077b6);
}
.filearchiv-tile .tile-preview {
width: 100%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 6px;
background: var(--colorbacklinepair1, #fafafa);
border-radius: 3px;
}
.filearchiv-tile .tile-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.filearchiv-tile .tile-preview .file-icon {
font-size: 42px;
color: var(--colortextlink, #666);
}
.filearchiv-tile .tile-preview .file-icon.fa-file-pdf {
color: #dc3545;
}
.filearchiv-tile .tile-preview .file-icon.fa-file-word {
color: #2b579a;
}
.filearchiv-tile .tile-preview .file-icon.fa-file-excel {
color: #217346;
}
.filearchiv-tile .tile-preview .file-icon.fa-file-image {
color: #6c5ce7;
}
.filearchiv-tile .tile-name {
font-size: 11px;
word-break: break-word;
color: var(--colortexttitle, #333);
line-height: 1.3;
max-height: 2.6em;
overflow: hidden;
}
.filearchiv-tile .tile-meta {
font-size: 10px;
color: var(--colortexttitlenotab, #888);
margin-top: 3px;
}
/* Hide standard table when tiles active */
.filearchiv-list-hidden {
display: none !important;
}
/* Lightbox */
.filearchiv-lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
z-index: 10000;
justify-content: center;
align-items: center;
}
.filearchiv-lightbox.active {
display: flex;
}
.filearchiv-lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.filearchiv-lightbox-content img {
max-width: 100%;
max-height: 85vh;
object-fit: contain;
}
.filearchiv-lightbox-close {
position: absolute;
top: -40px;
right: 0;
color: #fff;
font-size: 30px;
cursor: pointer;
padding: 5px 10px;
}
.filearchiv-lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #fff;
font-size: 40px;
cursor: pointer;
padding: 20px;
user-select: none;
}
.filearchiv-lightbox-nav:hover {
color: #0077b6;
}
.filearchiv-lightbox-prev { left: -60px; }
.filearchiv-lightbox-next { right: -60px; }
.filearchiv-lightbox-info {
position: absolute;
bottom: -35px;
left: 0;
right: 0;
text-align: center;
color: #fff;
font-size: 14px;
}
.filearchiv-lightbox-zoom {
position: absolute;
bottom: -35px;
right: 0;
display: flex;
gap: 10px;
}
.filearchiv-lightbox-zoom button {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
padding: 5px 10px;
cursor: pointer;
border-radius: 3px;
}
.filearchiv-lightbox-zoom button:hover {
background: rgba(255,255,255,0.4);
}
</style>';
// JavaScript
$filesJson = json_encode($filesData);
$galleryJson = json_encode($galleryData);
$out .= '<script>
(function() {
"use strict";
console.log("FileArchiv: Script loaded");
var filesData = ' . $filesJson . ';
var galleryData = ' . $galleryJson . ';
var currentView = localStorage.getItem("filearchiv_view") || "list";
var currentIndex = 0;
var currentZoom = 1;
// Find the file list table - look for #tablelines or table.liste inside div-table-responsive
var fileTable = document.getElementById("tablelines") ||
document.querySelector(".div-table-responsive-no-min table.liste") ||
document.querySelector(".div-table-responsive table.liste") ||
document.querySelector("table.liste.formdoc");
console.log("FileArchiv: Looking for table, found:", fileTable);
if (!fileTable) {
console.log("FileArchiv: No file table found - will retry on DOMContentLoaded");
document.addEventListener("DOMContentLoaded", initFileArchiv);
return;
}
initFileArchiv();
function initFileArchiv() {
fileTable = document.getElementById("tablelines") ||
document.querySelector(".div-table-responsive-no-min table.liste") ||
document.querySelector(".div-table-responsive table.liste") ||
document.querySelector("table.liste.formdoc");
if (!fileTable) {
console.log("FileArchiv: Still no file table found");
return;
}
console.log("FileArchiv: Found file table", fileTable.id || fileTable.className);
// Find the wrapper div
var tableWrapper = fileTable.closest(".div-table-responsive-no-min") ||
fileTable.closest(".div-table-responsive") ||
fileTable.parentElement;
// Create toggle buttons
var toggleHtml = \'<div class="filearchiv-view-toggle-wrapper" style="margin-bottom:8px; text-align:right;">\' +
\'<span class="filearchiv-view-toggle">\' +
\'<button type="button" class="btn-list" title="Listenansicht"><i class="fas fa-list"></i></button>\' +
\'<button type="button" class="btn-tiles" title="Kachelansicht"><i class="fas fa-th"></i></button>\' +
\'</span></div>\';
// Insert toggle before the table wrapper
tableWrapper.insertAdjacentHTML("beforebegin", toggleHtml);
console.log("FileArchiv: Toggle buttons inserted");
// Create tiles container
var tilesContainer = document.createElement("div");
tilesContainer.className = "filearchiv-tiles-container";
tilesContainer.id = "filearchiv-tiles";
var tilesHtml = \'<div class="filearchiv-tiles">\';
filesData.forEach(function(file, idx) {
var previewHtml = "";
if (file.isImage && file.thumb) {
previewHtml = \'<img src="\' + file.thumb + \'" alt="\' + file.name + \'" loading="lazy">\';
} else {
previewHtml = \'<i class="fas \' + file.icon + \' file-icon"></i>\';
}
tilesHtml += \'<div class="filearchiv-tile" data-index="\' + idx + \'" data-url="\' + file.url + \'" data-isimage="\' + (file.isImage ? "1" : "0") + \'" data-gallery-index="\' + (file.galleryIndex !== undefined ? file.galleryIndex : -1) + \'">\' +
\'<div class="tile-preview">\' + previewHtml + \'</div>\' +
\'<div class="tile-name">\' + file.name + \'</div>\' +
\'<div class="tile-meta">\' + file.size + \'</div>\' +
\'</div>\';
});
tilesHtml += \'</div>\';
tilesContainer.innerHTML = tilesHtml;
// Insert tiles container after table wrapper
tableWrapper.insertAdjacentElement("afterend", tilesContainer);
console.log("FileArchiv: Tiles container inserted");
// Create lightbox
var lightboxHtml = \'<div class="filearchiv-lightbox" id="filearchiv-lightbox">\' +
\'<div class="filearchiv-lightbox-content">\' +
\'<span class="filearchiv-lightbox-close">&times;</span>\' +
\'<span class="filearchiv-lightbox-nav filearchiv-lightbox-prev">&lsaquo;</span>\' +
\'<img id="filearchiv-lightbox-img" src="" alt="">\' +
\'<span class="filearchiv-lightbox-nav filearchiv-lightbox-next">&rsaquo;</span>\' +
\'<div class="filearchiv-lightbox-info"><span id="filearchiv-lightbox-counter"></span> - <span id="filearchiv-lightbox-name"></span></div>\' +
\'<div class="filearchiv-lightbox-zoom">\' +
\'<button type="button" onclick="FileArchivGallery.zoomOut()">-</button>\' +
\'<button type="button" onclick="FileArchivGallery.zoomReset()">100%</button>\' +
\'<button type="button" onclick="FileArchivGallery.zoomIn()">+</button>\' +
\'</div>\' +
\'</div>\' +
\'</div>\';
document.body.insertAdjacentHTML("beforeend", lightboxHtml);
var lightbox = document.getElementById("filearchiv-lightbox");
var lightboxImg = document.getElementById("filearchiv-lightbox-img");
var lightboxCounter = document.getElementById("filearchiv-lightbox-counter");
var lightboxName = document.getElementById("filearchiv-lightbox-name");
// Gallery functions
window.FileArchivGallery = {
open: function(index) {
if (galleryData.length === 0) return;
currentIndex = index;
currentZoom = 1;
this.updateImage();
lightbox.classList.add("active");
document.body.style.overflow = "hidden";
},
close: function() {
lightbox.classList.remove("active");
document.body.style.overflow = "";
},
prev: function() {
currentIndex = (currentIndex - 1 + galleryData.length) % galleryData.length;
currentZoom = 1;
this.updateImage();
},
next: function() {
currentIndex = (currentIndex + 1) % galleryData.length;
currentZoom = 1;
this.updateImage();
},
updateImage: function() {
var item = galleryData[currentIndex];
lightboxImg.src = item.path;
lightboxImg.style.transform = "scale(" + currentZoom + ")";
lightboxCounter.textContent = (currentIndex + 1) + " / " + galleryData.length;
lightboxName.textContent = item.name;
},
zoomIn: function() {
currentZoom = Math.min(currentZoom + 0.25, 3);
lightboxImg.style.transform = "scale(" + currentZoom + ")";
},
zoomOut: function() {
currentZoom = Math.max(currentZoom - 0.25, 0.5);
lightboxImg.style.transform = "scale(" + currentZoom + ")";
},
zoomReset: function() {
currentZoom = 1;
lightboxImg.style.transform = "scale(1)";
}
};
// Lightbox event listeners
lightbox.querySelector(".filearchiv-lightbox-close").addEventListener("click", function() {
FileArchivGallery.close();
});
lightbox.querySelector(".filearchiv-lightbox-prev").addEventListener("click", function() {
FileArchivGallery.prev();
});
lightbox.querySelector(".filearchiv-lightbox-next").addEventListener("click", function() {
FileArchivGallery.next();
});
lightbox.addEventListener("click", function(e) {
if (e.target === lightbox) FileArchivGallery.close();
});
// Keyboard navigation
document.addEventListener("keydown", function(e) {
if (!lightbox.classList.contains("active")) return;
if (e.key === "Escape") FileArchivGallery.close();
if (e.key === "ArrowLeft") FileArchivGallery.prev();
if (e.key === "ArrowRight") FileArchivGallery.next();
});
// View toggle functions
function setView(view) {
currentView = view;
localStorage.setItem("filearchiv_view", view);
var btnList = document.querySelector(".filearchiv-view-toggle .btn-list");
var btnTiles = document.querySelector(".filearchiv-view-toggle .btn-tiles");
if (view === "tiles") {
btnList?.classList.remove("active");
btnTiles?.classList.add("active");
tableWrapper.classList.add("filearchiv-list-hidden");
tilesContainer.classList.add("active");
} else {
btnList?.classList.add("active");
btnTiles?.classList.remove("active");
tableWrapper.classList.remove("filearchiv-list-hidden");
tilesContainer.classList.remove("active");
}
}
// Toggle button events
document.querySelector(".filearchiv-view-toggle .btn-list")?.addEventListener("click", function() {
setView("list");
});
document.querySelector(".filearchiv-view-toggle .btn-tiles")?.addEventListener("click", function() {
setView("tiles");
});
// Tile click events
tilesContainer.querySelectorAll(".filearchiv-tile").forEach(function(tile) {
tile.addEventListener("click", function() {
var isImage = tile.dataset.isimage === "1";
var galleryIndex = parseInt(tile.dataset.galleryIndex);
var url = tile.dataset.url;
if (isImage && galleryIndex >= 0) {
FileArchivGallery.open(galleryIndex);
} else {
window.open(url, "_blank");
}
});
});
// Initialize view
setView(currentView);
} // end initFileArchiv
})();
</script>';
return $out;
}
/**
* Get Font Awesome icon class for file type
*
* @param string $filename Filename
* @return string Icon class
*/
private function getFileIcon($filename)
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$icons = array(
'pdf' => 'fa-file-pdf',
'doc' => 'fa-file-word',
'docx' => 'fa-file-word',
'xls' => 'fa-file-excel',
'xlsx' => 'fa-file-excel',
'ppt' => 'fa-file-powerpoint',
'pptx' => 'fa-file-powerpoint',
'jpg' => 'fa-file-image',
'jpeg' => 'fa-file-image',
'png' => 'fa-file-image',
'gif' => 'fa-file-image',
'webp' => 'fa-file-image',
'bmp' => 'fa-file-image',
'svg' => 'fa-file-image',
'zip' => 'fa-file-archive',
'rar' => 'fa-file-archive',
'7z' => 'fa-file-archive',
'tar' => 'fa-file-archive',
'gz' => 'fa-file-archive',
'txt' => 'fa-file-alt',
'csv' => 'fa-file-csv',
'xml' => 'fa-file-code',
'json' => 'fa-file-code',
'html' => 'fa-file-code',
'htm' => 'fa-file-code',
'mp3' => 'fa-file-audio',
'wav' => 'fa-file-audio',
'mp4' => 'fa-file-video',
'avi' => 'fa-file-video',
'mov' => 'fa-file-video',
);
return isset($icons[$ext]) ? $icons[$ext] : 'fa-file';
}
/**
* Get full URL for a file
*
* @param array $file File array
* @param string $modulepart Module part
* @return string URL
*/
private function getFileUrl($file, $modulepart)
{
global $conf;
$relativepath = isset($file['relativename']) ? $file['relativename'] : $file['name'];
return DOL_URL_ROOT . '/document.php?modulepart=' . urlencode($modulepart) . '&file=' . urlencode($relativepath);
}
/**
* Get thumbnail URL for a file
*
* @param array $file File array
* @param string $modulepart Module part
* @return string Thumbnail URL
*/
private function getThumbUrl($file, $modulepart)
{
// For now, use the same as file URL
// Could be enhanced to use actual thumbnails
return $this->getFileUrl($file, $modulepart);
}
/**
* Check if file is an image
*
* @param string $filename Filename
* @return bool
*/
private function isImageFile($filename)
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, array('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'));
}
/**
* Check if file is a PDF
*
* @param string $filename Filename
* @return bool
*/
private function isPdfFile($filename)
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return $ext === 'pdf';
}
/**
* Check if file is pinned
*
* @param array $file File array
* @return bool
*/
private function isFilePinned($file)
{
// Will be implemented with database table
// For now return false
$filepath = isset($file['fullname']) ? $file['fullname'] : '';
if (empty($filepath)) {
return false;
}
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "filearchiv_pinned";
$sql .= " WHERE filepath = '" . $this->db->escape($filepath) . "'";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
return true;
}
return false;
}
/**
* Toggle pin status for a file
*
* @param string $filepath Full file path
* @param string $modulepart Module part
* @return bool Success
*/
public function togglePin($filepath, $modulepart)
{
global $user;
if ($this->isFilePinnedByPath($filepath)) {
// Unpin
$sql = "DELETE FROM " . MAIN_DB_PREFIX . "filearchiv_pinned";
$sql .= " WHERE filepath = '" . $this->db->escape($filepath) . "'";
} else {
// Pin
$sql = "INSERT INTO " . MAIN_DB_PREFIX . "filearchiv_pinned";
$sql .= " (filepath, modulepart, fk_user, datec)";
$sql .= " VALUES ('" . $this->db->escape($filepath) . "',";
$sql .= " '" . $this->db->escape($modulepart) . "',";
$sql .= " " . ((int) $user->id) . ",";
$sql .= " '" . $this->db->idate(dol_now()) . "')";
}
$resql = $this->db->query($sql);
return $resql ? true : false;
}
/**
* Check if file is pinned by path
*
* @param string $filepath File path
* @return bool
*/
private function isFilePinnedByPath($filepath)
{
$sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "filearchiv_pinned";
$sql .= " WHERE filepath = '" . $this->db->escape($filepath) . "'";
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
return true;
}
return false;
}
/**
* Hook called when FormMail generates the email form
* This hook is called with initHooks(['formmail']) context
*
* @param array $parameters Hook parameters (addfileaction, removefileaction, trackid)
* @param FormMail $object FormMail object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior (add resPrint to output), >0 = replace entire form
*/
public function getFormMail($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
// DEBUG - ALWAYS write first
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " === getFormMail ENTRY ===\n", FILE_APPEND);
// Get action from URL
$currentAction = GETPOST('action', 'aZ09');
$trackid = isset($parameters['trackid']) ? $parameters['trackid'] : '';
$id = GETPOST('id', 'int');
// DEBUG - detailed info
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " getFormMail called, action=$currentAction, trackid=$trackid, id=$id, PHP_SELF=" . $_SERVER['PHP_SELF'] . "\n", FILE_APPEND);
if (!isModEnabled('filearchiv')) {
file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " -> module not enabled\n", FILE_APPEND);
return 0;
}
// Only show on presend action
if ($currentAction != 'presend') {
file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " -> action is not presend, skipping\n", FILE_APPEND);
return 0;
}
// Get element and ID from URL/POST
$element = GETPOST('element', 'aZ09');
$id = GETPOST('id', 'int');
// Try to determine element from trackid pattern
$trackid = isset($parameters['trackid']) ? $parameters['trackid'] : '';
if (empty($element) && !empty($trackid)) {
// Trackid format: pro123 (propal), ord123 (order), inv123 (invoice), shi123 (shipping)
$patterns = array(
'pro' => 'propal',
'ord' => 'commande',
'inv' => 'facture',
'shi' => 'expedition',
'sor' => 'order_supplier',
'sin' => 'invoice_supplier',
);
foreach ($patterns as $prefix => $elemType) {
if (strpos($trackid, $prefix) === 0) {
$element = $elemType;
$id = (int) substr($trackid, strlen($prefix));
break;
}
}
}
// Try to get from the current page URL
if (empty($element)) {
$script = basename($_SERVER['PHP_SELF']);
$pageMap = array(
'card.php' => array(
'/comm/propal/' => 'propal',
'/commande/' => 'commande',
'/compta/facture/' => 'facture',
'/expedition/' => 'expedition',
),
);
if (isset($pageMap[$script])) {
foreach ($pageMap[$script] as $path => $elemType) {
if (strpos($_SERVER['PHP_SELF'], $path) !== false) {
$element = $elemType;
break;
}
}
}
}
if (empty($element) || empty($id)) {
return 0;
}
// Load translations
$langs->load('filearchiv@filearchiv');
// Build the document browser button and modal HTML
$out = $this->getDocumentBrowserHTML($element, $id, $trackid);
$this->resprints = $out;
return 0; // Return 0 to add our HTML without replacing the form
}
/**
* Hook called on formConfirm - adds document browser for presend action
* This hook is called early on the page and allows adding HTML that will be output
*
* @param array $parameters Hook parameters
* @param CommonObject $object Current object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior (add resPrint), >0 = replace
*/
/**
* Hook called at the beginning of each page (doActions)
* Used to inject document browser HTML on presend pages
*
* @param array $parameters Hook parameters
* @param CommonObject $object Current object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior
*/
public function doActions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
// Debug log
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " doActions called, action=" . GETPOST('action', 'aZ09') . "\n", FILE_APPEND);
return 0;
}
public function formConfirm($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
// Always write to debug log to confirm hook is being called
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " formConfirm called, action=" . GETPOST('action', 'aZ09') . "\n", FILE_APPEND);
if (!isModEnabled('filearchiv')) {
return 0;
}
// Only show on presend action
$currentAction = GETPOST('action', 'aZ09');
if ($currentAction != 'presend') {
return 0;
}
// Get current object info
$element = '';
$id = 0;
if (is_object($object) && !empty($object->element)) {
$element = $object->element;
$id = $object->id;
}
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " formConfirm presend: element=$element, id=$id\n", FILE_APPEND);
if (empty($element) || empty($id)) {
return 0;
}
// Check if this element type is supported
$supportedElements = array('propal', 'commande', 'facture', 'expedition', 'order_supplier', 'invoice_supplier');
if (!in_array($element, $supportedElements)) {
return 0;
}
// Load translations
$langs->load('filearchiv@filearchiv');
// Build the document browser button and modal HTML
$out = $this->getDocumentBrowserHTML($element, $id, '');
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " formConfirm injecting HTML, length=" . strlen($out) . "\n", FILE_APPEND);
$this->resprints = $out;
return 0;
}
/**
* Hook called after action buttons are displayed
* This hook is reliably called on all card pages
*
* @param array $parameters Hook parameters
* @param CommonObject $object Current object
* @param string $action Current action
* @param HookManager $hookmanager Hook manager
* @return int 0 = keep standard behavior
*/
public function addMoreActionsButtons($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
if (!isModEnabled('filearchiv')) {
return 0;
}
// Only show on presend action
$currentAction = GETPOST('action', 'aZ09');
if ($currentAction != 'presend') {
return 0;
}
// Get current object info
$element = '';
$id = 0;
if (is_object($object) && !empty($object->element)) {
$element = $object->element;
$id = $object->id;
}
if (empty($element) || empty($id)) {
return 0;
}
// Check if this element type is supported
$supportedElements = array('propal', 'commande', 'facture', 'expedition', 'order_supplier', 'invoice_supplier');
if (!in_array($element, $supportedElements)) {
return 0;
}
// Load translations
$langs->load('filearchiv@filearchiv');
// Build the document browser button and modal HTML
$this->resprints = $this->getDocumentBrowserHTML($element, $id, '');
return 0;
}
/**
* Generate HTML for document browser button and modal
*
* @param string $element Element type (facture, propal, commande, etc.)
* @param int $id Element ID
* @param string $trackid Email tracking ID
* @return string HTML output
*/
private function getDocumentBrowserHTML($element, $id, $trackid)
{
global $conf, $langs;
// Use custom module path for AJAX URLs
$ajaxUrl = dol_buildpath('/custom/filearchiv/ajax/getdocuments.php', 1);
$addFileUrl = dol_buildpath('/custom/filearchiv/ajax/addfile.php', 1);
// Debug: Log the URLs
@file_put_contents('/tmp/filearchiv_debug.log', date('Y-m-d H:i:s') . " ajaxUrl=$ajaxUrl, addFileUrl=$addFileUrl\n", FILE_APPEND);
$out = '';
// CSS for document browser - using Dolibarr native styles where possible
$out .= '
<style id="filearchiv-docbrowser-styles">
/* Document Browser Modal - Dolibarr style */
.filearchiv-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
justify-content: center;
align-items: center;
}
.filearchiv-modal-overlay.active {
display: flex;
}
.filearchiv-modal {
background: var(--colorbackbody, #fff);
border-radius: 3px;
width: 90%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
border: 1px solid var(--colorborder, #bbb);
}
.filearchiv-modal-header {
padding: 10px 15px;
border-bottom: 1px solid var(--colorborder, #bbb);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--colorbacktitle1, #f0f0f0);
}
.filearchiv-modal-header h3 {
margin: 0;
font-size: 14px;
font-weight: normal;
color: var(--colortexttitle, #333);
}
.filearchiv-modal-close {
font-size: 20px;
cursor: pointer;
color: var(--colortextlink, #333);
line-height: 1;
padding: 0 5px;
}
.filearchiv-modal-close:hover {
color: var(--colortextbackhmenu, #000);
}
.filearchiv-modal-body {
padding: 15px;
overflow-y: auto;
flex: 1;
background: var(--colorbackbody, #fff);
}
.filearchiv-modal-footer {
padding: 10px 15px;
border-top: 1px solid var(--colorborder, #bbb);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--colorbacktitle1, #f0f0f0);
gap: 10px;
}
.filearchiv-category {
margin-bottom: 10px;
border: 1px solid var(--colorborder, #ccc);
border-radius: 3px;
}
.filearchiv-category-header {
padding: 8px 12px;
background: var(--colorbacktitle1, #f8f8f8);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--colortexttitle, #333);
font-size: 13px;
}
.filearchiv-category-header:hover {
background: var(--colorbacklinepairhover, #eee);
}
.filearchiv-category-header i {
width: 16px;
text-align: center;
color: var(--colortextlink, #444);
}
.filearchiv-category-header .count {
margin-left: auto;
background: var(--colortextlink, #444);
color: #fff;
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
font-weight: normal;
}
.filearchiv-category-header .toggle-icon {
margin-left: 5px;
transition: transform 0.2s;
}
.filearchiv-category-body {
display: none;
padding: 8px 12px;
background: var(--colorbackbody, #fff);
}
.filearchiv-category.open .filearchiv-category-body {
display: block;
}
.filearchiv-category.open .toggle-icon {
transform: rotate(180deg);
}
.filearchiv-file-item {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid var(--colorbacklinebreak, #f0f0f0);
gap: 8px;
}
.filearchiv-file-item:last-child {
border-bottom: none;
}
.filearchiv-file-item:hover {
background: var(--colorbacklinepairhover, #f5f5f5);
}
.filearchiv-file-item input[type="checkbox"] {
cursor: pointer;
}
.filearchiv-file-item .file-icon {
font-size: 16px;
width: 20px;
text-align: center;
color: var(--colortextlink, #444);
}
.filearchiv-file-item .file-info {
flex: 1;
min-width: 0;
}
.filearchiv-file-item .file-name {
font-size: 13px;
color: var(--colortextlink, #333);
word-break: break-word;
}
.filearchiv-file-item .file-meta {
font-size: 11px;
color: var(--colortexttitlenotab, #888);
margin-top: 2px;
}
.filearchiv-file-item .file-source {
font-size: 10px;
color: var(--colortextlink, #444);
background: var(--colorbacklinepair1, #f0f0f0);
padding: 1px 5px;
border-radius: 2px;
margin-top: 2px;
display: inline-block;
}
.filearchiv-selected-count {
font-size: 13px;
color: var(--colortexttitle, #333);
}
.filearchiv-loading {
text-align: center;
padding: 30px;
color: var(--colortexttitlenotab, #666);
}
.filearchiv-loading i {
font-size: 24px;
margin-bottom: 8px;
display: block;
}
.filearchiv-loading i.fa-spinner {
animation: filearchiv-spin 1s linear infinite;
}
@keyframes filearchiv-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.filearchiv-empty {
text-align: center;
padding: 20px;
color: var(--colortexttitlenotab, #888);
font-size: 13px;
}
.filearchiv-browse-btn {
margin-left: 5px;
}
</style>';
// Button to open document browser (hidden initially, will be moved by JS)
$out .= '
<div id="filearchiv-browse-container" style="display: none; margin-top: 5px;">
<input type="button" class="button smallpaddingimp filearchiv-browse-btn" onclick="FileArchivDocBrowser.open()" value="' . dol_escape_htmltag($langs->trans('BrowseRelatedDocuments')) . '">
</div>';
// Modal HTML
$out .= '
<div class="filearchiv-modal-overlay" id="filearchiv-docbrowser-modal">
<div class="filearchiv-modal">
<div class="filearchiv-modal-header">
<h3><i class="fas fa-folder-open paddingright"></i>' . $langs->trans('SelectDocumentsToAttach') . '</h3>
<span class="filearchiv-modal-close" onclick="FileArchivDocBrowser.close()">&times;</span>
</div>
<div class="filearchiv-modal-body" id="filearchiv-docbrowser-content">
<div class="filearchiv-loading">
<i class="fas fa-spinner fa-spin"></i>
<div>' . $langs->trans('Loading') . '...</div>
</div>
</div>
<div class="filearchiv-modal-footer">
<span class="filearchiv-selected-count">
<span id="filearchiv-selected-num">0</span> ' . $langs->trans('FilesSelected') . '
</span>
<div style="display: flex; gap: 5px;">
<input type="button" class="button button-cancel" onclick="FileArchivDocBrowser.close()" value="' . dol_escape_htmltag($langs->trans('Cancel')) . '">
<input type="button" class="button button-add" id="filearchiv-add-btn" onclick="FileArchivDocBrowser.addSelected()" disabled value="' . dol_escape_htmltag($langs->trans('AddSelectedFiles')) . '">
</div>
</div>
</div>
</div>';
// JavaScript
$out .= '
<script>
var FileArchivDocBrowser = {
element: "' . dol_escape_js($element) . '",
id: ' . ((int) $id) . ',
trackid: "' . dol_escape_js($trackid) . '",
ajaxUrl: "' . dol_escape_js($ajaxUrl) . '",
addFileUrl: "' . dol_escape_js($addFileUrl) . '",
token: "' . newToken() . '",
loaded: false,
selectedFiles: [],
open: function() {
document.getElementById("filearchiv-docbrowser-modal").classList.add("active");
document.body.style.overflow = "hidden";
if (!this.loaded) {
this.loadDocuments();
}
},
close: function() {
document.getElementById("filearchiv-docbrowser-modal").classList.remove("active");
document.body.style.overflow = "";
},
loadDocuments: function() {
var self = this;
var url = this.ajaxUrl + "?element=" + this.element + "&id=" + this.id + "&token=" + this.token;
console.log("FileArchiv: Loading documents from URL:", url);
console.log("FileArchiv: element=" + this.element + ", id=" + this.id);
fetch(url)
.then(function(response) {
console.log("FileArchiv: Response status:", response.status);
return response.text();
})
.then(function(text) {
console.log("FileArchiv: Response text:", text.substring(0, 500));
try {
var data = JSON.parse(text);
self.loaded = true;
self.renderDocuments(data);
} catch (e) {
console.error("FileArchiv: JSON parse error:", e);
document.getElementById("filearchiv-docbrowser-content").innerHTML =
\'<div class="filearchiv-empty"><i class="fas fa-exclamation-triangle"></i> Response was not JSON</div>\';
}
})
.catch(function(error) {
console.error("FileArchiv: Fetch error:", error);
document.getElementById("filearchiv-docbrowser-content").innerHTML =
\'<div class="filearchiv-empty"><i class="fas fa-exclamation-triangle"></i> ' . $langs->trans('ErrorLoadingDocuments') . '</div>\';
});
},
renderDocuments: function(data) {
var container = document.getElementById("filearchiv-docbrowser-content");
if (!data.success || !data.categories || data.categories.length === 0) {
container.innerHTML = \'<div class="filearchiv-empty"><i class="fas fa-folder-open"></i><br>' . $langs->trans('NoRelatedDocumentsFound') . '</div>\';
return;
}
var html = "";
data.categories.forEach(function(category, catIndex) {
html += \'<div class="filearchiv-category" id="cat-\' + catIndex + \'">\';
html += \'<div class="filearchiv-category-header" onclick="FileArchivDocBrowser.toggleCategory(\' + catIndex + \')">\';
html += \'<i class="fas \' + category.icon + \'"></i>\';
html += \'<span>\' + category.title + \'</span>\';
html += \'<span class="count">\' + category.files.length + \'</span>\';
html += \'<i class="fas fa-chevron-down toggle-icon"></i>\';
html += \'</div>\';
html += \'<div class="filearchiv-category-body">\';
category.files.forEach(function(file, fileIndex) {
var fileId = "file-" + catIndex + "-" + fileIndex;
html += \'<div class="filearchiv-file-item">\';
html += \'<input type="checkbox" id="\' + fileId + \'" onchange="FileArchivDocBrowser.toggleFile(this, \\\'\' + self.escapeHtml(file.fullpath) + \'\\\', \\\'\' + self.escapeHtml(file.name) + \'\\\')">\';
html += \'<i class="fas \' + file.icon + \' file-icon"></i>\';
html += \'<div class="file-info">\';
html += \'<div class="file-name">\' + self.escapeHtml(file.name) + \'</div>\';
html += \'<div class="file-meta">\' + file.size_formatted + \' - \' + file.date + \'</div>\';
if (file.product_ref) {
html += \'<span class="file-source">\' + self.escapeHtml(file.product_ref) + \': \' + self.escapeHtml(file.product_label) + \'</span>\';
} else if (file.object_ref) {
html += \'<span class="file-source">\' + self.escapeHtml(file.object_ref) + \'</span>\';
}
html += \'</div>\';
html += \'</div>\';
});
html += \'</div></div>\';
});
container.innerHTML = html;
// Open first category by default
if (data.categories.length > 0) {
document.getElementById("cat-0").classList.add("open");
}
},
escapeHtml: function(text) {
var div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
},
toggleCategory: function(index) {
var cat = document.getElementById("cat-" + index);
cat.classList.toggle("open");
},
toggleFile: function(checkbox, filepath, filename) {
if (checkbox.checked) {
this.selectedFiles.push({ path: filepath, name: filename });
} else {
this.selectedFiles = this.selectedFiles.filter(function(f) {
return f.path !== filepath;
});
}
this.updateSelectedCount();
},
updateSelectedCount: function() {
document.getElementById("filearchiv-selected-num").textContent = this.selectedFiles.length;
document.getElementById("filearchiv-add-btn").disabled = this.selectedFiles.length === 0;
},
addSelected: function() {
if (this.selectedFiles.length === 0) return;
var self = this;
var addBtn = document.getElementById("filearchiv-add-btn");
addBtn.disabled = true;
addBtn.value = "' . dol_escape_js($langs->trans('Adding')) . '...";
// Add files one by one
var promises = this.selectedFiles.map(function(file) {
return fetch(self.addFileUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "filepath=" + encodeURIComponent(file.path) +
"&filename=" + encodeURIComponent(file.name) +
"&trackid=" + encodeURIComponent(self.trackid) +
"&token=" + encodeURIComponent(self.token)
});
});
Promise.all(promises)
.then(function(responses) {
console.log("FileArchiv: All files added, responses:", responses);
// Parse all responses
return Promise.all(responses.map(function(r) { return r.json(); }));
})
.then(function(results) {
console.log("FileArchiv: Results:", results);
// Check if all succeeded
var allSuccess = results.every(function(r) { return r.success; });
if (allSuccess) {
console.log("FileArchiv: All files added successfully, reloading page...");
// Reload page WITHOUT mode=init (which clears attachments)
var url = new URL(window.location.href);
url.searchParams.delete("mode");
window.location.href = url.toString();
} else {
var errors = results.filter(function(r) { return !r.success; }).map(function(r) { return r.error; });
console.error("FileArchiv: Some files failed:", errors);
alert("' . dol_escape_js($langs->trans('ErrorAddingFiles')) . ': " + errors.join(", "));
addBtn.disabled = false;
addBtn.value = "' . dol_escape_js($langs->trans('AddSelectedFiles')) . '";
}
})
.catch(function(error) {
console.error("FileArchiv: Error adding files:", error);
addBtn.disabled = false;
addBtn.value = "' . dol_escape_js($langs->trans('AddSelectedFiles')) . '";
alert("' . dol_escape_js($langs->trans('ErrorAddingFiles')) . '");
});
}
};
var self = FileArchivDocBrowser;
// Move button to correct position after DOM ready and get trackid from form
document.addEventListener("DOMContentLoaded", function() {
console.log("FileArchiv: DOMContentLoaded triggered");
var container = document.getElementById("filearchiv-browse-container");
if (!container) {
console.log("FileArchiv: Container not found");
return;
}
console.log("FileArchiv: Container found");
// Try to find the file attachment section in the email form
// Try multiple selectors for different Dolibarr versions
var attachSection = document.querySelector("input[name=\\"addedfile\\"]") ||
document.querySelector("input[name=\\"addedfile[]\\"]") ||
document.querySelector("#addedfile") ||
document.querySelector("input[type=\\"file\\"]");
console.log("FileArchiv: attachSection =", attachSection);
if (attachSection) {
var parent = attachSection.closest("td") || attachSection.closest("tr") || attachSection.parentElement;
console.log("FileArchiv: parent =", parent);
if (parent) {
parent.appendChild(container);
container.style.display = "block";
console.log("FileArchiv: Button moved and displayed");
}
} else {
// Fallback: Try to find the mail form and add before submit button
var mailForm = document.getElementById("mailform");
if (mailForm) {
var submitBtn = mailForm.querySelector("input[type=\\"submit\\"]");
if (submitBtn && submitBtn.parentElement) {
submitBtn.parentElement.insertBefore(container, submitBtn);
container.style.display = "block";
console.log("FileArchiv: Button added before submit (fallback)");
}
} else {
// Last resort: just show it where it is
container.style.display = "block";
console.log("FileArchiv: Button displayed in place (last resort)");
}
}
// Get trackid from hidden input in form
var trackidInput = document.querySelector("input[name=\\"trackid\\"]");
if (trackidInput) {
FileArchivDocBrowser.trackid = trackidInput.value;
console.log("FileArchiv: trackid =", trackidInput.value);
}
});
// Close modal on Escape
document.addEventListener("keydown", function(e) {
if (e.key === "Escape" && document.getElementById("filearchiv-docbrowser-modal").classList.contains("active")) {
FileArchivDocBrowser.close();
}
});
</script>';
return $out;
}
}