81a93a489b
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists. Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets. Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
9.7 KiB
JavaScript
271 lines
9.7 KiB
JavaScript
/**
|
|
* OxiCloud WOPI Editor Integration
|
|
*
|
|
* Opens document files in Collabora Online / OnlyOffice via WOPI protocol.
|
|
* Supports two modes: in-app modal (default) and new browser tab.
|
|
*/
|
|
|
|
import { loadFiles } from '../../app/filesView.js';
|
|
import { ui } from '../../app/ui.js';
|
|
import { i18n } from '../../core/i18n.js';
|
|
|
|
class WopiEditor {
|
|
constructor() {
|
|
this.editorModal = null;
|
|
this._escHandler = null;
|
|
this._messageHandler = null;
|
|
this._supportedExtensions = null;
|
|
}
|
|
|
|
/**
|
|
* Check if a file can be opened in a WOPI editor by extension.
|
|
* Fetches supported extensions from the server (cached after first call).
|
|
* @param {string} filename
|
|
*/
|
|
async canEdit(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const supported = await this._getSupportedExtensions();
|
|
return supported.includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Open file in a modal overlay (default mode).
|
|
* @param {string} fileId
|
|
* @param {string} fileName
|
|
* @param {string} [action]
|
|
*/
|
|
async openInModal(fileId, fileName, action) {
|
|
action = action || 'edit';
|
|
// Error is thrown, caller can handle a classic view as fallback
|
|
var data = await this._getEditorUrlWithFallback(fileId, fileName, action);
|
|
this._showModal(data, fileName);
|
|
}
|
|
|
|
/**
|
|
* Open file in a new browser tab.
|
|
* @param {string} fileId
|
|
* @param {string} fileName
|
|
* @param {string} [action]
|
|
*/
|
|
async openInTab(fileId, fileName, action) {
|
|
action = action || 'edit';
|
|
try {
|
|
const data = await this._getEditorUrlWithFallback(fileId, fileName, action);
|
|
const hostUrl = `/wopi/edit/${encodeURIComponent(fileId)}?access_token=${encodeURIComponent(data.access_token)}`;
|
|
window.open(hostUrl, '_blank');
|
|
} catch (error) {
|
|
console.error('Failed to open WOPI editor in tab:', error);
|
|
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.wopiOpenError'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch editor URL and WOPI token from the backend.
|
|
* @param {string} fileId
|
|
* @param {string} action
|
|
*/
|
|
async _getEditorUrl(fileId, action) {
|
|
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Editor URL request failed: ${response.status} ${text}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Some WOPI file types, such as PDFs, are view-only.
|
|
* If an edit request returns 422, retry once in view mode.
|
|
* @param {string} fileId
|
|
* @param {string} fileName
|
|
* @param {string} action
|
|
*/
|
|
async _getEditorUrlWithFallback(fileId, fileName, action) {
|
|
try {
|
|
return await this._getEditorUrl(fileId, action);
|
|
} catch (error) {
|
|
if (!this._shouldRetryInViewMode(fileName, action, error)) {
|
|
throw error;
|
|
}
|
|
|
|
return this._getEditorUrl(fileId, 'view');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} fileName
|
|
* @param {string} action
|
|
* @param {any} error
|
|
*/
|
|
_shouldRetryInViewMode(fileName, action, error) {
|
|
if (action !== 'edit' || !error || !error.message) {
|
|
return false;
|
|
}
|
|
|
|
var ext = fileName.split('.').pop().toLowerCase();
|
|
return ext === 'pdf' && error.message.indexOf('422') !== -1;
|
|
}
|
|
|
|
/**
|
|
* Show the editor in a full-screen modal with iframe.
|
|
* @param {Record<string, any>} editorData
|
|
* @param {string} fileName
|
|
*/
|
|
_showModal(editorData, fileName) {
|
|
this.closeEditor();
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'wopi-editor-modal';
|
|
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;background:#fff;';
|
|
|
|
const header = document.createElement('div');
|
|
header.style.cssText =
|
|
'height:40px;background:#333;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-family:sans-serif;font-size:14px;';
|
|
|
|
const title = document.createElement('span');
|
|
title.textContent = fileName;
|
|
header.appendChild(title);
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.textContent = '\u2715';
|
|
closeBtn.style.cssText = 'background:none;border:none;color:#fff;cursor:pointer;font-size:18px;padding:4px 8px;';
|
|
closeBtn.onclick = this.closeEditor.bind(this);
|
|
header.appendChild(closeBtn);
|
|
|
|
const form = document.createElement('form');
|
|
form.id = 'wopi_form';
|
|
form.target = 'wopi_frame';
|
|
form.action = editorData.editor_url;
|
|
form.method = 'post';
|
|
form.style.display = 'none';
|
|
|
|
const tokenInput = document.createElement('input');
|
|
tokenInput.name = 'access_token';
|
|
tokenInput.value = editorData.access_token;
|
|
tokenInput.type = 'hidden';
|
|
form.appendChild(tokenInput);
|
|
|
|
const ttlInput = document.createElement('input');
|
|
ttlInput.name = 'access_token_ttl';
|
|
ttlInput.value = editorData.access_token_ttl;
|
|
ttlInput.type = 'hidden';
|
|
form.appendChild(ttlInput);
|
|
|
|
const frameHolder = document.createElement('div');
|
|
frameHolder.style.cssText = 'position:absolute;top:40px;left:0;right:0;bottom:0;';
|
|
|
|
// Loading spinner (removed once the editor signals ready)
|
|
const spinner = document.createElement('div');
|
|
spinner.id = 'wopi-loading-spinner';
|
|
spinner.style.cssText =
|
|
'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;z-index:1;';
|
|
spinner.innerHTML = '<i class="fas fa-spinner fa-spin empty-state-icon spinner"></i>';
|
|
frameHolder.appendChild(spinner);
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.name = 'wopi_frame';
|
|
iframe.title = 'Document Editor';
|
|
iframe.style.cssText = 'width:100%;height:100%;border:none;';
|
|
iframe.setAttribute('allowfullscreen', 'true');
|
|
// Fix 9: allow clipboard access for copy/paste inside the editor
|
|
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
|
|
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox');
|
|
frameHolder.appendChild(iframe);
|
|
|
|
modal.appendChild(header);
|
|
modal.appendChild(form);
|
|
modal.appendChild(frameHolder);
|
|
document.body.appendChild(modal);
|
|
|
|
// ESC key handler
|
|
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
|
|
if (e.key === 'Escape') this.closeEditor();
|
|
};
|
|
document.addEventListener('keydown', this._escHandler);
|
|
|
|
// Fix 7: Listen for postMessage from the editor iframe
|
|
this._messageHandler = (/** @type {MessageEvent} */ e) => {
|
|
var data;
|
|
try {
|
|
data = JSON.parse(e.data);
|
|
} catch (_) {
|
|
return; // Not a JSON message — ignore
|
|
}
|
|
var msgId = data.MessageId || data.messageId || '';
|
|
if (msgId === 'UI_Close' || msgId === 'close') {
|
|
this.closeEditor();
|
|
} else if (msgId === 'App_LoadingStatus') {
|
|
const status = data.Values?.Status;
|
|
if (status === 'Document_Loaded' || status === 'Frame_Ready') {
|
|
const sp = document.getElementById('wopi-loading-spinner');
|
|
if (sp) sp.remove();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('message', this._messageHandler);
|
|
|
|
form.submit();
|
|
this.editorModal = modal;
|
|
}
|
|
|
|
/**
|
|
* Close the editor modal and refresh the file list.
|
|
*/
|
|
closeEditor() {
|
|
var modal = document.getElementById('wopi-editor-modal');
|
|
if (modal) modal.remove();
|
|
if (this._escHandler) {
|
|
document.removeEventListener('keydown', this._escHandler);
|
|
this._escHandler = null;
|
|
}
|
|
if (this._messageHandler) {
|
|
window.removeEventListener('message', this._messageHandler);
|
|
this._messageHandler = null;
|
|
}
|
|
this.editorModal = null;
|
|
|
|
// Refresh file list to pick up any saves
|
|
loadFiles();
|
|
}
|
|
|
|
/**
|
|
* Fetch supported extensions from the server (cached).
|
|
*/
|
|
async _getSupportedExtensions() {
|
|
if (this._supportedExtensions !== null) {
|
|
return this._supportedExtensions;
|
|
}
|
|
return this._fetchSupportedExtensions();
|
|
}
|
|
|
|
/**
|
|
* Fetch supported extensions from /wopi/supported-extensions.
|
|
* Falls back to a hardcoded list on failure.
|
|
*/
|
|
async _fetchSupportedExtensions() {
|
|
try {
|
|
const response = await fetch('/wopi/supported-extensions');
|
|
if (response.ok) {
|
|
const exts = await response.json();
|
|
if (Array.isArray(exts) && exts.length > 0) {
|
|
this._supportedExtensions = exts;
|
|
return exts;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Ignore — fall through to hardcoded list
|
|
}
|
|
// Fallback hardcoded list
|
|
this._supportedExtensions = ['docx', 'doc', 'odt', 'rtf', 'txt', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp'];
|
|
return this._supportedExtensions;
|
|
}
|
|
}
|
|
|
|
// Global instance
|
|
export const wopiEditor = new WopiEditor();
|
|
|
|
// Prefetch supported extensions so canEdit() is fast on first use
|
|
wopiEditor._fetchSupportedExtensions();
|