/** * Frontend extension for the WebDAV sync backend plugin. * * Adds a sync icon and progress bar to the PDF viewer statusbar. * Shows a hover popup with recent sync messages. * Registers the "sync.syncFiles" endpoint so other plugins can trigger synchronization. * * @import { PluginContext } from '../../../../app/src/modules/plugin-context.js' * * @typedef {{ * conflicts?: number, * downloaded?: number, * deleted_local?: number, * deleted_remote?: number, * errors?: number, * metadata_synced?: number, * uploaded?: number, * new_version?: number, * duration_ms?: number, * message?: string, * skipped?: boolean * }} SyncResult */ const MAX_MESSAGES = 50; export default class WebdavSyncExtension extends FrontendExtensionPlugin { constructor(/** @type {PluginContext} */ context) { super(context, { name: 'webdav-sync-extension' }); } static extensionPoints = ['sync.syncFiles']; /** @type {HTMLElement|undefined} */ _syncContainer; /** @type {HTMLElement|undefined} */ _syncIcon; /** @type {HTMLElement|undefined} */ _syncProgressWidget; /** @type {HTMLElement|undefined} */ _messagePopup; /** @type {HTMLElement|undefined} */ _messageList; /** @type {string[]} */ _messageLog = []; async install(state) { await super.install(state); const sse = this.getDependency('sse'); sse.addEventListener('syncProgress', (event) => { const progress = parseInt(event.data); if (this._syncProgressWidget && this._syncProgressWidget.isConnected) { this._syncProgressWidget.indeterminate = false; this._syncProgressWidget.value = progress; } }); sse.addEventListener('syncMessage', (event) => { console.debug(`Sync: ${event.data}`); this._addMessage(event.data); }); this._syncProgressWidget = document.createElement('status-progress'); this._syncProgressWidget.indeterminate = false; this._syncProgressWidget.value = 0; this._syncProgressWidget.hidePercentage = true; this._syncProgressWidget.style.minWidth = '40px'; this._syncProgressWidget.style.maxWidth = '75px'; this._syncIcon = document.createElement('sl-icon'); this._syncIcon.name = 'arrow-repeat'; this._syncIcon.style.marginRight = '4px'; this._syncIcon.style.cursor = 'pointer'; this._syncIcon.title = 'Click to sync files'; this._syncIcon.addEventListener('click', (e) => { e.stopPropagation(); this._onClickSyncBtn(); }); this._syncContainer = document.createElement('div'); this._syncContainer.style.display = 'flex'; this._syncContainer.style.alignItems = 'center'; this._syncContainer.appendChild(this._syncIcon); this._syncContainer.appendChild(this._syncProgressWidget); this._syncContainer.addEventListener('click', (e) => { e.stopPropagation(); this._togglePopup(); }); this._messagePopup = document.createElement('div'); this._messagePopup.style.cssText = [ 'position: fixed', 'display: none', 'background: var(--sl-color-neutral-900, #1a1a2e)', 'color: var(--sl-color-neutral-0, #fff)', 'font-size: 11px', 'font-family: var(--sl-font-mono, monospace)', 'line-height: 1.5', 'padding: 6px 8px', 'border-radius: 4px', 'box-shadow: 0 2px 8px rgba(0,0,0,0.4)', 'max-width: 420px', 'min-width: 200px', 'z-index: 9999', ].join(';'); this._messagePopup.addEventListener('click', (e) => e.stopPropagation()); this._messageList = document.createElement('div'); this._messageList.style.cssText = 'max-height: 180px; overflow-y: auto;'; this._messagePopup.appendChild(this._messageList); document.body.appendChild(this._messagePopup); document.addEventListener('click', () => this._hidePopup()); this.getDependency('ui').pdfViewer.statusbar.add(this._syncContainer, 'right', 3); const syncIntervalSeconds = await this.getDependency('config').get('plugin.webdav-sync.sync-interval', 0); if (syncIntervalSeconds > 0) { console.debug(`WebDAV sync: periodic sync every ${syncIntervalSeconds}s`); setInterval(async () => { try { const summary = await this.syncFiles(); if (summary && (summary.downloaded || summary.deleted_local || summary.conflicts)) { await this.getDependency('file-selection').reload({ refresh: true }); } } catch (e) { console.error('Periodic sync failed:', e); } }, syncIntervalSeconds * 1000); } } /** * Implements sync.syncFiles endpoint. * Called by other plugins to trigger synchronization. * @returns {Promise} */ async syncFiles() { console.debug('WebDAV sync: synchronizing files on the server'); this._messageLog.length = 0; if (this._syncIcon) this._syncIcon.classList.add('rotating'); if (this._syncProgressWidget) { this._syncProgressWidget.indeterminate = true; this._syncProgressWidget.value = 0; } try { const response = await this.getDependency('client').apiClient.pluginsExecute('webdav-sync', { endpoint: 'execute', params: {} }); const summary = response?.result; if (summary && summary.skipped) { const reason = summary.message || 'already in sync'; console.debug(`Sync skipped: ${reason}`); this._addMessage(`Sync skipped: ${reason}`); } else if (summary) { console.debug(`Sync completed: ${JSON.stringify(summary)}`); this._addMessage(_formatSummary(summary)); } return summary; } finally { if (this._syncIcon) this._syncIcon.classList.remove('rotating'); if (this._syncProgressWidget) { this._syncProgressWidget.indeterminate = false; this._syncProgressWidget.value = 0; } } } /** @param {string} text */ _addMessage(text) { this._messageLog.push(text); if (this._messageLog.length > MAX_MESSAGES) this._messageLog.shift(); this._renderMessages(); } _renderMessages() { if (!this._messageList) return; this._messageList.innerHTML = ''; if (this._messageLog.length === 0) { const placeholder = document.createElement('div'); placeholder.textContent = 'No sync messages yet'; placeholder.style.opacity = '0.5'; this._messageList.appendChild(placeholder); } else { for (const msg of this._messageLog) { const row = document.createElement('div'); row.textContent = msg; row.style.whiteSpace = 'nowrap'; this._messageList.appendChild(row); } this._messageList.scrollTop = this._messageList.scrollHeight; } } _positionPopup() { if (!this._syncContainer || !this._messagePopup) return; const rect = this._syncContainer.getBoundingClientRect(); this._messagePopup.style.left = `${rect.left}px`; this._messagePopup.style.bottom = `${window.innerHeight - rect.top + 6}px`; } _showPopup() { if (!this._messagePopup) return; this._renderMessages(); this._positionPopup(); this._messagePopup.style.display = 'block'; } _hidePopup() { if (this._messagePopup) this._messagePopup.style.display = 'none'; } _togglePopup() { if (!this._messagePopup) return; if (this._messagePopup.style.display === 'none' || !this._messagePopup.style.display) { this._showPopup(); } else { this._hidePopup(); } } async _onClickSyncBtn() { try { await this.syncFiles(); } catch (e) { console.error('Sync failed:', e); } await this.getDependency('file-selection').reload({ refresh: true }); } } /** * Format a SyncResult into a human-readable summary line. * @param {SyncResult} summary * @returns {string} */ function _formatSummary(summary) { const parts = []; if (summary.uploaded) parts.push(`↑${summary.uploaded}`); if (summary.downloaded) parts.push(`↓${summary.downloaded}`); if (summary.metadata_synced) parts.push(`≡${summary.metadata_synced}`); if (summary.deleted_remote) parts.push(`remote_del:${summary.deleted_remote}`); if (summary.deleted_local) parts.push(`local_del:${summary.deleted_local}`); if (summary.conflicts) parts.push(`conflicts:${summary.conflicts}`); if (summary.errors) parts.push(`errors:${summary.errors}`); return parts.length ? `Sync done — ${parts.join(' ')}` : 'Sync done — no changes'; }